Author SHA1 Message Date
safavimohadeseh78andCursor 866be22395 feat: complete the thirteen-stage financial learning path
Backend CI / type-check, build, tests (push) Successful in 1m16s
Server grades levels 7-13 and keeps castle attempt results separate from stored progress.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 19:41:24 +03:30
safavimohadeseh78andCursor dfb20ca92c feat: prepare six-lesson trial backend with reliable rewards and operations
Backend CI / type-check, build, tests (push) Successful in 1m0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:35:42 +03:30
safavimohadeseh78andCursor 5e5a14030a chore(docker): refine compose for named volume server deploy
Use a named SQLite volume, healthcheck, and clearer production env examples.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:20:27 +02:00
safavimohadeseh78andCursor 2c5aa01a9a feat(db): add versioned SQLite migrations
Track applied schema changes in schema_migrations and run pending migrations on startup.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 10:20:27 +02:00
safavimohadeseh78andCursor 1261a9b482 feat(docker): add multi-stage image and compose with persistent SQLite
Mount ./docker-data for WAL-capable SQLite storage, support DATABASE_PATH alias, and document compose secrets via .env.docker.example.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 08:21:51 +02:00
safavimohadeseh78andCursor e42386a5ee fix(auth): reset demo password only outside production
Keep demo login predictable in development without overwriting stored hashes when NODE_ENV is production.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 08:21:04 +02:00
safavimohadeseh78andCursor d512863aa7 chore: ignore build artifacts and SQLite runtime files
Stop tracking generated tsbuildinfo cache and expand gitignore for WAL/SHM, backups, and docker test data.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 08:21:03 +02:00
102 changed files with 6714 additions and 203 deletions
+16
View File
@@ -0,0 +1,16 @@
node_modules
dist
data
docker-data
backups
.env
.env.docker
.git
.gitignore
*.db
*.db-wal
*.db-shm
*.tsbuildinfo
test
coverage
.cursor-sqlite-audit.cjs
+7
View File
@@ -0,0 +1,7 @@
# Copy to .env.docker on the server (never commit real secrets).
#
# Required in production:
JWT_SECRET=replace-with-a-long-random-secret
#
# Frontend origin (your deployed Vue app URL):
CORS_ORIGIN=https://your-frontend-domain.example
+12 -6
View File
@@ -1,13 +1,19 @@
# Server — production cutover uses PORT=3000 (replaces legacy Fastify)
# Server — production trial uses PORT=3000
PORT=3000
HOST=0.0.0.0
NODE_ENV=development
# CORS (Vue dev server)
CORS_ORIGIN=http://localhost:5173
# CORS — exact browser origin(s); comma-separated for multiple trial hosts
CORS_ORIGIN=http://localhost:5174
# SQLite — production database
# DB_PATH=E:/absolute/path/to/backend-nest/data/app.db
# SQLite — persistent path (container: /app/data/app.db)
# DB_PATH=/app/data/app.db
# DATABASE_PATH=/app/data/app.db
# JWT — must match legacy Fastify when migrating existing tokens
# JWT — required in production (min 32 chars, not a placeholder below)
JWT_SECRET=gold-land-dev-secret-change-in-production
# Only when behind a known reverse proxy (optional in development)
# TRUST_PROXY=false
# Required in production when TRUST_PROXY=true — set your proxy/Docker network CIDR(s), not empty:
# TRUST_PROXY_CIDRS=172.20.0.0/16
+39
View File
@@ -0,0 +1,39 @@
name: Backend CI
on:
push:
pull_request:
env:
NODE_VERSION: '22.18.0'
jobs:
checks:
name: type-check, build, tests
runs-on: ubuntu-latest
timeout-minutes: 25
env:
JWT_SECRET: ci-test-jwt-not-for-production
NODE_ENV: test
steps:
- name: Checkout backend
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies (npm ci)
run: npm ci
- name: Type check
run: npm run type-check
- name: Build
run: npm run build
- name: Tests (temporary SQLite only)
run: npm run test
+10
View File
@@ -1,5 +1,15 @@
node_modules/
dist/
.idea/
data/
.env
.env.docker
*.db
*.db-wal
*.db-shm
*.tsbuildinfo
backups/
docker-data/
data/*.db
data/*.db-wal
data/*.db-shm
+1
View File
@@ -0,0 +1 @@
22.18.0
+53
View File
@@ -0,0 +1,53 @@
# syntax=docker/dockerfile:1
FROM node:22.18.0-bookworm-slim AS builder
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci
COPY nest-cli.json tsconfig.json tsconfig.build.json ./
COPY src ./src
RUN npm run build
FROM node:22.18.0-bookworm-slim AS production
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
HOST=0.0.0.0 \
DB_PATH=/app/data/app.db
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci --omit=dev \
&& npm rebuild better-sqlite3 \
&& apt-get purge -y python3 make g++ \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/dist ./dist
RUN mkdir -p /app/data \
&& chown -R node:node /app
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/health/ready').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/main.js"]
+132 -11
View File
@@ -6,23 +6,111 @@ Legacy Fastify `backend/` was removed in G4 (2026-08-01).
## Requirements
- Node.js **22+** (`nvm use 22.12.0`)
- **Node.js:** `^22.18.0` or `>=24.12.0` (`package.json` → `engines`)
- **Pinned for local dev / CI:** `22.18.0` (see `.nvmrc`)
```powershell
nvm install 22.18.0
nvm use 22.18.0
```
After switching Node on a new machine, ensure **better-sqlite3** matches the Node ABI:
```powershell
npm ci
# if load fails: npm rebuild better-sqlite3
```
On Linux CI/runners, native build needs `build-essential` and `python3`.
## Configuration
Copy `.env.example` to `.env` or use the checked-in local `.env` (gitignored):
Copy `.env.example` to `.env` for a new machine. Do not commit `.env`.
| Variable | Production cutover |
| Variable | Local development |
|----------|-------------------|
| `PORT` | `3000` |
| `DB_PATH` | `backend-nest/data/app.db` |
| `JWT_SECRET` | Same as legacy Fastify |
| `CORS_ORIGIN` | `http://localhost:5173` |
| `CORS_ORIGIN` | `http://localhost:5174` (Vite dev server) |
| `DB_PATH` | optional; defaults to `backend-nest/data/app.db` |
| `JWT_SECRET` | dev value in `.env.example`; required in production |
## Development
**CI/tests** use temporary SQLite paths or env overrides — never commit or upload `data/app.db`.
## Development (this repo)
```powershell
npm run dev
npm ci
npm run build # first time or after src changes if not using dev watch
npm run dev # nest start --watch
# or run compiled:
npm run start
```
- Health: http://127.0.0.1:3000/health
- API prefix: `/api/...`
## Frontend (sibling repo)
Vue app: `gold_land_user` on Gitea — dev http://localhost:5174, proxies `/api` to this backend.
## CI (Gitea Actions)
Workflow: [`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) (runs on **push** / **pull_request** when **act_runner** is available — **not verified** on `get.feep.ir` from this repo alone).
### Job **checks**
```powershell
npm ci
npm run type-check
npm run build
npm run test
```
- **Node:** `22.18.0`
- **Env in job:** `NODE_ENV=test`, `JWT_SECRET=ci-test-jwt-not-for-production` (not production secrets)
- **Timeout:** 25 minutes
- **Cache:** npm optional via `actions/setup-node`
### Local equivalent
Same commands as the CI job above. Use a **clean** tree:
```powershell
Remove-Item -Recurse -Force node_modules -ErrorAction SilentlyContinue
npm ci
npm run type-check
npm run build
npm run test
```
### E2E (frontend repo)
Browser E2E lives in **frontend** (`.gitea/workflows/ci.yml` job **e2e**). It clones this repo at a **pinned ref** (`frontend/e2e/backend.contract.json` + optional variable `E2E_BACKEND_REF`). Backend CI does not run Playwright.
### Runner prerequisites
- Node **22.18.0**
- Toolchain for **better-sqlite3** native module on Linux
### Secrets
This workflow needs **no** repository secrets. Frontend E2E may use `E2E_BACKEND_CHECKOUT_TOKEN` to clone this repo if it is private.
### Not yet verified on a real runner
- Workflow execution on `get.feep.ir` with registered **act_runner**
- `npm ci` + full test suite on a clean Linux runner (validated locally in a temp checkout — see stage 7 report)
## Optional: start backend + frontend (Windows)
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\dev-local-stack.ps1
```
Test ports + temp DB (does not touch `data/app.db`):
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\dev-local-stack.ps1 -UseTestPorts -SmokeTest
```
## Production
@@ -32,10 +120,43 @@ npm run build
npm run start
```
Set `NODE_ENV=production` and a strong `JWT_SECRET` via environment or `.env` on the server.
## Tests
Uses **temporary SQLite files only** — not `data/app.db`.
```powershell
npm run type-check
npm run test:progression
npm run test:integration
npm run test
```
Includes: progression, integration, concurrency, gold-precision, lesson-rewards, dual-connection.
Removed historical `test:cutover:*` scripts — see [docs/cutover-scripts-removed.md](docs/cutover-scripts-removed.md).
## Docker
`docker-compose.yml` in this repo runs backend only. Full stack compose lives in the **frontend** repo.
Trial deploy guide (6 lessons, placeholders, backup, rollback): [../frontend/docs/DEPLOY-TRIAL.md](../frontend/docs/DEPLOY-TRIAL.md) when repos are siblings, or `frontend/docs/DEPLOY-TRIAL.md` in the user repo.
## Backup / restore (SQLite)
WAL-safe backup (not raw copy):
```powershell
$env:DB_PATH = ".\data\app.db"
$env:BACKUP_DIR = ".\backups"
$env:BACKUP_RETENTION_DAYS = "14"
npm run backup:sqlite
npm run restore:sqlite -- backups\app-....db .\restored-trial.db
```
**Live database path:** the script cannot restore directly onto configured `DB_PATH`. Stop the service, restore to a sibling file, swap files, remove stale `-wal`/`-shm`. See [docs/restore-production-db.md](docs/restore-production-db.md). `RESTORE_ACK_STOPPED=YES` is operator acknowledgment only — not a service stop detector.
## Production smoke (local, temp DB)
```powershell
npm run build
npm run smoke:trial-production
```
+37
View File
@@ -0,0 +1,37 @@
services:
backend:
build:
context: .
dockerfile: Dockerfile
image: child-gold-land-backend:latest
container_name: child-gold-land-backend
ports:
- "${PORT:-3000}:3000"
env_file:
- .env.docker
environment:
NODE_ENV: production
PORT: 3000
HOST: 0.0.0.0
DB_PATH: /app/data/app.db
volumes:
# Persistent SQLite storage (WAL/SHM live here too).
# On server, replace with e.g. /var/lib/child-gold-land/data:/app/data
- sqlite_data:/app/data
restart: unless-stopped
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
volumes:
sqlite_data:
name: child-gold-land-sqlite
+17
View File
@@ -0,0 +1,17 @@
# Cutover npm scripts (removed)
Historical migration scripts lived at `scripts/cutover/` (workspace parent of `backend-nest`). That folder was not recovered after the 2026-08 incident.
The following **npm scripts were removed** from `package.json` because they pointed at missing files:
- `test:cutover:g1a`
- `test:cutover:g1b`
- `test:cutover:g1b2b`
- `test:cutover:g1b:dry-run`
**Use instead:**
- `npm run test:progression` — Gold Land progression, grader, `reward_ledger`
- `npm run test:integration` — auth, economy, API contracts (temporary SQLite only)
To recover old cutover tooling, restore from Git history or the backend remote archive; do not re-add broken script paths without the files.
+57
View File
@@ -0,0 +1,57 @@
# وضعیت مسیر آموزشی ۱۳ مرحله
تاریخ بررسی: ۲۰۲۶-۱۰-۲۲. این سند وضعیت محلی commit‌نشده است. موفقیت CI قبلی با SHA قدیمی backend این تغییرات را تأیید نمی‌کند.
## وضعیت مراحل
هر ۱۳ مرحله در هر دو فهرست `gold-land/levels` و `progression/levels` فعال است. مرحله ۱۴ وجود ندارد.
| مرحله | عنوان | نوع | کلید |
| ---: | --- | --- | --- |
| ۱ | پول چیه؟ | lesson | what-is-money |
| ۲ | پس‌انداز | lesson | saving |
| ۳ | نیاز و خواسته | lesson | needs-and-wants |
| ۴ | طلا چیه؟ | lesson | what-is-gold |
| ۵ | بازار طلا | lesson | gold-market |
| ۶ | تورم | lesson | inflation |
| ۷ | بودجه‌بندی | game | budgeting |
| ۸ | ریسک و بازده | challenge | risk-return |
| ۹ | سبد سرمایه‌گذاری | game | portfolio |
| ۱۰ | سود مرکب | lesson | compound-interest |
| ۱۱ | خرید هوشمند | game | smart-shop |
| ۱۲ | هدف مالی | challenge | financial-goal |
| ۱۳ | آزمون قلعه | final_exam | castle-exam |
کارت «چالش آموزشی» خانه و مسیر `/challenge` چالش روزانه کاتالوگ است و آزمون نقشه نیست.
## اجرای بررسی
Node مورد انتظار در تنظیمات `22.18.0` است. اگر همان نسخه نصب نباشد، نسخه واقعی را گزارش کن و `.nvmrc`، engines و CI را عوض نکن.
```text
cd backend-nest
npm run type-check
npm run build
npm test
cd frontend
npm test
npm run type-check
npm run build
$env:PLAYWRIGHT_CHROME_CHANNEL='chrome'
npx playwright test -c e2e/playwright.config.ts
```
`npm test` در backend شامل suiteهای مراحل ۷ تا ۱۳ است. `frontend/test/run-all-tests.mjs` هم همان‌ها را دارد. Playwright همه فایل‌های `e2e/*.spec.ts` را، از جمله `full-path.spec.ts`، اجرا می‌کند. journey کامل فقط روی پروژه desktop است تا زمان کنترل شود. تست‌های موبایل و دسکتاپ اختصاصی مراحل جدا مانده‌اند.
تست‌ها باید با دیتابیس و حساب موقت باشند.
## نتیجه تلاش و بهترین پیشرفت
امتیاز پاسخ complete، نتیجه همان تلاش است. ستاره ذخیره‌شده، بهترین امتیاز و `completed_at` فقط وقتی تلاش به ستاره برسد به‌روز می‌شوند.
تلاش زیر ۶۰ در مراحل ۱ تا ۱۲ رد می‌شود. در آزمون قلعه همان شکل پاسخ برمی‌گردد، ولی completion و پاداش ثبت نمی‌شود. اگر قبلاً قبول شده باشد، تلاش ناموفق بعدی ستاره، بهترین امتیاز و تاریخ تکمیل قبلی را نگه می‌دارد و پاداش صفر است. پیام «آفرین» فقط وقتی نتیجه همین تلاش معتبر است نشان داده می‌شود، نه به‌خاطر ستاره قبلی.
## کارهای باز
Docker آزمایشی، انتشار، چالش روزانه واقعی، ارزیابی بازی عمومی، و Friends هنوز انجام نشده‌اند. `pinnedRef` در قرارداد E2E هنوز SHA قدیمی backend است.
+48
View File
@@ -0,0 +1,48 @@
# مرحله ۱۰ — سود مرکب
## هدف
کودک فرق رشد بر مبلغ اولیه و رشد بر موجودی جدید را در یک مثال ریاضی ببیند. این مرحله وعده سود واقعی یا پیشنهاد سرمایه‌گذاری نیست. موجودی شبیه‌سازی از کیف پول کاربر جداست.
## فرض‌ها
- مبلغ اولیه ۱۰۰ سکه آموزشی.
- نرخ فرضی ثابت ۱۰٪ در هر دوره.
- هزینه، زیان و تورم در محاسبه مثال وارد نمی‌شوند و در متن توضیح داده می‌شوند.
- واحد زمان «دوره فرضی» است، نه سال یا ماه.
- احتمال و قرعه‌کشی وجود ندارد.
## فرمول
مبالغ با واحد صحیح یک‌دهم سکه نگه داشته می‌شوند تا ۱۳۳٫۱ به اعشار شناور خراب نشود. ۱۰۰ سکه = ۱۰۰۰ واحد.
رشد ساده در دوره `n` (از ۱):
`شروع = ۱۰۰۰ + ۱۰۰ × (n − ۱)` و `رشد = ۱۰۰` و `پایان = شروع + رشد`
رشد مرکب:
`رشد = شروع × ۱۰ / ۱۰۰` و `پایان = شروع + رشد` و شروع دوره بعد برابر پایان همین دوره است.
| دوره | ساده | مرکب |
| --- | ---: | ---: |
| ۰ | ۱۰۰ | ۱۰۰ |
| ۱ | ۱۱۰ | ۱۱۰ |
| ۲ | ۱۲۰ | ۱۲۱ |
| ۳ | ۱۳۰ | ۱۳۳٫۱ |
دوره دوم مرکب: ۱۱۰ + ۱۱ = ۱۲۱. دوره سوم مرکب: ۱۲۱ + ۱۲٫۱ = ۱۳۳٫۱.
مرور دکمه‌های دوره فقط تعداد ردیف‌های دیده‌شده را عوض می‌کند و مبلغ‌ها را تغییر نمی‌دهد.
## نمره
پنج سؤال، هرکدام ۲۰. زیر ۶۰ تکمیل نمی‌شود. سرعت و کلیک امتیاز نیست. پاسخ درست و توضیح در GET عمومی نیست؛ توضیح بعد از complete می‌آید.
1. `compound-q1` = `on-balance`
2. `compound-q2` = `121`
3. `compound-q3` = `from-110`
4. `compound-q4` = `compound`
5. `compound-q5` = `false`
پاداش پایه ۴۵ XP و ۱۸ سکه است.
+35
View File
@@ -0,0 +1,35 @@
# مرحله ۱۱ — خرید هوشمند
## هدف
کودک خرید را با نیاز، هزینه نهایی، اطلاعات کافی و بودجه مقایسه کند. ارزان‌ترین عدد روی کارت یا بزرگ‌ترین تخفیف همیشه بهترین انتخاب نیست. فروشگاه، کالا و سکه خیالی‌اند و از کیف پول بازی کم نمی‌شوند.
## هزینه‌ها
هزینه نهایی فقط وقتی هر دو مبلغ معلوم باشند: `قیمت + تحویل`. مبلغ نامشخص صفر نیست و جمع نهایی هم نامشخص می‌ماند.
| پیشنهاد | قیمت | تحویل | جمع |
| --- | ---: | ---: | ---: |
| دفتر ب | ۴۰ | ۵ | ۴۵ |
| دفتر ج | ۳۸ | ۱۲ | ۵۰ |
| مداد با تخفیف ۲۵٪ | ۳۰ | رایگان | ۳۰ |
| مداد بدون تخفیف | ۲۸ | رایگان | ۲۸ |
| وسیله با اندازه نامشخص | ۴۵ | نامشخص | نامشخص |
انتخاب مناسب دفتر، پیشنهاد ب است. برای دو مداد یکسان، ۲۸ از ۳۰ کمتر است. اگر اندازه یا تحویل نامشخص باشد، خرید فوری مناسب نیست.
## نمره
شش پاسخ با وزن برابر:
`score = Math.round(correctCount × 100 / 6)`
| درست | ۰ | ۱ | ۲ | ۳ | ۴ | ۵ | ۶ |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| امتیاز | ۰ | ۱۷ | ۳۳ | ۵۰ | ۶۷ | ۸۳ | ۱۰۰ |
زیر ۶۰ تکمیل نمی‌شود. ۴ درست یک ستاره، ۵ درست دو ستاره، ۶ درست سه ستاره. پاداش پایه ۴۵ XP و ۱۸ سکه است. سرعت و تعداد باز کردن کارت‌ها امتیاز نیست.
شناسه‌ها: `notebook-choice=b`، `notebook-why=fits-and-cheaper`، `discount-choice=b`، `discount-why=pay-amount`، `info-choice=ask`، `info-why=incomplete`.
ارسال فقط `{ questionId, answerId }` است. مبلغ، تخفیف، امتیاز و پاداش کلاینت پذیرفته نمی‌شود.
+36
View File
@@ -0,0 +1,36 @@
# مرحله ۱۲ — هدف مالی
## هدف
کودک یک خواسته را به هدف مشخص تبدیل کند، مبلغ باقی‌مانده و زمان لازم را حساب کند و بعد از تغییر شرایط برنامه را اصلاح کند. مبالغ خیالی‌اند و از کیف پول بازی کم نمی‌شوند. بدهی، درآمد تضمینی و حذف نیاز ضروری راه‌حل نیستند.
## سناریو
بسته ساخت کاردستی قلعه ۱۲۰ سکه است. موجودی ۳۰، پول هفتگی ۳۰ و نیاز ضروری عادی ۱۰ سکه است. پس‌انداز قابل انتخاب در حالت عادی ۵، ۱۰، ۱۵ یا ۲۰ است. خرج اختیاری = ۳۰ − ۱۰ − پس‌انداز. صفر بودن آن خطا نیست.
مبلغ باقی‌مانده ۹۰ است. تعداد هفته = `ceil(۹۰ / پس‌انداز هفتگی)`.
| پس‌انداز | هفته |
| ---: | ---: |
| ۵ | ۱۸ |
| ۱۰ | ۹ |
| ۱۵ | ۶ |
| ۲۰ | ۵ |
در هفته دوم نیاز ضروری ۲۰ سکه می‌شود. پس‌انداز همان هفته ۰، ۵ یا ۱۰ است. هفته اول با نرخ اولیه ثبت شده می‌ماند. از هفته سوم نرخ ادامه دوباره یکی از ۵، ۱۰، ۱۵ یا ۲۰ است و خودکار پر نمی‌شود.
`موجودی پس از هفته دوم = ۳۰ + نرخ اولیه + نرخ هفته دوم`
`هفته‌های باقی‌مانده = ceil(max(0, ۱۲۰ − موجودی) / نرخ ادامه)`
`کل زمان = ۲ + هفته‌های باقی‌مانده`
مثال ۱۵ / ۵ / ۱۵: موجودی ۵۰، پنج هفته دیگر، کل ۷ هفته، موجودی نهایی ۱۲۵.
## نمره
پنج سؤال ۲۰ امتیازی: ۰، ۴۰، ۶۰، ۸۰، ۱۰۰. سرعت برنامه نمره نیست. زیر ۶۰ تکمیل نمی‌شود. پاداش پایه ۵۰ XP و ۲۰ سکه است.
شناسه‌ها: `q-goal=specific`، `q-left=90`، `q-weeks=6`، `q-need=adjust`، `q-change=update`.
ارسال: `{ questionId: "plan", plan: { initialWeekly, exceptionWeekly, continueWeekly } }` به‌علاوه پنج `{ questionId, answerId }`. زمان و موجودی کلاینت پذیرفته نمی‌شود.
+32
View File
@@ -0,0 +1,32 @@
# مرحله ۱۳ — آزمون قلعه
## هدف
آزمون، آموخته‌های مراحل ۱ تا ۱۲ را مرور می‌کند و مفهوم تازه یاد نمی‌دهد. مثال‌ها خیالی و جدا از کیف پول‌اند. سرعت و حفظ اصطلاح نمره نیست. شکست پایان بازی نیست.
## نگاشت سؤال‌ها
| سؤال | مرحله | پاسخ |
| --- | ---: | --- |
| castle-q1 | ۱ | exchange |
| castle-q2 | ۲ | regular |
| castle-q3 | ۳ | water |
| castle-q4 | ۴ | moves |
| castle-q5 | ۵ | no |
| castle-q6 | ۶ | less |
| castle-q7 | ۷ | valid |
| castle-q8 | ۸ | keep |
| castle-q9 | ۹ | no |
| castle-q10 | ۱۰ | 121 |
| castle-q11 | ۱۱ | b |
| castle-q12 | ۱۲ | 6 |
## امتیاز
`score = Math.round(correctCount × 100 / 12)`
۰، ۸، ۱۷، ۲۵، ۳۳، ۴۲، ۵۰، ۵۸، ۶۷، ۷۵، ۸۳، ۹۲، ۱۰۰.
کمتر از ۶۰ تکمیل نمی‌شود و پاداش ندارد، ولی توضیح سؤال‌ها برمی‌گردد. ۸ درست یک ستاره، ۹ یا ۱۰ دو ستاره، ۱۱ یا ۱۲ سه ستاره. پاداش پایه ۱۰۰ XP و ۵۰ سکه است. بعد از مرحله ۱۳ مرحله دیگری باز نمی‌شود.
ارسال فقط دوازده `{ questionId, answerId }` است. امتیاز و ستاره کلاینت پذیرفته نمی‌شود. پاسخ درست در GET عمومی نیست.
+35
View File
@@ -0,0 +1,35 @@
# مرحله ۷ — مأموریت بودجه‌بندی
- گروه سنی: ۹ تا ۱۲ سال.
- مدت هدف: حدود ۵ تا ۸ دقیقه، بدون محدودیت زمانی.
- لحن: فارسی ساده و صمیمی.
- راهنما: تصویر موجود `professor` با عنوان «راهنما». شخصیت جدید ساخته نشده است.
- نوع فعالیت: `game` با کلید `budgeting`. این مرحله درس پنج‌سؤالی نیست.
## هدف آموزشی
کودک ۱۰۰ سکهٔ آموزشی را بین نیاز سفر (رفت‌وآمد و آب و غذا)، هدف پس‌انداز، و تفریح اختیاری تقسیم می‌کند و بعد از یک تغییر مشخص، برنامه را اصلاح می‌کند. این سکه‌ها از کیف پول کاربر کم نمی‌شوند.
## سناریو
- رفت‌وآمد: ابتدا حداقل ۲۰، بعد از اتفاق حداقل ۳۰.
- آب و غذا: حداقل ۲۵.
- پس‌انداز برای هدف: هدف ۲۰. حداقل ۱۰ برای بخشی از امتیاز.
- تفریح: اختیاری و بدون امتیاز اضافه.
- ذخیره: باقی‌ماندهٔ تخصیص‌نشده. صفر بودن آن خطا نیست و تنها راه درست معرفی نمی‌شود.
- گام تغییر: ۵ سکه.
- اتفاق ثابت: «هزینه رفت‌وآمد ۱۰ سکه بیشتر شد؛ حالا ۳۰ سکه لازم داری.» معرفی فقط می‌گوید ممکن است شرایط تغییر کند.
## Rubric
هر کدام از برنامهٔ اولیه و برنامهٔ نهایی پنج معیار ۱۰امتیازی دارد. مجموع ۱۰۰ است.
1. مجموع تخصیص حداکثر ۱۰۰.
2. تأمین حداقل رفت‌وآمد همان مرحله.
3. تأمین آب و غذا.
4. حداقل ۱۰ سکه پس‌انداز.
5. رسیدن پس‌انداز به ۲۰.
ورودی منفی، غیرصحیح، خارج از گام ۵، مجموع بیش از ۱۰۰، یا فیلد ناشناخته با خطای ورودی رد می‌شود. برنامهٔ اول بدون حداقل ۲۰ رفت‌وآمد و ۲۵ آب‌وغذا، و برنامهٔ نهایی بدون حداقل ۳۰ رفت‌وآمد و ۲۵ آب‌وغذا، با خطای جداگانهٔ نیاز ضروری رد می‌شوند و امتیاز نمی‌گیرند. پس‌انداز، تفریح و ذخیره شرط تأیید نیستند.
پاداش پایهٔ ۴۵ XP و ۱۸ سکه و پاداش هر ستارهٔ تازه (۵ XP و ۳ سکه) فقط از سرور و دفتر پاداش است. امتیاز زیر ۶۰ تکمیل نمی‌شود. مرحلهٔ ۸ غیرفعال می‌ماند.
+31
View File
@@ -0,0 +1,31 @@
# مرحله ۸ — ریسک و بازده
- گروه سنی: ۹ تا ۱۲ سال.
- مدت هدف: حدود ۵ تا ۸ دقیقه، بدون محدودیت زمانی.
- راهنما: تصویر موجود `professor`.
- نوع: `challenge` با کلید `risk-return`.
## هدف آموزشی
کودک تفاوت نتیجهٔ ممکن و نتیجهٔ تضمینی را می‌فهمد، احتمال زیان را تشخیص می‌دهد و انتخاب را با هدف و زمان نیاز به پول هماهنگ می‌کند. امتیاز برای فهم مفهوم و دلیل انتخاب است، نه برای انتخاب پرریسک یا سود اتفاقی.
## مثال ساختگی
برای ۲۰ سکهٔ آموزشی که از کیف پول بازی کم نمی‌شود:
- پیشنهاد الف، چهار پیامد هم‌احتمال: ۲۰، ۲۰، ۲۲، ۲۲.
- پیشنهاد ب، چهار پیامد هم‌احتمال: ۸، ۱۶، ۲۸، ۳۶.
در این مثال هر کارت شانس یکسان دارد. در دنیای واقعی همیشه احتمال‌ها را دقیق نمی‌دانیم. نبود زیان اسمی در پیشنهاد الف فقط مربوط به همین مثال است و تضمین عمومی سرمایه‌گذاری نیست. هیچ دارایی واقعی توصیه نمی‌شود.
## Rubric
پنج فعالیت، هر کدام ۲۰ امتیاز. جمع ۱۰۰.
1. امکان برگشت کمتر از ۲۰: پیشنهاد ب.
2. دریافت قطعی ۳۶ در پیشنهاد ب: خیر.
3. نیاز ضروری تا دو روز دیگر: پول را وارد پیشنهادهای نامطمئن نکن.
4. مقایسه: پیشنهاد ب امکان نتیجهٔ بالاتر و زیان بیشتر دارد؛ سود بیشتر قطعی نیست.
5. وعدهٔ سود زیاد بدون ریسک: تضمین معتبر فرض نکن، بررسی کن و از بزرگسال قابل اعتماد کمک بگیر.
امتیاز به قرعه وابسته نیست. پاسخ درست در محتوای عمومی سؤال نیست. پاداش پایه ۵۰ XP و ۲۰ سکه است. مرحلهٔ ۹ غیرفعال می‌ماند.
+49
View File
@@ -0,0 +1,49 @@
# مرحله ۹ — سبد سرمایه‌گذاری
## هدف آموزشی
کودک ببیند تقسیم ۱۰۰ سکهٔ آموزشی بین گزینه‌هایی با رفتار متفاوت می‌تواند وابستگی به یک گزینه را کم کند. این تقسیم زیان را حذف نمی‌کند و سود را تضمین نمی‌کند. زیاد بودن تعداد اسم‌ها به‌تنهایی تنوع مؤثر نیست.
صندوق‌های «آفتاب»، «باران» و «باد» خیالی‌اند و نمایندهٔ دارایی واقعی نیستند. سکه‌ها از کیف پول بازی کم نمی‌شوند.
## فرض شبیه‌سازی
- فقط سه سناریوی ثابت نشان داده می‌شود. قرعه‌کشی و احتمال وجود ندارد.
- سناریوها مثال‌اند، نه پیش‌بینی و نه مجموعهٔ کامل حالت‌های واقعی.
- هیچ سبدی به‌خاطر بهتر بودن در یک سناریو «بهترین» معرفی نمی‌شود.
| سناریو | آفتاب | باران | باد |
| --- | ---: | ---: | ---: |
| هوای آفتابی | +۲۰٪ | −۱۰٪ | ۰٪ |
| روز بارانی | −۲۰٪ | +۱۰٪ | ۰٪ |
| افت همگانی | −۱۰٪ | −۱۰٪ | −۱۰٪ |
سبد مرجع همیشه ۱۰۰ سکه فقط در آفتاب است و برای مقایسه نمایش داده می‌شود.
## فرمول
مبالغ مضرب ۱۰ و درصدها مضرب ۱۰ هستند. نتیجه با حساب صحیح این فرمول دقیق است و اعشار ندارد:
`ارزش = مبلغ × (۱۰۰ + درصد) / ۱۰۰`
تغییر نسبت به ۱۰۰ با متن «سود N سکه»، «زیان N سکه» یا «بدون تغییر» نوشته می‌شود.
نمونه‌ها:
- ۱۰۰ / ۰ / ۰: آفتابی ۱۲۰ (سود ۲۰)، بارانی ۸۰ (زیان ۲۰)، افت ۹۰ (زیان ۱۰).
- ۴۰ / ۳۰ / ۳۰: آفتابی ۱۰۵ (سود ۵)، بارانی ۹۵ (زیان ۵)، افت ۹۰ (زیان ۱۰).
- هر ترکیب معتبری در افت همگانی ۹۰ سکه است.
## نمره
امتیاز فقط از پنج سؤال، هرکدام ۲۰، می‌آید. ترکیب سبد و سود یا زیان شبیه‌سازی نمره نیستند. زیر ۶۰ تکمیل نمی‌شود. پاسخ درست در GET عمومی نیست.
1. `q-one` = `depend`
2. `q-loss` = `no`
3. `q-name` = `no`
4. `q-best` = `no`
5. `q-approach` = `balanced`
ارسال: `{ questionId: 'portfolio', holdings: { sun, rain, wind } }` به‌علاوهٔ پنج `{ questionId, answerId }`. سرور ترکیب و پاسخ‌ها را خودش اعتبارسنجی و محاسبه می‌کند.
پاداش پایه ۵۰ XP و ۲۰ سکه است و از complete همان مرحلهٔ Gold Land می‌آید، نه از کارت کاتالوگ `portfolio`.
+73
View File
@@ -0,0 +1,73 @@
# Restore SQLite onto the live database path
The restore script **refuses to write directly** to the path in `DB_PATH` while that variable still points at the live file. That avoids accidental overwrite with the service running.
`RESTORE_ACK_STOPPED=YES` is an **operator acknowledgment** only — the script does **not** detect whether the backend process is stopped. You must stop the service yourself before swapping files.
## Prerequisites
- Backend **stopped** (no open handles on `app.db`, `-wal`, `-shm`).
- A validated backup from `npm run backup:sqlite` (WAL-safe, schema-checked).
- Shell access to the data directory (host path or mounted volume).
## Recommended procedure (same logical database)
Replace `/app/data/app.db` with your paths.
1. **Stop backend**
```powershell
docker compose stop backend
# or: stop the Node process / systemd unit
```
2. **Optional safety backup of current file**
```powershell
$env:DB_PATH = "/app/data/app.db"
$env:BACKUP_DIR = "./backups"
npm run backup:sqlite
```
3. **Restore to a sibling file** (not the live `DB_PATH` target)
```powershell
npm run restore:sqlite -- ./backups/app-2026-01-01T12-00-00.db /app/data/app-restored.db
```
Use `RESTORE_ACK_STOPPED=YES` if `/app/data/app-restored.db` already exists from a prior attempt.
4. **Verify** (optional second check)
```powershell
node -e "import('./scripts/sqlite-app-schema.mjs').then(m=>console.log(m.validateAppSqliteFile('/app/data/app-restored.db')))"
```
5. **Swap files** (preserve previous DB on disk)
```powershell
mv /app/data/app.db /app/data/app.db.pre-restore-<timestamp>
rm -f /app/data/app.db-wal /app/data/app.db-shm
mv /app/data/app-restored.db /app/data/app.db
```
6. **Start backend** and check readiness
```powershell
docker compose start backend
curl http://127.0.0.1:<frontend-port>/health/ready
```
## What the script enforces
| Rule | Meaning |
|------|---------|
| No restore when `target === DB_PATH` | Use a sibling path, then rename after stop |
| `RESTORE_ACK_STOPPED=YES` if target exists | Confirms you intend to overwrite an existing file |
| Schema + `integrity_check` | Backup must match app migrations |
| Failed validation | Staging removed; live file unchanged |
| Existing target | Moved to `*.pre-restore-<timestamp>.bak` before replace |
## Development
Restoring to a **new temp path** does not require stopping production. See `npm run restore:sqlite -- backup.db ./tmp/restored.db`.
+24 -6
View File
@@ -8,12 +8,30 @@
"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"
"test:integration": "tsx --tsconfig tsconfig.json test/integration.test.ts",
"test:progression": "tsx --tsconfig tsconfig.json test/progression.test.ts",
"test:concurrency": "tsx --tsconfig tsconfig.json test/concurrency.test.ts",
"test:gold-precision": "tsx --tsconfig tsconfig.json test/gold-precision.test.ts",
"test:lesson-rewards": "tsx --tsconfig tsconfig.json test/lesson-rewards-api.test.ts",
"test:dual-connection": "tsx --tsconfig tsconfig.json test/dual-connection.test.ts",
"test:production-config": "tsx --tsconfig tsconfig.json test/production-config.test.ts",
"test:auth-rate-limit": "tsx --tsconfig tsconfig.json test/auth-rate-limit.test.ts",
"test:client-ip": "tsx --tsconfig tsconfig.json test/client-ip.test.ts",
"test:auth-rate-limit-xff": "tsx --tsconfig tsconfig.json test/auth-rate-limit-xff.test.ts",
"test:restore-safety": "tsx --tsconfig tsconfig.json test/restore-safety.test.ts",
"test:budgeting": "tsx --tsconfig tsconfig.json test/budgeting.test.ts",
"test:risk-return": "tsx --tsconfig tsconfig.json test/risk-return.test.ts",
"test:portfolio": "tsx --tsconfig tsconfig.json test/portfolio.test.ts",
"test:compound-interest": "tsx --tsconfig tsconfig.json test/compound-interest.test.ts",
"test:smart-shop": "tsx --tsconfig tsconfig.json test/smart-shop.test.ts",
"test:financial-goal": "tsx --tsconfig tsconfig.json test/financial-goal.test.ts",
"test:castle-exam": "tsx --tsconfig tsconfig.json test/castle-exam.test.ts",
"test:production-error": "tsx --tsconfig tsconfig.json test/production-error.test.ts",
"backup:sqlite": "node scripts/sqlite-backup.mjs",
"restore:sqlite": "node scripts/sqlite-restore.mjs",
"smoke:trial-production": "node scripts/trial-production-smoke.mjs",
"test:backup-restore": "tsx --tsconfig tsconfig.json test/backup-restore.test.ts",
"test": "npm run test:progression && npm run test:integration && npm run test:concurrency && npm run test:gold-precision && npm run test:lesson-rewards && npm run test:dual-connection && npm run test:production-config && npm run test:production-error && npm run test:client-ip && npm run test:auth-rate-limit && npm run test:auth-rate-limit-xff && npm run test:backup-restore && npm run test:restore-safety && npm run test:budgeting && npm run test:risk-return && npm run test:portfolio && npm run test:compound-interest && npm run test:smart-shop && npm run test:financial-goal && npm run test:castle-exam"
},
"dependencies": {
"@nestjs/common": "^11.0.12",
+280
View File
@@ -0,0 +1,280 @@
# Starts Child Gold Land backend + frontend for local dev.
# Manages only processes started by this script. No npm install / rebuild on each run.
#
# Usage:
# pwsh -File .\scripts\dev-local-stack.ps1
# pwsh -File .\scripts\dev-local-stack.ps1 -UseTestPorts -SmokeTest # alt ports + health/login probe then exit
#
# Default ports: backend 3000, frontend 5174. Test ports: 3010 / 5184 (when 3000/5174 are busy).
param(
[switch]$UseTestPorts,
[switch]$SmokeTest
)
$ErrorActionPreference = 'Stop'
$BackendRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$WorkspaceRoot = (Resolve-Path (Join-Path $BackendRoot '..')).Path
$FrontendRoot = Join-Path $WorkspaceRoot 'frontend'
if ($UseTestPorts) {
$BackendPort = 3010
$FrontendPort = 5190
} else {
$BackendPort = 3000
$FrontendPort = 5174
}
$HealthUrl = "http://127.0.0.1:${BackendPort}/health"
$FrontendUrl = "http://localhost:${FrontendPort}/"
$LoginProxyUrl = "http://localhost:${FrontendPort}/api/auth/login"
$script:ManagedProcesses = @()
$script:TempDbDir = $null
function Write-Info([string]$Message) { Write-Host $Message -ForegroundColor Cyan }
function Write-Warn([string]$Message) { Write-Host $Message -ForegroundColor Yellow }
function Write-Err([string]$Message) { Write-Host $Message -ForegroundColor Red }
function Test-NodeEngines {
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
throw 'Node.js not found in PATH. Install Node ^22.18.0 or >=24.12.0 (see .nvmrc).'
}
$raw = (node -v).TrimStart('v')
$ver = [version]$raw
$ok = ($ver.Major -eq 22 -and $ver -ge [version]'22.18.0') -or ($ver.Major -ge 24 -and $ver -ge [version]'24.12.0')
if (-not $ok) {
throw "Node v$raw is not supported. Required: ^22.18.0 or >=24.12.0. Run: nvm install 22.18.0 && nvm use 22.18.0"
}
Write-Info "Node $(node -v) at $((Get-Command node).Source)"
}
function Test-RepoLayout {
if (-not (Test-Path (Join-Path $BackendRoot 'package.json'))) {
throw "Backend package.json not found at $BackendRoot"
}
if (-not (Test-Path (Join-Path $FrontendRoot 'package.json'))) {
throw "Frontend not found at `"$FrontendRoot`" (expected sibling of backend-nest)."
}
}
function Test-DependenciesPresent {
if (-not (Test-Path (Join-Path $BackendRoot 'node_modules'))) {
throw "Backend node_modules missing. Run once: cd `"$BackendRoot`"; npm install"
}
if (-not (Test-Path (Join-Path $FrontendRoot 'node_modules'))) {
throw "Frontend node_modules missing. Run once: cd `"$FrontendRoot`"; npm install"
}
}
function Get-ListenProcessId([int]$Port) {
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
if ($conn) { return [int]$conn.OwningProcess }
return $null
}
function Assert-PortFree([int]$Port, [string]$Label) {
$ownerPid = Get-ListenProcessId $Port
if ($null -ne $ownerPid) {
$name = (Get-Process -Id $ownerPid -ErrorAction SilentlyContinue).ProcessName
throw "${Label} port $Port is in use (PID $ownerPid, $name). Use -UseTestPorts or free the port."
}
}
function New-BackendProcessEnvironment {
$envMap = @{}
foreach ($entry in [System.Environment]::GetEnvironmentVariables('Process').GetEnumerator()) {
$envMap[$entry.Key] = [string]$entry.Value
}
$envMap['PORT'] = [string]$BackendPort
$envMap['HOST'] = '0.0.0.0'
$envMap['NODE_ENV'] = 'development'
$envMap['CORS_ORIGIN'] = "http://localhost:${FrontendPort}"
if ($UseTestPorts) {
$script:TempDbDir = Join-Path ([System.IO.Path]::GetTempPath()) ("cgl-devstack-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $script:TempDbDir -Force | Out-Null
$envMap['DB_PATH'] = Join-Path $script:TempDbDir 'app.db'
Write-Info "Test backend DB: $($envMap['DB_PATH'])"
}
return $envMap
}
function Start-ProcessWithEnv {
param(
[string]$FilePath,
[string[]]$ArgumentList,
[string]$WorkingDirectory,
[hashtable]$Environment
)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $FilePath
$psi.WorkingDirectory = $WorkingDirectory
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
if ($ArgumentList -and $ArgumentList.Count -gt 0) {
$quoted = $ArgumentList | ForEach-Object {
if ($_ -match '\s') { "`"$_`"" } else { $_ }
}
$psi.Arguments = $quoted -join ' '
}
foreach ($entry in [System.Environment]::GetEnvironmentVariables('Process').GetEnumerator()) {
$psi.EnvironmentVariables[[string]$entry.Key] = [string]$entry.Value
}
foreach ($key in $Environment.Keys) {
$psi.EnvironmentVariables[$key] = [string]$Environment[$key]
}
$proc = [System.Diagnostics.Process]::Start($psi)
if (-not $proc) {
throw "Failed to start process: $FilePath"
}
return $proc
}
function Start-Backend {
$mainJs = Join-Path $BackendRoot 'dist\main.js'
if (-not (Test-Path $mainJs)) {
throw "Missing dist/main.js. Run once: cd `"$BackendRoot`"; npm run build"
}
$node = (Get-Command node).Source
$envMap = New-BackendProcessEnvironment
Write-Info "Starting backend on port $BackendPort ..."
$p = Start-ProcessWithEnv -FilePath $node -ArgumentList @($mainJs) -WorkingDirectory $BackendRoot -Environment $envMap
$script:ManagedProcesses += $p
Start-Sleep -Milliseconds 500
if ($p.HasExited) {
throw "Backend exited immediately (exit $($p.ExitCode)). Build with current Node and check dist/main.js."
}
return $p
}
function Start-Frontend {
$npm = (Get-Command npm.cmd -ErrorAction SilentlyContinue).Source
if (-not $npm) {
$npm = (Get-Command npm -ErrorAction SilentlyContinue).Source
}
if (-not $npm) {
throw 'npm not found in PATH.'
}
$envMap = @{}
foreach ($entry in [System.Environment]::GetEnvironmentVariables('Process').GetEnumerator()) {
$envMap[$entry.Key] = [string]$entry.Value
}
$envMap['VITE_DEV_PORT'] = [string]$FrontendPort
$envMap['VITE_DEV_API_TARGET'] = "http://127.0.0.1:${BackendPort}"
Write-Info "Starting frontend on port $FrontendPort (proxy -> $($envMap['VITE_DEV_API_TARGET'])) ..."
$p = Start-ProcessWithEnv -FilePath $npm -ArgumentList @('run', 'dev', '--', '--port', [string]$FrontendPort) `
-WorkingDirectory $FrontendRoot -Environment $envMap
$script:ManagedProcesses += $p
return $p
}
function Wait-BackendHealth {
param([int]$TimeoutSeconds = 90)
Write-Info "Waiting for backend health (max ${TimeoutSeconds}s) ..."
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
foreach ($p in $script:ManagedProcesses) {
if ($p.HasExited) {
throw "Managed process exited early (PID $($p.Id), exit $($p.ExitCode))."
}
}
try {
$r = Invoke-WebRequest -Uri $HealthUrl -UseBasicParsing -TimeoutSec 3
if ($r.StatusCode -eq 200) {
Write-Info "Backend health OK: $HealthUrl"
return
}
} catch {
Start-Sleep -Seconds 2
}
}
throw "Backend health timed out: $HealthUrl"
}
function Wait-FrontendHttp {
param([int]$TimeoutSeconds = 120)
Write-Info "Waiting for frontend (max ${TimeoutSeconds}s) ..."
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
try {
$r = Invoke-WebRequest -Uri $FrontendUrl -UseBasicParsing -TimeoutSec 3
if ($r.StatusCode -eq 200) {
Write-Info "Frontend OK: $FrontendUrl"
return
}
} catch {
Start-Sleep -Seconds 2
}
}
throw "Frontend HTTP timed out: $FrontendUrl"
}
function Test-ProxyLogin {
$body = '{"username":"demo","password":"123456"}'
$r = Invoke-WebRequest -Uri $LoginProxyUrl -Method POST -Body $body -ContentType 'application/json' -UseBasicParsing
if ($r.StatusCode -ne 200) {
throw "Proxy login failed: HTTP $($r.StatusCode)"
}
Write-Info "Proxy login OK: $LoginProxyUrl"
}
function Stop-ManagedProcesses {
foreach ($p in $script:ManagedProcesses) {
if ($p -and -not $p.HasExited) {
Write-Info "Stopping PID $($p.Id) ..."
try {
& taskkill.exe /PID $p.Id /T /F 2>$null | Out-Null
} catch {
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
}
}
}
$script:ManagedProcesses = @()
if ($script:TempDbDir -and (Test-Path $script:TempDbDir)) {
Remove-Item -Recurse -Force $script:TempDbDir -ErrorAction SilentlyContinue
$script:TempDbDir = $null
}
}
try {
Write-Info 'Child Gold Land — local dev stack'
if ($UseTestPorts) { Write-Warn 'Using test ports 3010 / 5190 (temp SQLite when backend starts fresh DB).' }
Test-NodeEngines
Test-RepoLayout
Test-DependenciesPresent
Assert-PortFree -Port $BackendPort -Label 'Backend'
Assert-PortFree -Port $FrontendPort -Label 'Frontend'
Start-Backend | Out-Null
Start-Frontend | Out-Null
Wait-BackendHealth
if ($SmokeTest) {
Wait-FrontendHttp
Test-ProxyLogin
Write-Info 'SmokeTest passed.'
exit 0
}
Write-Host ''
Write-Info "Frontend: $FrontendUrl"
Write-Info "Backend health: $HealthUrl"
Write-Info 'Demo login: demo / 123456'
Write-Warn 'Press Ctrl+C to stop only processes started by this script.'
Write-Host ''
while ($true) {
Start-Sleep -Seconds 2
foreach ($p in $script:ManagedProcesses) {
if ($p.HasExited) {
Write-Err "Process PID $($p.Id) exited unexpectedly (code $($p.ExitCode))."
exit 1
}
}
}
} catch {
Write-Err $_.Exception.Message
exit 1
} finally {
Stop-ManagedProcesses
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Validates that a SQLite file matches Child Gold Land backend schema expectations.
*/
import Database from 'better-sqlite3'
export const EXPECTED_TABLES = [
'users',
'user_level_progress',
'reward_ledger',
'auth_accounts',
]
/** Keep in sync with src/database/migrations/index.ts */
export const EXPECTED_MIGRATION_IDS = ['001_initial_schema']
/**
* @param {string} dbPath
* @returns {{ ok: true } | { ok: false; reason: string }}
*/
export function validateAppSqliteFile(dbPath) {
let db
try {
db = new Database(dbPath, { readonly: true })
} catch (error) {
return { ok: false, reason: `cannot open database: ${error instanceof Error ? error.message : error}` }
}
try {
const integrity = db.pragma('integrity_check', { simple: true })
if (integrity !== 'ok') {
return { ok: false, reason: `integrity_check failed: ${integrity}` }
}
const tables = db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations'",
)
.all()
.map((r) => r.name)
for (const name of EXPECTED_TABLES) {
if (!tables.includes(name)) {
return { ok: false, reason: `missing table: ${name}` }
}
}
const migTable = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'")
.get()
if (!migTable) {
return { ok: false, reason: 'missing schema_migrations table' }
}
const applied = db.prepare('SELECT id FROM schema_migrations ORDER BY id').all().map((r) => r.id)
for (const id of EXPECTED_MIGRATION_IDS) {
if (!applied.includes(id)) {
return { ok: false, reason: `missing migration: ${id}` }
}
}
return { ok: true }
} finally {
db.close()
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* WAL-safe SQLite backup via better-sqlite3 backup API (not a raw file copy).
* Usage: DB_PATH=./data/app.db BACKUP_DIR=./backups node scripts/sqlite-backup.mjs
*/
import Database from 'better-sqlite3'
import { mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
const srcPath = process.env.DB_PATH ?? join(process.cwd(), 'data', 'app.db')
const backupDir = process.env.BACKUP_DIR ?? join(process.cwd(), 'backups')
const retentionDays = Number(process.env.BACKUP_RETENTION_DAYS ?? '0')
if (!existsSync(srcPath)) {
console.error(`Source database not found: ${srcPath}`)
process.exit(1)
}
mkdirSync(backupDir, { recursive: true })
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
const destPath = join(backupDir, `app-${stamp}.db`)
const src = new Database(srcPath, { readonly: true })
try {
await src.backup(destPath)
} finally {
src.close()
}
const verify = new Database(destPath, { readonly: true })
try {
const integrity = verify.pragma('integrity_check', { simple: true })
if (integrity !== 'ok') {
console.error(`Backup integrity check failed: ${integrity}`)
process.exit(1)
}
} finally {
verify.close()
}
console.log(`Backup written: ${destPath}`)
if (retentionDays > 0) {
const cutoff = Date.now() - retentionDays * 86_400_000
for (const name of readdirSync(backupDir)) {
if (!name.startsWith('app-') || !name.endsWith('.db')) continue
const full = join(backupDir, name)
if (statSync(full).mtimeMs < cutoff) {
unlinkSync(full)
console.log(`Removed old backup: ${name}`)
}
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Restore backup to a target path (service must be stopped).
* Usage: node scripts/sqlite-restore.mjs <backup.db> <target.db>
*/
import Database from 'better-sqlite3'
import {
copyFileSync,
existsSync,
mkdirSync,
renameSync,
rmSync,
statSync,
} from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { validateAppSqliteFile } from './sqlite-app-schema.mjs'
const backupPath = resolve(process.argv[2] ?? '')
const targetPath = resolve(process.argv[3] ?? '')
if (!backupPath || !targetPath) {
console.error('Usage: node scripts/sqlite-restore.mjs <backup.db> <target.db>')
process.exit(1)
}
if (!existsSync(backupPath)) {
console.error(`Backup not found: ${backupPath}`)
process.exit(1)
}
const activeDb = resolve(process.env.DB_PATH ?? join(process.cwd(), 'data', 'app.db'))
const targetExists = existsSync(targetPath)
if (targetPath === activeDb) {
console.error('Restore onto active DB_PATH is not allowed while it is configured as the live database.')
console.error('Stop the backend service, point DB_PATH elsewhere, or restore to a temp file first.')
process.exit(1)
}
if (targetExists && process.env.RESTORE_ACK_STOPPED !== 'YES') {
console.error(
'Target database file already exists. Stop the backend service and set RESTORE_ACK_STOPPED=YES to continue.',
)
process.exit(1)
}
const schema = validateAppSqliteFile(backupPath)
if (!schema.ok) {
console.error(`Backup rejected: ${schema.reason}`)
process.exit(1)
}
const staging = `${targetPath}.restore-staging`
const stagingWal = `${staging}-wal`
const stagingShm = `${staging}-shm`
function cleanupStaging() {
for (const p of [staging, stagingWal, stagingShm]) {
try {
if (existsSync(p)) rmSync(p)
} catch {
// ignore
}
}
}
cleanupStaging()
try {
copyFileSync(backupPath, staging)
const stagedCheck = validateAppSqliteFile(staging)
if (!stagedCheck.ok) {
console.error(`Staging validation failed: ${stagedCheck.reason}`)
cleanupStaging()
process.exit(1)
}
const verify = new Database(staging, { readonly: true })
try {
const integrity = verify.pragma('integrity_check', { simple: true })
if (integrity !== 'ok') {
console.error(`Staging integrity check failed: ${integrity}`)
cleanupStaging()
process.exit(1)
}
} finally {
verify.close()
}
for (const sidecar of [`${targetPath}-wal`, `${targetPath}-shm`]) {
try {
if (existsSync(sidecar)) rmSync(sidecar)
} catch {
// ignore
}
}
if (targetExists) {
const backupOfTarget = `${targetPath}.pre-restore-${Date.now()}.bak`
renameSync(targetPath, backupOfTarget)
console.log(`Existing target moved to ${backupOfTarget}`)
}
renameSync(staging, targetPath)
console.log(`Restored ${backupPath} -> ${targetPath}`)
} catch (error) {
cleanupStaging()
console.error(error instanceof Error ? error.message : error)
process.exit(1)
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Smoke-test production env on a temp DB (no deploy).
* Requires: npm run build first.
*/
import { spawn, spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const backendRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
if (!existsSync(join(backendRoot, 'dist', 'main.js'))) {
console.error('Run npm run build before trial-production-smoke.mjs')
process.exit(1)
}
const tempDir = mkdtempSync(join(tmpdir(), 'cgl-prod-smoke-'))
const dbPath = join(tempDir, 'app.db')
const port = String(3020 + Math.floor(Math.random() * 100))
const secret = `trial-smoke-${'x'.repeat(40)}`
function fail(msg) {
console.error(msg)
rmSync(tempDir, { recursive: true, force: true })
process.exit(1)
}
const bad = spawnSync(process.execPath, ['dist/main.js'], {
cwd: backendRoot,
env: {
...process.env,
NODE_ENV: 'production',
PORT: port,
DB_PATH: dbPath,
JWT_SECRET: 'gold-land-dev-secret-change-in-production',
CORS_ORIGIN: 'http://127.0.0.1:8080',
},
stdio: 'pipe',
encoding: 'utf8',
timeout: 8000,
})
if (bad.status === 0) {
fail('Expected startup failure with dev JWT placeholder in production')
}
async function waitForReady() {
await new Promise((resolve, reject) => {
const child = spawn(process.execPath, ['dist/main.js'], {
cwd: backendRoot,
env: {
...process.env,
NODE_ENV: 'production',
PORT: port,
DB_PATH: dbPath,
JWT_SECRET: secret,
CORS_ORIGIN: 'http://127.0.0.1:8080',
},
stdio: 'pipe',
})
const timer = setTimeout(() => {
child.kill()
reject(new Error('startup timeout'))
}, 20_000)
const poll = async () => {
try {
const live = await fetch(`http://127.0.0.1:${port}/health`)
const ready = await fetch(`http://127.0.0.1:${port}/health/ready`)
if (live.ok && ready.ok) {
clearTimeout(timer)
child.kill()
resolve(undefined)
return
}
} catch {
// retry
}
setTimeout(poll, 300)
}
setTimeout(poll, 500)
})
}
await waitForReady().catch((e) => fail(String(e)))
const demoCheck = spawnSync(
process.execPath,
[
'-e',
`const D=require('better-sqlite3');const d=new D(${JSON.stringify(dbPath)});const row=d.prepare("SELECT COUNT(*) as c FROM auth_accounts WHERE username='demo'").get();console.log(row.c);d.close();`,
],
{ cwd: backendRoot, encoding: 'utf8' },
)
const demoCount = Number(demoCheck.stdout.trim())
if (demoCount !== 0) {
fail(`Expected no demo account in production DB, found ${demoCount}`)
}
rmSync(tempDir, { recursive: true, force: true })
console.log('trial-production-smoke passed')
+4
View File
@@ -8,6 +8,10 @@ export class AuthSeedService implements OnModuleInit {
constructor(@Inject(forwardRef(() => AuthService)) private readonly auth: AuthService) {}
onModuleInit(): void {
if (process.env.NODE_ENV === 'production') {
this.logger.log('Auth seed skipped in production (no demo account)')
return
}
this.auth.seedDemoAccount()
this.logger.log('Auth seed completed (demo account if missing)')
}
+8 -3
View File
@@ -1,5 +1,8 @@
import { Body, Controller, HttpCode, Inject, Post, forwardRef } from '@nestjs/common'
import { Body, Controller, HttpCode, Inject, Post, Req, forwardRef } from '@nestjs/common'
import type { FastifyRequest } from 'fastify'
import { Public } from '../common/decorators/public.decorator'
import { getClientIp } from '../common/http/client-ip'
import { assertAuthRateLimit } from '../common/rate-limit/auth-rate-limit'
import { strictBodyPipe } from '../common/pipes/strict-body.pipe'
import { AuthService } from './auth.service'
import { LoginDto } from './dto/login.dto'
@@ -12,14 +15,16 @@ export class AuthController {
@Public()
@HttpCode(200)
@Post('register')
register(@Body(strictBodyPipe(RegisterDto)) dto: RegisterDto) {
register(@Req() req: FastifyRequest, @Body(strictBodyPipe(RegisterDto)) dto: RegisterDto) {
assertAuthRateLimit(getClientIp(req))
return this.auth.register(dto.username, dto.password, dto.name)
}
@Public()
@HttpCode(200)
@Post('login')
login(@Body(strictBodyPipe(LoginDto)) dto: LoginDto) {
login(@Req() req: FastifyRequest, @Body(strictBodyPipe(LoginDto)) dto: LoginDto) {
assertAuthRateLimit(getClientIp(req))
return this.auth.login(dto.username, dto.password)
}
}
+17 -2
View File
@@ -99,14 +99,29 @@ export class AuthService {
}
seedDemoAccount(): void {
if (process.env.NODE_ENV === 'production') {
return
}
const demoPasswordHash = bcrypt.hashSync('123456', 10)
const demo = this.findAccountByUsername('demo')
if (demo) return
if (demo) {
// Dev DB may retain a stale hash after recovery/migration — keep demo login predictable.
if (process.env.NODE_ENV !== 'production') {
this.database
.getDb()
.prepare('UPDATE auth_accounts SET password_hash = ? WHERE username = ?')
.run(demoPasswordHash, 'demo')
}
return
}
this.users.ensureUser('demo')
const account: AuthAccount = {
id: randomUUID(),
username: 'demo',
passwordHash: bcrypt.hashSync('123456', 10),
passwordHash: demoPasswordHash,
userId: 'demo',
createdAt: new Date().toISOString(),
}
+18 -7
View File
@@ -7,6 +7,7 @@ import {
Logger,
} from '@nestjs/common'
import type { FastifyReply } from 'fastify'
import { safeErrorMessage } from '../http/safe-log'
interface ApiErrorBody {
error: string
@@ -19,43 +20,53 @@ export class ApiExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp()
const reply = ctx.getResponse<FastifyReply>()
const isProduction = process.env.NODE_ENV === 'production'
if (exception instanceof HttpException) {
const status = exception.getStatus()
const message = this.extractMessage(exception)
const message = this.extractMessage(exception, isProduction)
reply.status(status).send({ error: message } satisfies ApiErrorBody)
return
}
this.logger.error(exception)
this.logger.error(`Unhandled error: ${safeErrorMessage(exception)}`)
reply.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
error: 'خطای سرور',
} satisfies ApiErrorBody)
}
private extractMessage(exception: HttpException): string {
private extractMessage(exception: HttpException, isProduction: boolean): string {
const response = exception.getResponse()
if (typeof response === 'string') {
return response
return isProduction ? this.sanitizeClientMessage(response) : response
}
if (typeof response === 'object' && response !== null) {
const body = response as Record<string, unknown>
if (typeof body.message === 'string') {
return body.message
return isProduction ? this.sanitizeClientMessage(body.message) : body.message
}
if (Array.isArray(body.message) && body.message.length > 0) {
return body.message.map(String).join('؛ ')
const joined = body.message.map(String).join('؛ ')
return isProduction ? this.sanitizeClientMessage(joined) : joined
}
if (typeof body.error === 'string') {
return body.error
return isProduction ? this.sanitizeClientMessage(body.error) : body.error
}
}
return 'درخواست نامعتبر است'
}
/** Avoid leaking SQL paths, stack fragments, or driver names to clients in production. */
private sanitizeClientMessage(message: string): string {
if (/sqlite|SQLITE|better-sqlite|ENOENT|EACCES|stack|at\s+\S+\.(ts|js)/i.test(message)) {
return 'درخواست نامعتبر است'
}
return message
}
}
+90
View File
@@ -0,0 +1,90 @@
import type { FastifyRequest } from 'fastify'
/** Private ranges for Docker / RFC1918 — peer must match before X-Forwarded-For is used. */
const DEFAULT_TRUSTED_PROXY_CIDRS = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.0/8']
function parseCidrList(raw: string | undefined): string[] {
if (!raw?.trim()) {
if (process.env.NODE_ENV === 'production') return []
return DEFAULT_TRUSTED_PROXY_CIDRS
}
return raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
function ipv4ToInt(ip: string): number | null {
const parts = ip.split('.')
if (parts.length !== 4) return null
let n = 0
for (const p of parts) {
const oct = Number(p)
if (!Number.isInteger(oct) || oct < 0 || oct > 255) return null
n = (n << 8) + oct
}
return n >>> 0
}
function normalizePeerIp(raw: string | undefined): string {
if (!raw) return ''
if (raw.startsWith('::ffff:')) return raw.slice('::ffff:'.length)
return raw
}
function ipInCidr(ip: string, cidr: string): boolean {
const [net, bitsStr] = cidr.split('/')
const bits = Number(bitsStr)
if (!net || !Number.isFinite(bits) || bits < 0 || bits > 32) return false
const ipInt = ipv4ToInt(ip)
const netInt = ipv4ToInt(net)
if (ipInt === null || netInt === null) return false
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0
return (ipInt & mask) === (netInt & mask)
}
function isTrustedProxyPeer(peerIp: string, cidrs: string[]): boolean {
const ip = normalizePeerIp(peerIp)
if (!ip) return false
return cidrs.some((cidr) => ipInCidr(ip, cidr))
}
function trustProxyEnabled(): boolean {
return process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1'
}
/**
* Client IP for rate limiting.
* X-Forwarded-For is honored only when the immediate TCP peer is a trusted proxy (nginx on Docker network).
* Direct/untrusted connections ignore client-supplied XFF (mitigates header spoofing).
*/
export function getClientIp(req: FastifyRequest): string {
const peer = normalizePeerIp(req.socket.remoteAddress) || 'unknown'
if (!trustProxyEnabled()) {
return peer
}
const cidrs = parseCidrList(process.env.TRUST_PROXY_CIDRS)
if (!isTrustedProxyPeer(peer, cidrs)) {
return peer
}
const xff = req.headers['x-forwarded-for']
if (typeof xff === 'string' && xff.trim()) {
const client = xff.split(',')[0]?.trim()
if (client) return client
}
const realIp = req.headers['x-real-ip']
if (typeof realIp === 'string' && realIp.trim()) {
return realIp.trim()
}
return peer
}
export function resetClientIpConfigForTests(): void {
delete process.env.TRUST_PROXY
delete process.env.TRUST_PROXY_CIDRS
}
+27
View File
@@ -0,0 +1,27 @@
const SENSITIVE_KEY = /password|authorization|token|secret|jwt|cookie/i
export function sanitizeForLog(value: unknown): unknown {
if (value === null || value === undefined) return value
if (typeof value === 'string') {
if (value.length > 200 && /^Bearer\s+/i.test(value)) return '[Bearer redacted]'
return value
}
if (Array.isArray(value)) return value.map(sanitizeForLog)
if (typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (SENSITIVE_KEY.test(k)) {
out[k] = '[redacted]'
} else {
out[k] = sanitizeForLog(v)
}
}
return out
}
return value
}
export function safeErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}
+29
View File
@@ -0,0 +1,29 @@
import { HttpException, HttpStatus } from '@nestjs/common'
type Bucket = { count: number; resetAt: number }
const buckets = new Map<string, Bucket>()
export function resetAuthRateLimitForTests(): void {
buckets.clear()
}
const WINDOW_MS = 60_000
const MAX_ATTEMPTS = 30
/** In-memory limit for login/register per client key (IP). Suitable for single-instance trial deploy. */
export function assertAuthRateLimit(clientKey: string): void {
const key = clientKey.trim() || 'unknown'
const now = Date.now()
let bucket = buckets.get(key)
if (!bucket || now >= bucket.resetAt) {
bucket = { count: 0, resetAt: now + WINDOW_MS }
buckets.set(key, bucket)
}
bucket.count += 1
if (bucket.count > MAX_ATTEMPTS) {
throw new HttpException('تعداد درخواست زیاد است؛ کمی بعد دوباره تلاش کن.', HttpStatus.TOO_MANY_REQUESTS)
}
}
+49 -6
View File
@@ -1,6 +1,15 @@
import { plainToInstance } from 'class-transformer'
import { IsIn, IsInt, IsOptional, IsString, Min, validateSync } from 'class-validator'
const DEV_JWT_FALLBACK = 'gold-land-dev-secret-change-in-production'
const FORBIDDEN_PRODUCTION_JWT_SECRETS = new Set([
DEV_JWT_FALLBACK,
'e2e-local-jwt-not-for-production',
'e2e-ci-jwt-not-for-production',
'ci-test-jwt-not-for-production',
])
export class EnvironmentVariables {
@IsIn(['development', 'production', 'test'])
NODE_ENV!: 'development' | 'production' | 'test'
@@ -19,17 +28,50 @@ export class EnvironmentVariables {
@IsString()
DB_PATH?: string
/** Alias for DB_PATH (e.g. Docker Compose). DB_PATH takes precedence when both are set. */
@IsOptional()
@IsString()
DATABASE_PATH?: string
@IsOptional()
@IsString()
JWT_SECRET?: string
/** When `true`, Fastify trusts X-Forwarded-* (only enable behind a known reverse proxy). */
@IsOptional()
@IsString()
TRUST_PROXY?: string
}
function assertProductionJwtSecret(secret: string): void {
const trimmed = secret.trim()
if (trimmed.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters when NODE_ENV=production')
}
if (FORBIDDEN_PRODUCTION_JWT_SECRETS.has(trimmed)) {
throw new Error('JWT_SECRET is a known development placeholder — set a unique production secret')
}
}
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')
const trustProxyEnabled =
config.TRUST_PROXY === 'true' ||
config.TRUST_PROXY === '1' ||
config.TRUST_PROXY === true
if (nodeEnv === 'production') {
if (!jwtSecret || jwtSecret.trim().length === 0) {
throw new Error('JWT_SECRET is required when NODE_ENV=production')
}
assertProductionJwtSecret(jwtSecret)
if (trustProxyEnabled && !(config.TRUST_PROXY_CIDRS as string | undefined)?.trim()) {
throw new Error(
'TRUST_PROXY_CIDRS is required when TRUST_PROXY is enabled in production (set the reverse-proxy network CIDR, not broad defaults)',
)
}
}
if (nodeEnv !== 'production' && !jwtSecret) {
@@ -40,11 +82,12 @@ export function validateEnv(config: Record<string, unknown>): EnvironmentVariabl
const normalized = {
NODE_ENV: nodeEnv,
PORT: Number(config.PORT ?? 3001),
PORT: Number(config.PORT ?? 3000),
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',
CORS_ORIGIN: (config.CORS_ORIGIN as string | undefined) ?? 'http://localhost:5174',
DB_PATH: (config.DB_PATH ?? config.DATABASE_PATH) as string | undefined,
JWT_SECRET: jwtSecret ?? DEV_JWT_FALLBACK,
TRUST_PROXY: (config.TRUST_PROXY as string | undefined) ?? 'false',
}
const validated = plainToInstance(EnvironmentVariables, normalized, {
+19 -3
View File
@@ -12,7 +12,10 @@ export class DatabaseService implements OnModuleInit, OnModuleDestroy {
private readonly dbPath: string
constructor(@Optional() @Inject(ConfigService) private readonly config?: ConfigService) {
const configured = this.config?.get<string>('DB_PATH') ?? process.env.DB_PATH
const configured =
this.config?.get<string>('DB_PATH') ??
process.env.DB_PATH ??
process.env.DATABASE_PATH
this.dbPath = configured ?? join(process.cwd(), 'data', 'app.db')
}
@@ -20,7 +23,19 @@ export class DatabaseService implements OnModuleInit, OnModuleDestroy {
mkdirSync(dirname(this.dbPath), { recursive: true })
this.db = new Database(this.dbPath)
this.db.pragma('journal_mode = WAL')
runSchemaMigrations(this.db)
this.db.pragma('busy_timeout = 5000')
let applied: string[]
try {
applied = runSchemaMigrations(this.db)
} catch (error) {
this.logger.error('SQLite migration failed', error instanceof Error ? error.stack : error)
throw new Error(
`SQLite migration failed: ${error instanceof Error ? error.message : String(error)}`,
)
}
if (applied.length > 0) {
this.logger.log(`Applied migrations: ${applied.join(', ')}`)
}
this.logger.log(`SQLite ready at ${this.dbPath}`)
}
@@ -43,8 +58,9 @@ export class DatabaseService implements OnModuleInit, OnModuleDestroy {
return this.db
}
/** IMMEDIATE acquires the write lock before reads — avoids lost updates on state_json RMW. */
transaction<T>(fn: () => T): T {
return this.getDb().transaction(fn)()
return this.getDb().transaction(fn).immediate()
}
verifyMigrationsApplied(): { ok: boolean; tables: string[]; missing: string[] } {
+66
View File
@@ -0,0 +1,66 @@
import type Database from 'better-sqlite3'
import { SQL_MIGRATIONS } from './migrations'
const MIGRATIONS_TABLE = 'schema_migrations'
function ensureMigrationsTable(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
);
`)
}
function isMigrationApplied(db: Database.Database, id: string): boolean {
const row = db
.prepare(`SELECT id FROM ${MIGRATIONS_TABLE} WHERE id = ?`)
.get(id) as { id: string } | undefined
return row !== undefined
}
function recordMigrationApplied(db: Database.Database, id: string): void {
db.prepare(`INSERT INTO ${MIGRATIONS_TABLE} (id, applied_at) VALUES (?, ?)`).run(
id,
new Date().toISOString(),
)
}
/** Run pending versioned migrations inside a transaction per migration. */
export function runPendingMigrations(db: Database.Database): string[] {
ensureMigrationsTable(db)
const applied: string[] = []
for (const migration of SQL_MIGRATIONS) {
if (isMigrationApplied(db, migration.id)) {
continue
}
const apply = db.transaction(() => {
db.exec(migration.up)
recordMigrationApplied(db, migration.id)
})
apply()
applied.push(migration.id)
}
return applied
}
export function listAppliedMigrationIds(db: Database.Database): string[] {
ensureMigrationsTable(db)
const rows = db
.prepare(`SELECT id FROM ${MIGRATIONS_TABLE} ORDER BY id`)
.all() as Array<{ id: string }>
return rows.map((row) => row.id)
}
export function listPendingMigrationIds(db: Database.Database): string[] {
ensureMigrationsTable(db)
const applied = new Set(listAppliedMigrationIds(db))
return SQL_MIGRATIONS.filter((migration) => !applied.has(migration.id)).map(
(migration) => migration.id,
)
}
+13 -49
View File
@@ -1,55 +1,17 @@
import type Database from 'better-sqlite3'
import {
listAppliedMigrationIds,
listPendingMigrationIds,
runPendingMigrations,
} from './migration-runner'
/** 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
);
`)
/** Apply all pending versioned SQL migrations (safe to call on every startup). */
export function runSchemaMigrations(db: Database.Database): string[] {
return runPendingMigrations(db)
}
export { listAppliedMigrationIds, listPendingMigrationIds }
export const EXPECTED_TABLES = [
'users',
'user_level_progress',
@@ -61,5 +23,7 @@ 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)
return rows
.map((row) => row.name)
.filter((name) => name !== 'schema_migrations')
}
@@ -0,0 +1,50 @@
/** Initial application schema — idempotent (CREATE IF NOT EXISTS). */
export const migrationId = '001_initial_schema'
export const up = `
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
);
`
+11
View File
@@ -0,0 +1,11 @@
import { migrationId as id001, up as up001 } from './001-initial-schema'
export interface SqlMigration {
id: string
up: string
}
/** Ordered list — append new files and import them here. */
export const SQL_MIGRATIONS: SqlMigration[] = [
{ id: id001, up: up001 },
]
+175
View File
@@ -0,0 +1,175 @@
import { budgetingMission, BUDGET_STEP, BUDGET_TOTAL, FOOD_MIN, SAVINGS_GOAL, SAVINGS_PARTIAL, TRANSPORT_FINAL_MIN, TRANSPORT_INITIAL_MIN } from './budgeting.mission'
import type { BudgetAllocation, BudgetCriterionFeedback, BudgetMissionFeedback, BudgetMissionPublic, BudgetPlanFeedback } from './budgeting.types'
export class BudgetingGradingError extends Error {
statusCode: number
kind: 'invalid' | 'unmet-needs'
constructor(statusCode: number, message: string, kind: 'invalid' | 'unmet-needs' = 'invalid') {
super(message)
this.statusCode = statusCode
this.kind = kind
}
}
const CATEGORY_IDS = ['transport', 'food', 'savings', 'fun'] as const
export interface BudgetSubmissionAnswer {
questionId: string
allocations?: BudgetAllocation
answerId?: string
orderedItemIds?: string[]
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function assertAllocation(value: unknown, phase: string): BudgetAllocation {
if (!isPlainObject(value)) {
throw new BudgetingGradingError(400, `بودجهٔ ${phase} نامعتبر است`)
}
const unknown = Object.keys(value).filter((key) => !CATEGORY_IDS.includes(key as (typeof CATEGORY_IDS)[number]))
if (unknown.length > 0) {
throw new BudgetingGradingError(400, 'فیلد ناشناخته در بودجه')
}
const allocation = {} as BudgetAllocation
for (const id of CATEGORY_IDS) {
const amount = value[id]
if (typeof amount !== 'number' || !Number.isInteger(amount)) {
throw new BudgetingGradingError(400, 'مبلغ بودجه باید عدد صحیح باشد')
}
if (amount < 0) {
throw new BudgetingGradingError(400, 'مبلغ بودجه نمی‌تواند منفی باشد')
}
if (amount % BUDGET_STEP !== 0) {
throw new BudgetingGradingError(400, `مبلغ‌ها باید مضرب ${BUDGET_STEP} باشند`)
}
allocation[id] = amount
}
const sum = allocation.transport + allocation.food + allocation.savings + allocation.fun
if (sum > BUDGET_TOTAL) {
throw new BudgetingGradingError(400, 'مجموع تخصیص از ۱۰۰ سکه بیشتر است')
}
return allocation
}
function criterion(
id: string,
earned: boolean,
label: string,
detail: string,
): BudgetCriterionFeedback {
return { id, label, earned, detail }
}
function assertEssentialNeeds(allocation: BudgetAllocation, phase: 'initial' | 'final'): void {
const transportMin = phase === 'initial' ? TRANSPORT_INITIAL_MIN : TRANSPORT_FINAL_MIN
const gaps: string[] = []
if (allocation.transport < transportMin) {
gaps.push(`رفت‌وآمد حداقل ${transportMin} سکه`)
}
if (allocation.food < FOOD_MIN) {
gaps.push(`آب و غذا حداقل ${FOOD_MIN} سکه`)
}
if (gaps.length === 0) return
const planLabel = phase === 'initial' ? 'برنامهٔ اول' : 'برنامهٔ بعد از تغییر'
throw new BudgetingGradingError(
422,
`نیاز ضروری ${planLabel} کامل نیست: ${gaps.join(' و ')}. مقدارها را خودت اصلاح کن.`,
'unmet-needs',
)
}
function scorePlan(allocation: BudgetAllocation, transportMin: number, phase: 'initial' | 'final'): BudgetPlanFeedback {
const sum = allocation.transport + allocation.food + allocation.savings + allocation.fun
const criteria = [
criterion(
'sum',
sum <= BUDGET_TOTAL,
'جمع تخصیص از ۱۰۰ بیشتر نیست',
'سکه‌ها باید داخل همین ۱۰۰ سکه جا شوند.',
),
criterion(
'transport',
allocation.transport >= transportMin,
phase === 'initial' ? 'رفت‌وآمد حداقل ۲۰ سکه دارد' : 'رفت‌وآمد حداقل ۳۰ سکه دارد',
phase === 'initial'
? 'برای شروع گردش حداقل ۲۰ سکه رفت‌وآمد لازم است.'
: 'بعد از گران شدن، ۳۰ سکه رفت‌وآمد لازم است.',
),
criterion(
'food',
allocation.food >= FOOD_MIN,
'آب و غذا حداقل ۲۵ سکه دارد',
'آب و غذا نیاز این گردش است، نه پس‌انداز هدف.',
),
criterion(
'savings-start',
allocation.savings >= SAVINGS_PARTIAL,
'حداقل ۱۰ سکه برای هدف پس‌انداز',
'پس‌انداز هدف با نیاز سفر یکی نیست. از ۱۰ سکه به بالا این بخش را شروع کرده‌ای.',
),
criterion(
'savings-goal',
allocation.savings >= SAVINGS_GOAL,
'پس‌انداز به هدف ۲۰ سکه رسیده',
'هدف این مأموریت ۲۰ سکه پس‌انداز است.',
),
]
return {
phase,
title: phase === 'initial' ? 'برنامهٔ اول' : 'برنامهٔ بعد از تغییر',
criteria,
}
}
export function gradeBudgetSubmission(answers: BudgetSubmissionAnswer[]): {
score: number
feedback: BudgetMissionFeedback
} {
if (!Array.isArray(answers) || answers.length !== 2) {
throw new BudgetingGradingError(400, 'هر دو بودجهٔ اولیه و نهایی لازم است')
}
const byId = new Map<string, BudgetSubmissionAnswer>()
for (const answer of answers) {
if (!answer || typeof answer.questionId !== 'string') {
throw new BudgetingGradingError(400, 'شناسهٔ بودجه نامعتبر است')
}
if (byId.has(answer.questionId)) {
throw new BudgetingGradingError(400, 'بودجهٔ تکراری است')
}
if (answer.answerId !== undefined || answer.orderedItemIds !== undefined) {
throw new BudgetingGradingError(400, 'قالب پاسخ بودجه نامعتبر است')
}
byId.set(answer.questionId, answer)
}
const initialRaw = byId.get('initial')
const finalRaw = byId.get('final')
if (!initialRaw || !finalRaw || byId.size !== 2) {
throw new BudgetingGradingError(400, 'بودجهٔ اولیه و نهایی باید هر دو ارسال شوند')
}
const initial = assertAllocation(initialRaw.allocations, 'اولیه')
const finalPlan = assertAllocation(finalRaw.allocations, 'نهایی')
assertEssentialNeeds(initial, 'initial')
assertEssentialNeeds(finalPlan, 'final')
const plans = [
scorePlan(initial, TRANSPORT_INITIAL_MIN, 'initial'),
scorePlan(finalPlan, TRANSPORT_FINAL_MIN, 'final'),
]
const earned = plans.reduce(
(sum, plan) => sum + plan.criteria.filter((item) => item.earned).length,
0,
)
return {
score: earned * 10,
feedback: { plans },
}
}
export function getBudgetMissionPublic(): BudgetMissionPublic {
return budgetingMission
}
@@ -0,0 +1,61 @@
import type { BudgetMissionPublic } from './budgeting.types'
export const BUDGET_ACTIVITY_KEY = 'budgeting'
export const BUDGET_TOTAL = 100
export const BUDGET_STEP = 5
export const TRANSPORT_INITIAL_MIN = 20
export const TRANSPORT_FINAL_MIN = 30
export const FOOD_MIN = 25
export const SAVINGS_PARTIAL = 10
export const SAVINGS_GOAL = 20
export const budgetingMission: BudgetMissionPublic = {
id: 'budgeting',
version: 1,
title: 'مأموریت بودجه‌بندی',
guideTitle: 'راهنما',
guideImageKey: 'professor',
ageRange: '۹ تا ۱۲ سال',
totalCoins: BUDGET_TOTAL,
step: BUDGET_STEP,
intro:
'برای گردش قلعهٔ سرزمین طلا ۱۰۰ سکهٔ آموزشی داری. این سکه‌ها از کیف پول واقعی‌ات کم نمی‌شوند. پول را بین رفت‌وآمد، آب و غذا، پس‌انداز هدف و تفریح تقسیم کن. ممکن است وسط راه شرایط تغییر کند.',
rules: [
'هر بار فقط ۵ سکه اضافه یا کم می‌شود.',
'جمع تخصیص‌ها نباید از ۱۰۰ سکه بیشتر شود.',
'سکه‌ای که هنوز جایی نگذاشتی، ذخیره است. ذخیره اجباری نیست.',
'رفت‌وآمد و آب و غذا نیاز این گردش‌اند. پس‌انداز برای هدف جداست و تفریح اختیاری است.',
],
eventMessage: 'هزینه رفت‌وآمد ۱۰ سکه بیشتر شد؛ حالا ۳۰ سکه لازم داری.',
reserveLabel: 'ذخیره',
categories: [
{
id: 'transport',
label: 'رفت‌وآمد',
hint: 'اول حداقل ۲۰ سکه لازم است. بعد از تغییر شرایط ۳۰ سکه.',
requiredInitial: TRANSPORT_INITIAL_MIN,
requiredFinal: TRANSPORT_FINAL_MIN,
},
{
id: 'food',
label: 'آب و غذا',
hint: 'حداقل ۲۵ سکه برای این گردش.',
requiredInitial: FOOD_MIN,
requiredFinal: FOOD_MIN,
},
{
id: 'savings',
label: 'پس‌انداز برای هدف',
hint: 'هدف ۲۰ سکه است. این نیاز ضروری سفر نیست.',
requiredInitial: null,
requiredFinal: null,
},
{
id: 'fun',
label: 'تفریح',
hint: 'اختیاری است و امتیاز اضافه ندارد.',
requiredInitial: null,
requiredFinal: null,
},
],
}
@@ -0,0 +1,45 @@
export interface BudgetAllocation {
transport: number
food: number
savings: number
fun: number
}
export interface BudgetCriterionFeedback {
id: string
label: string
earned: boolean
detail: string
}
export interface BudgetPlanFeedback {
phase: 'initial' | 'final'
title: string
criteria: BudgetCriterionFeedback[]
}
export interface BudgetMissionFeedback {
plans: BudgetPlanFeedback[]
}
export interface BudgetMissionPublic {
id: 'budgeting'
version: number
title: string
guideTitle: string
guideImageKey: string
ageRange: string
totalCoins: number
step: number
intro: string
rules: string[]
eventMessage: string
reserveLabel: string
categories: Array<{
id: keyof BudgetAllocation
label: string
hint: string
requiredInitial: number | null
requiredFinal: number | null
}>
}
@@ -0,0 +1,82 @@
import { CASTLE_COMPLETE_MESSAGE, castleExamPublic, getCastleQuestionBank } from './castle.mission'
import type { CastleExamFeedback, CastleExamPublic } from './castle.types'
export class CastleExamGradingError extends Error {
statusCode: number
constructor(statusCode: number, message: string) {
super(message)
this.statusCode = statusCode
}
}
export interface CastleSubmissionAnswer {
questionId?: string
answerId?: string
orderedItemIds?: unknown
allocations?: unknown
holdings?: unknown
plan?: unknown
score?: unknown
stars?: unknown
reward?: unknown
completed?: unknown
}
export function gradeCastleExam(answers: CastleSubmissionAnswer[]): {
score: number
feedback: CastleExamFeedback
} {
if (!Array.isArray(answers)) throw new CastleExamGradingError(400, 'فرمت پاسخ نامعتبر است')
const bank = getCastleQuestionBank()
const seen = new Set<string>()
const chosen = new Map<string, string>()
for (const answer of answers) {
if (!answer || typeof answer.questionId !== 'string' || typeof answer.answerId !== 'string') {
throw new CastleExamGradingError(400, 'شناسهٔ پاسخ نامعتبر است')
}
if (seen.has(answer.questionId)) {
throw new CastleExamGradingError(400, `پاسخ ${answer.questionId} تکراری است`)
}
seen.add(answer.questionId)
if (
answer.orderedItemIds !== undefined ||
answer.allocations !== undefined ||
answer.holdings !== undefined ||
answer.plan !== undefined ||
answer.score !== undefined ||
answer.stars !== undefined ||
answer.reward !== undefined ||
answer.completed !== undefined
) {
throw new CastleExamGradingError(400, 'فیلد غیرمجاز در پاسخ آزمون')
}
const question = bank.find((item) => item.id === answer.questionId)
if (!question) throw new CastleExamGradingError(400, `سؤال ${answer.questionId} پیدا نشد`)
if (!question.options.some((option) => option.id === answer.answerId)) {
throw new CastleExamGradingError(400, `گزینهٔ نامعتبر برای سؤال ${answer.questionId}`)
}
chosen.set(answer.questionId, answer.answerId)
}
if (chosen.size !== bank.length) throw new CastleExamGradingError(400, 'پاسخ همهٔ سؤال‌ها لازم است')
const questions = bank.map((question) => ({
id: question.id,
sourceLevelId: question.sourceLevelId,
earned: chosen.get(question.id) === question.correctOptionId,
selectedAnswerId: chosen.get(question.id) ?? '',
explanation: question.explanation,
}))
const correctCount = questions.filter((item) => item.earned).length
const score = Math.round((correctCount * 100) / 12)
return {
score,
feedback: {
summary: score >= 60 ? CASTLE_COMPLETE_MESSAGE : 'این تلاش هنوز کامل نشده. پاسخ‌ها را ببین و دوباره امتحان کن.',
completed: score >= 60,
questions,
},
}
}
export function getCastleExamPublic(): CastleExamPublic {
return castleExamPublic
}
+194
View File
@@ -0,0 +1,194 @@
import type { CastleExamPublic, CastleQuestionPublic } from './castle.types'
export const CASTLE_ACTIVITY_KEY = 'castle-exam'
const sections = [
{ id: 'basics', title: 'پایه‌های پول' },
{ id: 'market', title: 'بازار و برنامه' },
{ id: 'choices', title: 'تصمیم‌های بزرگ‌تر' },
] as const
const bank = [
{
id: 'castle-q1',
sourceLevelId: 1,
sectionId: 'basics',
prompt: 'در یک خرید ساده، کاربرد پول چیست؟',
options: [
{ id: 'exchange', label: 'مبادله را آسان می‌کند و مقایسه قیمت را ممکن می‌سازد' },
{ id: 'decorate', label: 'فقط برای تزیین کیف است' },
{ id: 'always-more', label: 'هرچه بیشتر باشد، همیشه همه چیز ارزان‌تر می‌شود' },
],
correctOptionId: 'exchange',
explanation: 'پول کمک می‌کند به‌جای تعویض کالا با کالا، قیمت‌ها را با هم مقایسه کنیم.',
},
{
id: 'castle-q2',
sourceLevelId: 2,
sectionId: 'basics',
prompt: 'برای یک هدف در آینده، کدام رفتار مناسب‌تر است؟',
options: [
{ id: 'regular', label: 'مبلغی متناسب با شرایط را منظم کنار بگذاریم' },
{ id: 'all-now', label: 'همه پول این هفته را خرج کنیم و بعداً فکری کنیم' },
{ id: 'borrow', label: 'برای رسیدن سریع، بدهی بسازیم' },
],
correctOptionId: 'regular',
explanation: 'پس‌انداز مناسب، مبلغی است که با نیازها جور باشد و بتوانی ادامه بدهی.',
},
{
id: 'castle-q3',
sourceLevelId: 3,
sectionId: 'basics',
prompt: 'بودجه کم است. آب آشامیدنی لازم است و یک وسیله تزئینی اختیاری. اول کدام؟',
options: [
{ id: 'water', label: 'اول آب آشامیدنی را تأمین کنیم' },
{ id: 'decor', label: 'اول وسیله تزئینی، چون قشنگ‌تر است' },
{ id: 'neither', label: 'هیچ‌کدام فرقی ندارد' },
],
correctOptionId: 'water',
explanation: 'در این سناریو آب نیاز ضروری است و وسیله تزئینی خواسته اختیاری. اول نیاز را تأمین می‌کنیم.',
},
{
id: 'castle-q4',
sourceLevelId: 4,
sectionId: 'basics',
prompt: 'کدام جمله درباره طلا دقیق‌تر است؟',
options: [
{ id: 'moves', label: 'طلا یک دارایی است و قیمتش می‌تواند بالا یا پایین برود' },
{ id: 'fixed', label: 'قیمت طلا همیشه ثابت می‌ماند' },
{ id: 'only-up', label: 'قیمت طلا فقط بالا می‌رود' },
],
correctOptionId: 'moves',
explanation: 'طلا دارایی است، نه پول نقد هر روز. قیمتش می‌تواند تغییر کند.',
},
{
id: 'castle-q5',
sourceLevelId: 5,
sectionId: 'market',
prompt: 'آیا بالارفتن قیمت امروز تضمین می‌کند فردا هم بالا برود؟',
options: [
{ id: 'no', label: 'خیر' },
{ id: 'yes', label: 'بله، همیشه ادامه پیدا می‌کند' },
],
correctOptionId: 'no',
explanation: 'قیمت دیروز یا امروز، قیمت فردا را تضمین نمی‌کند.',
},
{
id: 'castle-q6',
sourceLevelId: 6,
sectionId: 'market',
prompt: 'قیمت همان سبد کالا از ۱۰۰ به ۱۲۰ رسیده و پول تو هنوز ۱۰۰ است. چه می‌شود؟',
options: [
{ id: 'less', label: 'با همان پول، مقدار کمتری از آن کالاها می‌توان خرید' },
{ id: 'more', label: 'با همان پول، مقدار بیشتری می‌توان خرید' },
{ id: 'same', label: 'قدرت خرید عوض نمی‌شود' },
],
correctOptionId: 'less',
explanation: 'وقتی قیمت سبد بالا برود و پولت ثابت بماند، قدرت خرید کمتر می‌شود.',
},
{
id: 'castle-q7',
sourceLevelId: 7,
sectionId: 'market',
prompt: '۱۰۰ سکه داری. رفت‌وآمد ۳۰ و آب‌وغذا ۲۵ لازم است. کدام برنامه معتبر است؟',
options: [
{ id: 'valid', label: 'رفت‌وآمد ۳۰، غذا ۲۵، پس‌انداز ۲۰، تفریح ۱۵، ذخیره ۱۰' },
{ id: 'over', label: 'رفت‌وآمد ۳۰، غذا ۲۵، پس‌انداز ۳۰، تفریح ۲۰، ذخیره ۱۰' },
{ id: 'short', label: 'رفت‌وآمد ۲۰، غذا ۲۵، پس‌انداز ۳۰، تفریح ۱۵، ذخیره ۱۰' },
],
correctOptionId: 'valid',
explanation: 'برنامه معتبر نیازها را تأمین می‌کند و مجموعش از ۱۰۰ بیشتر نیست. گزینه دوم ۱۱۵ است و گزینه سوم رفت‌وآمد را کم دارد.',
},
{
id: 'castle-q8',
sourceLevelId: 8,
sectionId: 'market',
prompt: 'برای نیازی ضروری در دو روز آینده، کدام رفتار مناسب‌تر است؟',
options: [
{ id: 'keep', label: 'پول لازم را وارد پیشنهاد نامطمئن با احتمال زیان نکنیم' },
{ id: 'risk', label: 'همان پول ضروری را برای سود بیشتر وارد پیشنهاد نامطمئن کنیم' },
],
correctOptionId: 'keep',
explanation: 'پولی که به‌زودی برای نیاز ضروری لازم است، نباید وارد گزینه‌ای شود که ممکن است کم شود.',
},
{
id: 'castle-q9',
sourceLevelId: 9,
sectionId: 'choices',
prompt: 'آیا تقسیم پول بین چند گزینه، زیان را ناممکن می‌کند؟',
options: [
{ id: 'no', label: 'خیر؛ چند گزینه ممکن است هم‌زمان افت کنند' },
{ id: 'yes', label: 'بله، با چند گزینه دیگر زیان ممکن نیست' },
],
correctOptionId: 'no',
explanation: 'تقسیم می‌تواند وابستگی به یک گزینه را کم کند، اما افت همگانی هنوز ممکن است.',
},
{
id: 'castle-q10',
sourceLevelId: 10,
sectionId: 'choices',
prompt: 'در مثال فرضی، ۱۰۰ سکه با رشد مرکب ۱۰٪ در هر دوره و بدون هزینه، پس از دو دوره چقدر است؟ این وعده واقعی نیست.',
options: [
{ id: '121', label: '۱۲۱' },
{ id: '120', label: '۱۲۰' },
{ id: '110', label: '۱۱۰' },
],
correctOptionId: '121',
explanation: 'با فرض ثابت‌ماندن نرخ و نبودن هزینه: ۱۰۰ + ۱۰ = ۱۱۰ و ۱۱۰ + ۱۱ = ۱۲۱.',
},
{
id: 'castle-q11',
sourceLevelId: 11,
sectionId: 'choices',
prompt: 'دو کالای یکسان: الف قیمت ۳۸ و تحویل ۱۲؛ ب قیمت ۴۰ و تحویل ۵. کدام مبلغ نهایی کمتری دارد؟',
options: [
{ id: 'b', label: 'ب، چون ۴۵ در برابر ۵۰ است' },
{ id: 'a', label: 'الف، چون عدد روی کارت کوچک‌تر است' },
],
correctOptionId: 'b',
explanation: 'هزینه نهایی قیمت به‌اضافه تحویل است: ۳۸ + ۱۲ = ۵۰ و ۴۰ + ۵ = ۴۵.',
},
{
id: 'castle-q12',
sourceLevelId: 12,
sectionId: 'choices',
prompt: 'هدف ۱۲۰ سکه، موجودی ۳۰ و پس‌انداز هفتگی ۱۵ است. اگر شرایط ثابت بماند، چند هفته لازم است؟',
options: [
{ id: '6', label: '۶ هفته' },
{ id: '8', label: '۸ هفته' },
{ id: '4', label: '۴ هفته' },
],
correctOptionId: '6',
explanation: '۹۰ سکه باقی مانده و ۱۵ سکه در هفته، با شرایط ثابت، ۶ هفته می‌شود. این برآورد است.',
},
] as const
function sectionTitle(sectionId: string): string {
return sections.find((section) => section.id === sectionId)?.title ?? ''
}
export const castleExamPublic: CastleExamPublic = {
id: 'castle-exam',
version: 1,
title: 'آزمون قلعه',
guideTitle: 'راهنما',
guideImageKey: 'professor',
intro:
'به قلعه رسیدی! حالا با تصمیم‌های کوچک نشان بده چه چیزهایی یاد گرفته‌ای. عجله‌ای نیست و قبل از ثبت می‌توانی پاسخ‌ها را مرور کنی.',
sections: sections.map((section) => ({ id: section.id, title: section.title })),
questions: bank.map((question) => ({
id: question.id,
sourceLevelId: question.sourceLevelId,
sectionId: question.sectionId,
sectionTitle: sectionTitle(question.sectionId),
prompt: question.prompt,
options: question.options.map((option) => ({ id: option.id, label: option.label })),
})) satisfies CastleQuestionPublic[],
}
export function getCastleQuestionBank() {
return bank
}
export const CASTLE_COMPLETE_MESSAGE =
'آفرین! مسیر آموزشی قلعه را کامل کردی. می‌توانی برای مرور و بهترشدن دوباره بازی کنی.'
+31
View File
@@ -0,0 +1,31 @@
export interface CastleQuestionPublic {
id: string
sourceLevelId: number
sectionId: string
sectionTitle: string
prompt: string
options: Array<{ id: string; label: string }>
}
export interface CastleExamPublic {
id: string
version: number
title: string
guideTitle: string
guideImageKey: string
intro: string
sections: Array<{ id: string; title: string }>
questions: CastleQuestionPublic[]
}
export interface CastleExamFeedback {
summary: string
completed: boolean
questions: Array<{
id: string
sourceLevelId: number
earned: boolean
selectedAnswerId: string
explanation: string
}>
}
+62
View File
@@ -1,6 +1,7 @@
import { Type } from 'class-transformer'
import {
IsArray,
IsInt,
IsNumber,
IsOptional,
IsString,
@@ -8,6 +9,52 @@ import {
ValidateNested,
} from 'class-validator'
export class BudgetAllocationDto {
@IsInt()
@Min(0)
transport!: number
@IsInt()
@Min(0)
food!: number
@IsInt()
@Min(0)
savings!: number
@IsInt()
@Min(0)
fun!: number
}
export class PortfolioHoldingDto {
@IsInt()
@Min(0)
sun!: number
@IsInt()
@Min(0)
rain!: number
@IsInt()
@Min(0)
wind!: number
}
export class GoalPlanDto {
@IsInt()
@Min(0)
initialWeekly!: number
@IsInt()
@Min(0)
exceptionWeekly!: number
@IsInt()
@Min(0)
continueWeekly!: number
}
export class LessonAnswerDto {
@IsString()
questionId!: string
@@ -20,6 +67,21 @@ export class LessonAnswerDto {
@IsArray()
@IsString({ each: true })
orderedItemIds?: string[]
@IsOptional()
@ValidateNested()
@Type(() => BudgetAllocationDto)
allocations?: BudgetAllocationDto
@IsOptional()
@ValidateNested()
@Type(() => PortfolioHoldingDto)
holdings?: PortfolioHoldingDto
@IsOptional()
@ValidateNested()
@Type(() => GoalPlanDto)
plan?: GoalPlanDto
}
export class CompleteLevelDto {
+133
View File
@@ -0,0 +1,133 @@
import {
CONTINUE_RATES,
EXCEPTION_RATES,
INITIAL_RATES,
initialPlan,
revisedPlan,
} from './goal.math'
import { financialGoalMission, getGoalQuestionBank } from './goal.mission'
import type { FinancialGoalFeedback, FinancialGoalPublic } from './goal.types'
export class FinancialGoalGradingError extends Error {
statusCode: number
constructor(statusCode: number, message: string) {
super(message)
this.statusCode = statusCode
}
}
export interface GoalPlanInput {
initialWeekly: number
exceptionWeekly: number
continueWeekly: number
}
export interface GoalSubmissionAnswer {
questionId?: string
answerId?: string
plan?: GoalPlanInput
orderedItemIds?: unknown
allocations?: unknown
holdings?: unknown
weeks?: unknown
balance?: unknown
score?: unknown
reward?: unknown
}
function assertPlan(value: GoalPlanInput | undefined): GoalPlanInput {
if (!value || typeof value !== 'object') {
throw new FinancialGoalGradingError(400, 'برنامه هدف نامعتبر است')
}
const rates = [
['initialWeekly', INITIAL_RATES],
['exceptionWeekly', EXCEPTION_RATES],
['continueWeekly', CONTINUE_RATES],
] as const
const plan = {} as GoalPlanInput
for (const [key, allowed] of rates) {
const amount = value[key]
if (typeof amount !== 'number' || !Number.isInteger(amount) || amount < 0) {
throw new FinancialGoalGradingError(400, 'مبلغ پس‌انداز نامعتبر است')
}
if (!allowed.includes(amount as never)) {
throw new FinancialGoalGradingError(400, 'نرخ پس‌انداز مجاز نیست')
}
plan[key] = amount
}
if (plan.continueWeekly === 0) {
throw new FinancialGoalGradingError(400, 'نرخ ادامه نمی‌تواند صفر باشد')
}
return plan
}
export function gradeFinancialGoalSubmission(answers: GoalSubmissionAnswer[]): {
score: number
feedback: FinancialGoalFeedback
} {
if (!Array.isArray(answers)) throw new FinancialGoalGradingError(400, 'فرمت پاسخ نامعتبر است')
const bank = getGoalQuestionBank()
const seen = new Set<string>()
let plan: GoalPlanInput | null = null
const chosen = new Map<string, string>()
for (const answer of answers) {
if (!answer || typeof answer.questionId !== 'string') {
throw new FinancialGoalGradingError(400, 'شناسهٔ پاسخ نامعتبر است')
}
if (seen.has(answer.questionId)) {
throw new FinancialGoalGradingError(400, `پاسخ ${answer.questionId} تکراری است`)
}
seen.add(answer.questionId)
if (
answer.orderedItemIds !== undefined ||
answer.allocations !== undefined ||
answer.holdings !== undefined ||
answer.weeks !== undefined ||
answer.balance !== undefined ||
answer.score !== undefined ||
answer.reward !== undefined
) {
throw new FinancialGoalGradingError(400, 'فیلد غیرمجاز در پاسخ هدف')
}
if (answer.questionId === 'plan') {
plan = assertPlan(answer.plan)
continue
}
if (typeof answer.answerId !== 'string') {
throw new FinancialGoalGradingError(400, 'نوع پاسخ سؤال نامعتبر است')
}
const question = bank.find((item) => item.id === answer.questionId)
if (!question) throw new FinancialGoalGradingError(400, `سؤال ${answer.questionId} پیدا نشد`)
if (!question.options.some((option) => option.id === answer.answerId)) {
throw new FinancialGoalGradingError(400, `گزینهٔ نامعتبر برای سؤال ${answer.questionId}`)
}
chosen.set(answer.questionId, answer.answerId)
}
if (!plan) throw new FinancialGoalGradingError(400, 'برنامه هدف ارسال نشده')
if (chosen.size !== bank.length) throw new FinancialGoalGradingError(400, 'پاسخ همهٔ سؤال‌ها لازم است')
const initial = initialPlan(plan.initialWeekly)
const revised = revisedPlan(plan.initialWeekly, plan.exceptionWeekly, plan.continueWeekly)
const questions = bank.map((question) => ({
id: question.id,
earned: chosen.get(question.id) === question.correctOptionId,
explanation: question.explanation,
}))
return {
score: questions.filter((item) => item.earned).length * 20,
feedback: {
summary: financialGoalMission.summary,
timeline: {
initialWeeks: initial.weeks,
balanceAfterTwo: revised.balanceAfterTwo,
remainingWeeks: revised.remainingWeeks,
totalWeeks: revised.totalWeeks,
finalBalance: revised.finalBalance,
},
questions,
},
}
}
export function getFinancialGoalPublic(): FinancialGoalPublic {
return financialGoalMission
}
+55
View File
@@ -0,0 +1,55 @@
export const GOAL_PRICE = 120
export const CURRENT_SAVINGS = 30
export const WEEKLY_INCOME = 30
export const NORMAL_ESSENTIAL = 10
export const EXCEPTION_ESSENTIAL = 20
export const INITIAL_RATES = [5, 10, 15, 20] as const
export const EXCEPTION_RATES = [0, 5, 10] as const
export const CONTINUE_RATES = [5, 10, 15, 20] as const
export interface InitialPlanResult {
remaining: number
weeks: number
totalSaved: number
extra: number
optional: number
}
export interface RevisedPlanResult {
balanceAfterTwo: number
remainingWeeks: number
totalWeeks: number
finalBalance: number
extra: number
}
export function optionalCoins(essential: number, saving: number): number {
return WEEKLY_INCOME - essential - saving
}
export function initialPlan(weekly: number): InitialPlanResult {
const remaining = GOAL_PRICE - CURRENT_SAVINGS
const weeks = Math.ceil(remaining / weekly)
const totalSaved = CURRENT_SAVINGS + weeks * weekly
return {
remaining,
weeks,
totalSaved,
extra: totalSaved - GOAL_PRICE,
optional: optionalCoins(NORMAL_ESSENTIAL, weekly),
}
}
export function revisedPlan(initialWeekly: number, exceptionWeekly: number, continueWeekly: number): RevisedPlanResult {
const balanceAfterTwo = CURRENT_SAVINGS + initialWeekly + exceptionWeekly
const gap = Math.max(0, GOAL_PRICE - balanceAfterTwo)
const remainingWeeks = gap === 0 ? 0 : Math.ceil(gap / continueWeekly)
const finalBalance = balanceAfterTwo + remainingWeeks * continueWeekly
return {
balanceAfterTwo,
remainingWeeks,
totalWeeks: 2 + remainingWeeks,
finalBalance,
extra: finalBalance - GOAL_PRICE,
}
}
@@ -0,0 +1,105 @@
import {
CONTINUE_RATES,
CURRENT_SAVINGS,
EXCEPTION_ESSENTIAL,
EXCEPTION_RATES,
GOAL_PRICE,
INITIAL_RATES,
NORMAL_ESSENTIAL,
WEEKLY_INCOME,
} from './goal.math'
import type { FinancialGoalPublic } from './goal.types'
export const FINANCIAL_GOAL_ACTIVITY_KEY = 'financial-goal'
const questions = [
{
id: 'q-goal',
prompt: 'هدف مشخص کدام است؟',
options: [
{ id: 'vague', label: 'فقط پول بیشتری می‌خواهم' },
{ id: 'specific', label: 'خرید بسته مشخص با قیمت ۱۲۰ و برنامه پس‌انداز' },
],
correctOptionId: 'specific',
explanation: 'هدف مشخص می‌گوید چه چیزی، با چه قیمتی و با چه برنامه‌ای خریده می‌شود.',
},
{
id: 'q-left',
prompt: 'با ۳۰ سکه موجود، چقدر تا هدف باقی مانده؟',
options: [
{ id: '90', label: '۹۰ سکه' },
{ id: '120', label: '۱۲۰ سکه' },
{ id: '30', label: '۳۰ سکه' },
],
correctOptionId: '90',
explanation: '۱۲۰ منهای ۳۰ می‌شود ۹۰ سکه.',
},
{
id: 'q-weeks',
prompt: 'با پس‌انداز ۱۵ در هفته و شرایط اولیه، چند هفته لازم است؟',
options: [
{ id: '5', label: '۵ هفته' },
{ id: '6', label: '۶ هفته' },
{ id: '9', label: '۹ هفته' },
],
correctOptionId: '6',
explanation: '۹۰ تقسیم بر ۱۵ می‌شود ۶ هفته. این برآورد است، اگر شرایط ثابت بماند.',
},
{
id: 'q-need',
prompt: 'در هفته‌ای که نیاز ضروری بیشتر شده، کدام کار مناسب‌تر است؟',
options: [
{ id: 'drop', label: 'نیاز ضروری را حذف کن تا زودتر به هدف برسی' },
{ id: 'adjust', label: 'نیاز را تأمین کن و مبلغ پس‌انداز یا زمان هدف را اصلاح کن' },
],
correctOptionId: 'adjust',
explanation: 'نیاز ضروری نباید فدای هدف شود. برنامه را می‌شود آهسته‌تر کرد.',
},
{
id: 'q-change',
prompt: 'اگر شرایط یا قیمت هدف تغییر کند، چه باید کرد؟',
options: [
{ id: 'keep', label: 'برآورد قبلی را تضمین بدان و تغییر نده' },
{ id: 'update', label: 'برنامه و زمان را دوباره بررسی و به‌روز کن' },
],
correctOptionId: 'update',
explanation: 'برآورد قبلی تضمین نیست. با تغییر شرایط، زمان را دوباره حساب می‌کنیم.',
},
] as const
export const financialGoalMission: FinancialGoalPublic = {
id: 'financial-goal',
version: 1,
title: 'هدف مالی',
guideTitle: 'راهنما',
guideImageKey: 'professor',
intro:
'برای خرید بسته ساخت کاردستی قلعه برنامه می‌ریزی. سکه‌ها خیالی‌اند و از کیف پول بازی کم نمی‌شوند. مقدار پس‌انداز باید با نیازها سازگار و قابل ادامه باشد.',
rules: [
'سریع‌ترین برنامه به‌خودی‌خود بهتر نیست.',
'صفر بودن خرج اختیاری به‌تنهایی اشتباه نیست.',
'در هفته استثنایی می‌توانی پس‌انداز نکنی.',
'زمان‌ها برآوردند، نه وعده واقعی.',
],
eventMessage: 'در هفته دوم، از ۳۰ سکه پول هفتگی، ۲۰ سکه برای یک نیاز ضروری لازم شد.',
plan: {
goalPrice: GOAL_PRICE,
currentSavings: CURRENT_SAVINGS,
weeklyIncome: WEEKLY_INCOME,
normalEssential: NORMAL_ESSENTIAL,
exceptionEssential: EXCEPTION_ESSENTIAL,
initialRates: [...INITIAL_RATES],
exceptionRates: [...EXCEPTION_RATES],
continueRates: [...CONTINUE_RATES],
},
questions: questions.map(({ id, prompt, options }) => ({
id,
prompt,
options: options.map((option) => ({ id: option.id, label: option.label })),
})),
summary: 'هدف مشخص، مبلغ باقی‌مانده و زمان برآوردی را با تغییر شرایط دوباره بررسی می‌کنیم.',
}
export function getGoalQuestionBank() {
return questions
}
@@ -0,0 +1,42 @@
export interface GoalPlanPublic {
goalPrice: number
currentSavings: number
weeklyIncome: number
normalEssential: number
exceptionEssential: number
initialRates: number[]
exceptionRates: number[]
continueRates: number[]
}
export interface GoalQuestionPublic {
id: string
prompt: string
options: Array<{ id: string; label: string }>
}
export interface FinancialGoalPublic {
id: string
version: number
title: string
guideTitle: string
guideImageKey: string
intro: string
rules: string[]
eventMessage: string
plan: GoalPlanPublic
questions: GoalQuestionPublic[]
summary: string
}
export interface FinancialGoalFeedback {
summary: string
timeline: {
initialWeeks: number
balanceAfterTwo: number
remainingWeeks: number
totalWeeks: number
finalBalance: number
}
questions: Array<{ id: string; earned: boolean; explanation: string }>
}
+2 -2
View File
@@ -13,7 +13,7 @@ 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 type { LevelSubmissionAnswer } from './levels/level.types'
import { GoldLandProgressionService } from './services/progression.service'
@Controller('api/gold-land')
@@ -80,7 +80,7 @@ export class GoldLandController {
@Body(strictBodyPipe(CompleteLevelDto)) dto: CompleteLevelDto,
) {
return this.progression.completeLevel(userId, levelId, {
answers: dto.answers as LessonSubmissionAnswer[],
answers: dto.answers as LevelSubmissionAnswer[],
durationMs: dto.durationMs,
})
}
@@ -0,0 +1,102 @@
import type { LessonDefinition } from './lesson.types.js'
import { buildGrowthExample } from './growth-example.js'
const example = buildGrowthExample(100, 10, 3)
export const compoundInterestLesson: LessonDefinition = {
id: 'compound-interest',
version: 1,
title: 'سود مرکب',
steps: [
{
id: 'compound-story',
type: 'story',
title: 'راهنما',
body: 'وقتی سود در حساب بماند، در دوره بعد ممکن است خودش هم در محاسبه رشد شرکت کند. این یک مثال ریاضی با نرخ فرضی ثابت است، نه وعده سود واقعی.',
imageKey: 'professor',
},
{
id: 'compound-table',
type: 'growth_table',
title: 'دو روش رشد',
body: '۱۰۰ سکه اولیه و نرخ فرضی ۱۰٪ در هر دوره. رشد ساده هر بار فقط ۱۰٪ مبلغ اولیه را اضافه می‌کند. رشد مرکب هر بار ۱۰٪ موجودی همان دوره را اضافه می‌کند.',
disclaimer:
'این مثال نرخ ثابت و بدون هزینه فرض می‌کند. در دنیای واقعی سود ممکن است ثابت نباشد، زیان رخ دهد و هزینه و تورم بر نتیجه اثر بگذارند.',
unitLabel: 'دوره فرضی',
principalTenths: example.principalTenths,
ratePercent: example.ratePercent,
methods: [
{ id: 'simple', label: 'رشد ساده', note: 'رشد هر دوره فقط از مبلغ اولیه حساب می‌شود.' },
{ id: 'compound', label: 'رشد مرکب', note: 'رشد هر دوره از موجودی جدید، شامل رشد قبلی، حساب می‌شود.' },
],
periods: example.periods,
},
{
id: 'compound-q1',
type: 'single_choice',
title: 'سؤال ۱',
question: 'تفاوت اصلی رشد مرکب چیست؟',
options: [
{ id: 'initial-only', label: 'همیشه فقط از مبلغ اولیه حساب می‌شود' },
{ id: 'on-balance', label: 'رشد دوره بعد بر موجودی جدید، شامل رشد قبلی، محاسبه می‌شود' },
{ id: 'same', label: 'با رشد ساده هیچ فرقی ندارد' },
],
correctAnswerId: 'on-balance',
explanation: 'در رشد مرکب، رشد قبلی داخل موجودی می‌ماند و دوره بعد روی همان موجودی جدید حساب می‌شود.',
},
{
id: 'compound-q2',
type: 'single_choice',
title: 'سؤال ۲',
question: 'پس از دو دوره، مبلغ روش مرکب چقدر است؟',
options: [
{ id: '120', label: '۱۲۰ سکه' },
{ id: '121', label: '۱۲۱ سکه' },
{ id: '130', label: '۱۳۰ سکه' },
],
correctAnswerId: '121',
explanation: 'دوره اول ۱۰۰ + ۱۰ = ۱۱۰ و دوره دوم ۱۱۰ + ۱۱ = ۱۲۱.',
},
{
id: 'compound-q3',
type: 'single_choice',
title: 'سؤال ۳',
question: 'چرا رشد دوره دوم در روش مرکب ۱۱ سکه است؟',
options: [
{ id: 'from-110', label: 'چون ۱۰٪ از ۱۱۰ محاسبه شده است' },
{ id: 'from-100', label: 'چون همیشه ۱۰٪ از ۱۰۰ است' },
{ id: 'random', label: 'چون عدد ۱۱ تصادفی انتخاب شده' },
],
correctAnswerId: 'from-110',
explanation: 'موجودی شروع دوره دوم ۱۱۰ است و ۱۰٪ آن ۱۱ سکه می‌شود.',
},
{
id: 'compound-q4',
type: 'single_choice',
title: 'سؤال ۴',
question: 'پس از سه دوره، کدام روش در همین مثال مبلغ بیشتری دارد؟',
options: [
{ id: 'simple', label: 'ساده؛ ۱۳۰ در برابر ۱۳۳٫۱' },
{ id: 'compound', label: 'مرکب؛ ۱۳۳٫۱ در برابر ۱۳۰' },
{ id: 'equal', label: 'هر دو برابرند' },
],
correctAnswerId: 'compound',
explanation: 'فقط در همین مثال با نرخ ثابت، مرکب ۱۳۳٫۱ و ساده ۱۳۰ است. این نتیجه برای هر سرمایه‌گذاری واقعی تضمین نیست.',
},
{
id: 'compound-q5',
type: 'true_false',
title: 'سؤال ۵',
question: 'این مثال تضمین می‌کند هر سرمایه‌گذاری واقعی همیشه همین رشد را دارد.',
correctAnswerId: 'false',
explanation: 'خیر. مثال نرخ ثابت و بدون هزینه است. سود واقعی ممکن است تغییر کند، زیان پیش بیاید و هزینه یا تورم نتیجه را عوض کند.',
},
{
id: 'compound-summary',
type: 'summary',
title: 'جمع‌بندی',
body: 'رشد ساده فقط به مبلغ اولیه نگاه می‌کند. رشد مرکب به موجودی جدید نگاه می‌کند. این تفاوت در همین مثال بعد از سه دوره ۱۳۳٫۱ را در برابر ۱۳۰ نشان داد، ولی تضمین دنیای واقعی نیست.',
highlight: 'رشد روی موجودی جدید، تضمین سود واقعی نیست.',
},
],
}
+54
View File
@@ -0,0 +1,54 @@
export interface GrowthRow {
methodId: 'simple' | 'compound'
label: string
startTenths: number
growthTenths: number
endTenths: number
}
export interface GrowthPeriod {
index: number
rows: GrowthRow[]
}
export interface GrowthExample {
principalTenths: number
ratePercent: number
periods: GrowthPeriod[]
}
export function buildGrowthExample(principalCoins = 100, ratePercent = 10, periodCount = 3): GrowthExample {
if (!Number.isInteger(principalCoins) || !Number.isInteger(ratePercent) || !Number.isInteger(periodCount)) {
throw new Error('پارامتر رشد باید عدد صحیح باشد')
}
const principalTenths = principalCoins * 10
const periods: GrowthPeriod[] = [
{
index: 0,
rows: [
{ methodId: 'simple', label: 'رشد ساده', startTenths: principalTenths, growthTenths: 0, endTenths: principalTenths },
{ methodId: 'compound', label: 'رشد مرکب', startTenths: principalTenths, growthTenths: 0, endTenths: principalTenths },
],
},
]
let compound = principalTenths
for (let index = 1; index <= periodCount; index += 1) {
const simpleStart = principalTenths + ((principalTenths * ratePercent) / 100) * (index - 1)
const simpleGrowth = (principalTenths * ratePercent) / 100
const compoundGrowth = (compound * ratePercent) / 100
if (![simpleStart, simpleGrowth, compoundGrowth].every(Number.isInteger)) {
throw new Error('این نرخ با واحد یک‌دهم سکه دقیق نیست')
}
const simpleEnd = simpleStart + simpleGrowth
const compoundEnd = compound + compoundGrowth
periods.push({
index,
rows: [
{ methodId: 'simple', label: 'رشد ساده', startTenths: simpleStart, growthTenths: simpleGrowth, endTenths: simpleEnd },
{ methodId: 'compound', label: 'رشد مرکب', startTenths: compound, growthTenths: compoundGrowth, endTenths: compoundEnd },
],
})
compound = compoundEnd
}
return { principalTenths, ratePercent, periods }
}
+19 -2
View File
@@ -121,7 +121,7 @@ export function gradeLessonSubmission(
}
validateSortAnswer(step, submitted.orderedItemIds)
} else {
if (!isChoiceLessonAnswer(submitted)) {
if (!isChoiceLessonAnswer(submitted) || ('orderedItemIds' in submitted && submitted.orderedItemIds != null)) {
throw new LessonGradingError(400, `فرمت پاسخ برای سؤال ${questionId} نامعتبر است`)
}
validateChoiceAnswer(step, submitted.answerId)
@@ -132,6 +132,16 @@ export function gradeLessonSubmission(
}
}
const feedbackQuestions = gradable.flatMap(({ questionId, step }) => {
if (!('explanation' in step) || !step.explanation) return []
const submitted = byQuestion.get(questionId)
return [{
id: questionId,
earned: submitted ? isAnswerCorrect(step, submitted) : false,
explanation: step.explanation,
}]
})
for (const answer of answers) {
const known = gradable.some((g) => g.questionId === answer.questionId)
if (!known) {
@@ -142,5 +152,12 @@ export function gradeLessonSubmission(
const totalQuestions = gradable.length
const score = Math.round((correctCount / totalQuestions) * 100)
return { score, correctCount, totalQuestions }
return {
score,
correctCount,
totalQuestions,
...(feedbackQuestions.length === gradable.length
? { feedback: { questions: feedbackQuestions } }
: {}),
}
}
+2
View File
@@ -1,4 +1,5 @@
import type { LessonDefinition } from './lesson.types.js'
import { compoundInterestLesson } from './compound-interest.lesson.js'
import { inflationLesson } from './inflation.lesson.js'
import { whatIsMoneyLesson } from './what-is-money.lesson.js'
import { savingLesson } from './saving.lesson.js'
@@ -14,6 +15,7 @@ const PLAYABLE_LESSONS: Record<string, LessonDefinition> = {
'what-is-gold': whatIsGoldLesson,
'gold-market': goldMarketLesson,
inflation: inflationLesson,
'compound-interest': compoundInterestLesson,
}
export function getPlayableLesson(activityKey: string): LessonDefinition | null {
@@ -11,6 +11,7 @@ const STRIP_KEYS = new Set([
'correctOption',
'answerKey',
'correctOrder',
'explanation',
])
function sanitizeStep(step: LessonStep): SanitizedLessonStep {
+36 -1
View File
@@ -1,4 +1,4 @@
export type LessonStepType = 'story' | 'single_choice' | 'true_false' | 'sort' | 'summary'
export type LessonStepType = 'story' | 'single_choice' | 'true_false' | 'sort' | 'summary' | 'growth_table'
export interface LessonOption {
id: string
@@ -22,12 +22,38 @@ export interface SingleChoiceStep extends LessonStepBase {
question: string
options: LessonOption[]
correctAnswerId: string
explanation?: string
}
export interface TrueFalseStep extends LessonStepBase {
type: 'true_false'
question: string
correctAnswerId: 'true' | 'false'
explanation?: string
}
export interface GrowthTableRow {
methodId: string
label: string
startTenths: number
growthTenths: number
endTenths: number
}
export interface GrowthTablePeriod {
index: number
rows: GrowthTableRow[]
}
export interface GrowthTableStep extends LessonStepBase {
type: 'growth_table'
body: string
disclaimer: string
unitLabel: string
principalTenths: number
ratePercent: number
methods: Array<{ id: string; label: string; note: string }>
periods: GrowthTablePeriod[]
}
export interface SortStep extends LessonStepBase {
@@ -49,6 +75,7 @@ export type LessonStep =
| TrueFalseStep
| SortStep
| SummaryStep
| GrowthTableStep
export interface LessonDefinition {
id: string
@@ -76,10 +103,17 @@ export interface SortLessonAnswer {
export type LessonSubmissionAnswer = ChoiceLessonAnswer | SortLessonAnswer
export interface LessonAnswerFeedback {
id: string
earned: boolean
explanation: string
}
export interface GradeLessonResult {
score: number
correctCount: number
totalQuestions: number
feedback?: { questions: LessonAnswerFeedback[] }
}
export type SanitizedLessonStep =
@@ -88,6 +122,7 @@ export type SanitizedLessonStep =
| Omit<TrueFalseStep, 'correctAnswerId'>
| Omit<SortStep, 'correctOrder'>
| Omit<SummaryStep, never>
| GrowthTableStep
export interface SanitizedLessonDefinition {
id: string
+29 -2
View File
@@ -1,6 +1,19 @@
import type { MeResponse } from '../../shared/types/user.types'
import type { sanitizeLesson } from '../lessons/lesson.sanitizer'
import type { LessonSubmissionAnswer } from '../lessons/lesson.types'
import type { BudgetAllocation, BudgetMissionFeedback, BudgetMissionPublic } from '../budgeting/budgeting.types'
import type { RiskReturnFeedback, RiskReturnPublic } from '../risk-return/risk-return.types'
import type { PortfolioFeedback, PortfolioMissionPublic } from '../portfolio/portfolio.types'
import type { SmartShopFeedback, SmartShopPublic } from '../smart-shop/shop.types'
import type { FinancialGoalFeedback, FinancialGoalPublic } from '../financial-goal/goal.types'
import type { CastleExamFeedback, CastleExamPublic } from '../castle-exam/castle.types'
import type { LessonAnswerFeedback, LessonSubmissionAnswer } from '../lessons/lesson.types'
export interface BudgetLevelAnswer {
questionId: string
allocations: BudgetAllocation
}
export type LevelSubmissionAnswer = LessonSubmissionAnswer | BudgetLevelAnswer
export type LevelStatus =
| 'locked'
@@ -21,7 +34,7 @@ export interface LevelProgressRow {
}
export interface CompleteLevelSubmission {
answers: LessonSubmissionAnswer[]
answers: LevelSubmissionAnswer[]
durationMs?: number
}
@@ -59,6 +72,12 @@ export interface LevelDetailResponse {
activity: {
type: string
lesson?: ReturnType<typeof sanitizeLesson>
budgeting?: BudgetMissionPublic
riskReturn?: RiskReturnPublic
portfolio?: PortfolioMissionPublic
smartShop?: SmartShopPublic
financialGoal?: FinancialGoalPublic
castleExam?: CastleExamPublic
} | null
}
@@ -72,4 +91,12 @@ export interface CompleteLevelResponse {
reward: { xp: number; coins: number }
unlockedLevelId: number | null
me: MeResponse
feedback?:
| BudgetMissionFeedback
| RiskReturnFeedback
| PortfolioFeedback
| SmartShopFeedback
| FinancialGoalFeedback
| CastleExamFeedback
| { questions: LessonAnswerFeedback[] }
}
+7 -7
View File
@@ -99,7 +99,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
activityKey: 'budgeting',
rewardXp: 45,
rewardCoins: 18,
isActive: false,
isActive: true,
},
{
id: 8,
@@ -111,7 +111,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
activityKey: 'risk-return',
rewardXp: 50,
rewardCoins: 20,
isActive: false,
isActive: true,
},
{
id: 9,
@@ -123,7 +123,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
activityKey: 'portfolio',
rewardXp: 50,
rewardCoins: 20,
isActive: false,
isActive: true,
},
{
id: 10,
@@ -135,7 +135,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
activityKey: 'compound-interest',
rewardXp: 45,
rewardCoins: 18,
isActive: false,
isActive: true,
},
{
id: 11,
@@ -147,7 +147,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
activityKey: 'smart-shop',
rewardXp: 45,
rewardCoins: 18,
isActive: false,
isActive: true,
},
{
id: 12,
@@ -159,7 +159,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
activityKey: 'financial-goal',
rewardXp: 50,
rewardCoins: 20,
isActive: false,
isActive: true,
},
{
id: 13,
@@ -171,7 +171,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
activityKey: 'castle-exam',
rewardXp: 100,
rewardCoins: 50,
isActive: false,
isActive: true,
},
]
+118
View File
@@ -0,0 +1,118 @@
import { PORTFOLIO_STEP, PORTFOLIO_TOTAL, simulatePortfolio } from './portfolio.math'
import { getPortfolioQuestionBank, portfolioMission } from './portfolio.mission'
import type { PortfolioFeedback, PortfolioMissionPublic } from './portfolio.types'
import type { PortfolioHolding } from './portfolio.math'
export class PortfolioGradingError extends Error {
statusCode: number
constructor(statusCode: number, message: string) {
super(message)
this.statusCode = statusCode
}
}
const FUND_IDS = ['sun', 'rain', 'wind'] as const
export interface PortfolioSubmissionAnswer {
questionId?: string
answerId?: string
holdings?: PortfolioHolding
orderedItemIds?: string[]
allocations?: unknown
}
function assertHolding(value: unknown): PortfolioHolding {
if (typeof value !== 'object' || value === null) {
throw new PortfolioGradingError(400, 'ترکیب سبد نامعتبر است')
}
const record = value as Record<string, unknown>
const unknown = Object.keys(record).filter((key) => !FUND_IDS.includes(key as (typeof FUND_IDS)[number]))
if (unknown.length > 0) {
throw new PortfolioGradingError(400, 'صندوق ناشناخته در سبد')
}
const holding = {} as PortfolioHolding
for (const id of FUND_IDS) {
const amount = record[id]
if (typeof amount !== 'number' || !Number.isInteger(amount) || amount < 0) {
throw new PortfolioGradingError(400, 'مبلغ صندوق نامعتبر است')
}
if (amount % PORTFOLIO_STEP !== 0) {
throw new PortfolioGradingError(400, 'مبلغ‌ها باید مضرب ۱۰ باشند')
}
holding[id] = amount
}
const sum = holding.sun + holding.rain + holding.wind
if (sum !== PORTFOLIO_TOTAL) {
throw new PortfolioGradingError(400, 'مجموع سبد باید دقیقاً ۱۰۰ سکه باشد')
}
return holding
}
export function gradePortfolioSubmission(answers: PortfolioSubmissionAnswer[]): {
score: number
feedback: PortfolioFeedback
} {
if (!Array.isArray(answers)) {
throw new PortfolioGradingError(400, 'فرمت پاسخ نامعتبر است')
}
const bank = getPortfolioQuestionBank()
const seen = new Set<string>()
let holding: PortfolioHolding | null = null
const chosen = new Map<string, string>()
for (const answer of answers) {
if (!answer || typeof answer.questionId !== 'string') {
throw new PortfolioGradingError(400, 'شناسهٔ پاسخ نامعتبر است')
}
if (seen.has(answer.questionId)) {
throw new PortfolioGradingError(400, `پاسخ ${answer.questionId} تکراری است`)
}
seen.add(answer.questionId)
if (answer.orderedItemIds !== undefined || answer.allocations !== undefined) {
throw new PortfolioGradingError(400, 'فیلد غیرمجاز در پاسخ سبد')
}
if (answer.questionId === 'portfolio') {
holding = assertHolding(answer.holdings)
continue
}
if (typeof answer.answerId !== 'string') {
throw new PortfolioGradingError(400, 'نوع پاسخ سؤال نامعتبر است')
}
const question = bank.find((item) => item.id === answer.questionId)
if (!question) {
throw new PortfolioGradingError(400, `سؤال ${answer.questionId} پیدا نشد`)
}
if (!question.options.some((option) => option.id === answer.answerId)) {
throw new PortfolioGradingError(400, `گزینهٔ نامعتبر برای سؤال ${answer.questionId}`)
}
chosen.set(answer.questionId, answer.answerId)
}
if (!holding) {
throw new PortfolioGradingError(400, 'ترکیب سبد ارسال نشده')
}
if (chosen.size !== bank.length) {
throw new PortfolioGradingError(400, 'پاسخ همهٔ سؤال‌ها لازم است')
}
const questions = bank.map((question) => ({
id: question.id,
earned: chosen.get(question.id) === question.correctOptionId,
explanation: question.explanation,
}))
return {
score: questions.filter((question) => question.earned).length * 20,
feedback: {
summary: portfolioMission.summary,
scenarios: simulatePortfolio(holding),
questions,
},
}
}
export function getPortfolioPublic(): PortfolioMissionPublic {
return portfolioMission
}
+83
View File
@@ -0,0 +1,83 @@
export const PORTFOLIO_TOTAL = 100
export const PORTFOLIO_STEP = 10
export interface PortfolioHolding {
sun: number
rain: number
wind: number
}
export interface PortfolioScenarioResult {
id: string
title: string
note: string
parts: Array<{ fundId: 'sun' | 'rain' | 'wind'; label: string; value: number }>
total: number
changeLabel: string
referenceSunTotal: number
referenceChangeLabel: string
}
export function applyPercent(amount: number, percent: number): number {
return (amount * (100 + percent)) / 100
}
export function changeLabel(total: number, baseline = PORTFOLIO_TOTAL): string {
const delta = total - baseline
if (delta > 0) return `سود ${delta} سکه`
if (delta < 0) return `زیان ${Math.abs(delta)} سکه`
return 'بدون تغییر'
}
const FUNDS = [
{ id: 'sun' as const, label: 'آفتاب' },
{ id: 'rain' as const, label: 'باران' },
{ id: 'wind' as const, label: 'باد' },
]
const SCENARIOS = [
{
id: 'sunny',
title: 'هوای آفتابی',
note: 'مثال ساختگی است، نه پیش‌بینی. آفتاب ۲۰٪ بیشتر، باران ۱۰٪ کمتر، باد بدون تغییر.',
percents: { sun: 20, rain: -10, wind: 0 },
},
{
id: 'rainy',
title: 'روز بارانی',
note: 'مثال ساختگی است. آفتاب ۲۰٪ کمتر، باران ۱۰٪ بیشتر، باد بدون تغییر.',
percents: { sun: -20, rain: 10, wind: 0 },
},
{
id: 'drop',
title: 'افت همگانی',
note: 'مثال ساختگی است. هر سه صندوق ۱۰٪ کمتر می‌شوند.',
percents: { sun: -10, rain: -10, wind: -10 },
},
]
export function simulatePortfolio(holding: PortfolioHolding): PortfolioScenarioResult[] {
const reference: PortfolioHolding = { sun: PORTFOLIO_TOTAL, rain: 0, wind: 0 }
return SCENARIOS.map((scenario) => {
const parts = FUNDS.map((fund) => ({
fundId: fund.id,
label: fund.label,
value: applyPercent(holding[fund.id], scenario.percents[fund.id]),
}))
const total = parts.reduce((sum, part) => sum + part.value, 0)
const referenceSunTotal = FUNDS.reduce(
(sum, fund) => sum + applyPercent(reference[fund.id], scenario.percents[fund.id]),
0,
)
return {
id: scenario.id,
title: scenario.title,
note: scenario.note,
parts,
total,
changeLabel: changeLabel(total),
referenceSunTotal,
referenceChangeLabel: changeLabel(referenceSunTotal),
}
})
}
@@ -0,0 +1,124 @@
import type { PortfolioMissionPublic } from './portfolio.types'
export const PORTFOLIO_ACTIVITY_KEY = 'portfolio'
const questions = [
{
id: 'q-one',
prompt: 'قرار دادن همه پول در یک گزینه چه اثری دارد؟',
options: [
{ id: 'depend', label: 'نتیجه بیشتر به همان گزینه وابسته می‌شود' },
{ id: 'safe', label: 'زیان کاملاً ناممکن می‌شود' },
{ id: 'best', label: 'حتماً بهترین نتیجه آینده را می‌سازد' },
],
correctOptionId: 'depend',
explanation: 'وقتی همه پول در یک گزینه باشد، بالا و پایین همان گزینه تقریباً کل سبد را جابه‌جا می‌کند.',
},
{
id: 'q-loss',
prompt: 'آیا تقسیم پول بین چند گزینه زیان را ناممکن می‌کند؟',
options: [
{ id: 'yes', label: 'بله، دیگر هیچ زیانی ممکن نیست' },
{ id: 'no', label: 'خیر؛ افت همگانی می‌تواند چند گزینه را هم‌زمان کم کند' },
],
correctOptionId: 'no',
explanation: 'در سناریوی افت همگانی هر سه صندوق ۱۰٪ کمتر شدند. تنوع، زیان را حذف نکرد.',
},
{
id: 'q-name',
prompt: 'اگر دو صندوق اسم متفاوت داشته باشند ولی همیشه یکسان تغییر کنند، آیا فقط اسم متفاوت تنوع مؤثر می‌سازد؟',
options: [
{ id: 'yes', label: 'بله، اسم متفاوت کافی است' },
{ id: 'no', label: 'خیر؛ رفتار و وابستگی آن‌ها مهم است' },
],
correctOptionId: 'no',
explanation: 'اسم جدا به‌تنهایی تنوع مؤثر نیست. اگر همیشه مثل هم حرکت کنند، وابستگی‌شان یکی است.',
},
{
id: 'q-best',
prompt: 'آیا سبدی که در یک سناریو بیشترین سود را داشته، حتماً برای آینده بهترین است؟',
options: [
{ id: 'yes', label: 'بله، همان سبد برای آینده بهترین است' },
{ id: 'no', label: 'خیر؛ یک سناریو آینده را تضمین نمی‌کند' },
],
correctOptionId: 'no',
explanation: 'یک مثال ساختگی، آینده را تضمین نمی‌کند. هیچ سبدی را فقط به‌خاطر یک سناریو بهترین نمی‌دانیم.',
},
{
id: 'q-approach',
prompt: 'برای ساخت سبد، کدام رویکرد مناسب‌تر است؟',
options: [
{ id: 'count', label: 'هرچه تعداد گزینه‌ها بیشتر باشد کافی است' },
{ id: 'balanced', label: 'توجه به هدف، زمان نیاز به پول و ریسک؛ در مسائل واقعی کمک از بزرگسال قابل اعتماد' },
{ id: 'chase', label: 'فقط گزینه‌ای را بردار که در یک مثال بیشترین سود را داشت' },
],
correctOptionId: 'balanced',
explanation: 'تعداد زیاد به‌تنهایی تنوع مؤثر نیست. هدف، زمان نیاز به پول و ریسک مهم‌اند و موضوع واقعی به کمک بزرگسال نیاز دارد.',
},
] as const
export const portfolioMission: PortfolioMissionPublic = {
id: 'portfolio',
version: 1,
title: 'سبد سرمایه‌گذاری',
guideTitle: 'راهنما',
guideImageKey: 'professor',
totalCoins: 100,
step: 10,
intro:
'در مسیر قلعه ۱۰۰ سکهٔ آموزشی داری. این سکه‌ها از کیف پول بازی کم نمی‌شوند و نام صندوق‌ها دارایی واقعی نیستند. تقسیم پول می‌تواند وابستگی به یک گزینه را کم کند، ولی زیان را حذف یا سود را تضمین نمی‌کند.',
rules: [
'هر بار ۱۰ سکه جابه‌جا می‌شود.',
'مجموع باید دقیقاً ۱۰۰ سکه باشد.',
'گذاشتن همهٔ پول در یک صندوق مجاز است تا اثرش را ببینی.',
'تعداد صندوق‌ها یا سود یک سناریو نمره نمی‌دهد.',
],
funds: [
{ id: 'sun', label: 'آفتاب', hint: 'در مثال آفتابی بیشتر و در مثال بارانی کمتر می‌شود.' },
{ id: 'rain', label: 'باران', hint: 'در مثال بارانی بیشتر و در مثال آفتابی کمتر می‌شود.' },
{ id: 'wind', label: 'باد', hint: 'در دو مثال اول بدون تغییر می‌ماند.' },
],
scenarios: [
{
id: 'sunny',
title: 'هوای آفتابی',
note: 'مثال است، نه پیش‌بینی و نه همهٔ حالت‌های واقعی.',
changes: [
{ fundId: 'sun', percent: 20 },
{ fundId: 'rain', percent: -10 },
{ fundId: 'wind', percent: 0 },
],
},
{
id: 'rainy',
title: 'روز بارانی',
note: 'مثال است. برای این حالت‌ها احتمال تعریف نشده.',
changes: [
{ fundId: 'sun', percent: -20 },
{ fundId: 'rain', percent: 10 },
{ fundId: 'wind', percent: 0 },
],
},
{
id: 'drop',
title: 'افت همگانی',
note: 'مثال است. هر سه صندوق با هم کمتر می‌شوند.',
changes: [
{ fundId: 'sun', percent: -10 },
{ fundId: 'rain', percent: -10 },
{ fundId: 'wind', percent: -10 },
],
},
],
questions: questions.map(({ id, prompt, options }) => ({
id,
prompt,
options: options.map((option) => ({ id: option.id, label: option.label })),
})),
summary:
'تقسیم منابع می‌تواند وابستگی به یک گزینه را کم کند، اما زیان را حذف یا سود را تضمین نمی‌کند. اسم زیاد به‌تنهایی تنوع مؤثر نیست.',
}
export function getPortfolioQuestionBank() {
return questions
}
@@ -0,0 +1,46 @@
export interface PortfolioFundPublic {
id: 'sun' | 'rain' | 'wind'
label: string
hint: string
}
export interface PortfolioScenarioPublic {
id: string
title: string
note: string
changes: Array<{ fundId: 'sun' | 'rain' | 'wind'; percent: number }>
}
export interface PortfolioQuestionPublic {
id: string
prompt: string
options: Array<{ id: string; label: string }>
}
export interface PortfolioMissionPublic {
id: 'portfolio'
version: number
title: string
guideTitle: string
guideImageKey: string
totalCoins: number
step: number
intro: string
rules: string[]
funds: PortfolioFundPublic[]
scenarios: PortfolioScenarioPublic[]
questions: PortfolioQuestionPublic[]
summary: string
}
export interface PortfolioQuestionFeedback {
id: string
earned: boolean
explanation: string
}
export interface PortfolioFeedback {
summary: string
scenarios: import('./portfolio.math').PortfolioScenarioResult[]
questions: PortfolioQuestionFeedback[]
}
@@ -0,0 +1,75 @@
import { getRiskQuestionBank, riskReturnMission } from './risk-return.mission'
import type { RiskReturnFeedback, RiskReturnPublic } from './risk-return.types'
export class RiskReturnGradingError extends Error {
statusCode: number
constructor(statusCode: number, message: string) {
super(message)
this.statusCode = statusCode
}
}
export interface RiskSubmissionAnswer {
questionId?: string
answerId?: string
orderedItemIds?: string[]
allocations?: unknown
}
export function gradeRiskReturnSubmission(answers: RiskSubmissionAnswer[]): {
score: number
feedback: RiskReturnFeedback
} {
if (!Array.isArray(answers)) {
throw new RiskReturnGradingError(400, 'فرمت پاسخ نامعتبر است')
}
const bank = getRiskQuestionBank()
const seen = new Set<string>()
const byId = new Map<string, string>()
for (const answer of answers) {
if (!answer || typeof answer.questionId !== 'string' || typeof answer.answerId !== 'string') {
throw new RiskReturnGradingError(400, 'نوع پاسخ نامعتبر است')
}
if (answer.orderedItemIds !== undefined || answer.allocations !== undefined) {
throw new RiskReturnGradingError(400, 'فیلد غیرمجاز در پاسخ')
}
if (seen.has(answer.questionId)) {
throw new RiskReturnGradingError(400, `سؤال ${answer.questionId} تکراری است`)
}
const question = bank.find((item) => item.id === answer.questionId)
if (!question) {
throw new RiskReturnGradingError(400, `سؤال ${answer.questionId} پیدا نشد`)
}
if (!question.options.some((option) => option.id === answer.answerId)) {
throw new RiskReturnGradingError(400, `گزینهٔ نامعتبر برای سؤال ${answer.questionId}`)
}
seen.add(answer.questionId)
byId.set(answer.questionId, answer.answerId)
}
if (byId.size !== bank.length) {
throw new RiskReturnGradingError(400, 'پاسخ همهٔ فعالیت‌ها لازم است')
}
const questions = bank.map((question) => ({
id: question.id,
earned: byId.get(question.id) === question.correctOptionId,
explanation: question.explanation,
}))
const correctCount = questions.filter((question) => question.earned).length
return {
score: correctCount * 20,
feedback: {
summary: riskReturnMission.summary,
questions,
},
}
}
export function getRiskReturnPublic(): RiskReturnPublic {
return riskReturnMission
}
@@ -0,0 +1,89 @@
import type { RiskReturnPublic } from './risk-return.types'
export const RISK_ACTIVITY_KEY = 'risk-return'
const questions = [
{
id: 'q-loss',
prompt: 'کدام پیشنهاد در این مثال امکان برگشت کمتر از ۲۰ سکه دارد؟',
options: [
{ id: 'a', label: 'پیشنهاد الف' },
{ id: 'b', label: 'پیشنهاد ب' },
{ id: 'neither', label: 'هیچ‌کدام' },
],
correctOptionId: 'b',
explanation: 'در پیشنهاد ب یک کارت ۸ سکه است، یعنی کمتر از ۲۰. در پیشنهاد الف هر چهار کارت ۲۰ یا ۲۲ است.',
},
{
id: 'q-certain',
prompt: 'آیا در پیشنهاد ب حتماً ۳۶ سکه دریافت می‌کنی؟',
options: [
{ id: 'yes', label: 'بله، حتماً ۳۶ سکه می‌گیری' },
{ id: 'no', label: 'خیر، ۳۶ فقط یکی از نتیجه‌های ممکن است' },
],
correctOptionId: 'no',
explanation: '۳۶ فقط یکی از چهار کارت است. آمدن آن قطعی نیست.',
},
{
id: 'q-need',
prompt: 'دو روز دیگر برای یک نیاز ضروری به تمام ۲۰ سکه نیاز داری. تصمیم مناسب‌تر چیست؟',
options: [
{ id: 'a', label: 'همین ۲۰ سکه را به پیشنهاد الف بده' },
{ id: 'b', label: 'همین ۲۰ سکه را به پیشنهاد ب بده' },
{ id: 'keep', label: 'این پول ضروری را وارد پیشنهادهای نامطمئن نکن' },
],
correctOptionId: 'keep',
explanation: 'پولی که به‌زودی برای نیاز ضروری لازم است نباید وارد انتخاب نامطمئن شود. هیچ‌کدام از دو پیشنهاد برای این موقعیت مناسب نیست.',
},
{
id: 'q-compare',
prompt: 'درباره مقایسه دو پیشنهاد کدام جمله درست است؟',
options: [
{ id: 'certain-gain', label: 'پیشنهاد ب حتماً سود بیشتری می‌دهد' },
{ id: 'range', label: 'پیشنهاد ب امکان نتیجه بالاتر و زیان بیشتری دارد؛ سود بیشتر قطعی نیست' },
{ id: 'same', label: 'هر دو پیشنهاد دقیقاً یک نتیجه‌اند' },
],
correctOptionId: 'range',
explanation: 'پیشنهاد ب هم کارت ۸ دارد و هم کارت ۳۶. نتیجهٔ بالاتر ممکن است، ولی تضمین نشده.',
},
{
id: 'q-promise',
prompt: 'اگر کسی بگوید «سود خیلی زیاد، بدون هیچ ریسک و حتماً»، واکنش مناسب چیست؟',
options: [
{ id: 'trust', label: 'وعده را تضمین معتبر حساب کن و سریع بپذیر' },
{ id: 'check', label: 'وعده را تضمین معتبر فرض نکن، بررسی کن و از بزرگسال قابل اعتماد کمک بگیر' },
{ id: 'big-number', label: 'فقط به عدد بزرگ نگاه کن' },
],
correctOptionId: 'check',
explanation: 'وعدهٔ سود زیاد و بدون ریسک، تضمین معتبر نیست. باید بررسی شود و از بزرگسال قابل اعتماد کمک بگیری.',
},
] as const
export const riskReturnMission: RiskReturnPublic = {
id: 'risk-return',
version: 1,
title: 'ریسک و بازده',
guideTitle: 'راهنما',
guideImageKey: 'professor',
stakeCoins: 20,
intro:
'در بودجه‌بندی دیدی پول نیاز ضروری با پول هدف فرق دارد. حالا در مسیر قلعه، ۲۰ سکهٔ آموزشی و کاملاً خیالی را فقط برای فکر کردن نگاه می‌کنی. این سکه‌ها از کیف پول بازی‌ات کم نمی‌شوند و هیچ دارایی واقعی پیشنهاد نمی‌شود.',
disclaimer:
'در این مثال ساختگی، هر کارت شانس یکسان دارد. در دنیای واقعی همیشه احتمال‌ها را دقیق نمی‌دانیم.',
exampleLimit:
'پیشنهاد الف فقط در همین مثال زیان اسمی ندارد. این ویژگی تضمین عمومی برای سرمایه‌گذاری‌ها نیست.',
summary: 'امکان سود بیشتر، تضمین سود نیست؛ انتخاب به هدف و زمان نیاز به پول بستگی دارد.',
offers: [
{ id: 'a', label: 'پیشنهاد الف', outcomes: [20, 20, 22, 22] },
{ id: 'b', label: 'پیشنهاد ب', outcomes: [8, 16, 28, 36] },
],
questions: questions.map(({ id, prompt, options }) => ({
id,
prompt,
options: options.map((option) => ({ id: option.id, label: option.label })),
})),
}
export function getRiskQuestionBank() {
return questions
}
@@ -0,0 +1,42 @@
export interface RiskOffer {
id: 'a' | 'b'
label: string
outcomes: number[]
}
export interface RiskOption {
id: string
label: string
}
export interface RiskQuestionPublic {
id: string
prompt: string
options: RiskOption[]
}
export interface RiskReturnPublic {
id: 'risk-return'
version: number
title: string
guideTitle: string
guideImageKey: string
stakeCoins: number
intro: string
disclaimer: string
exampleLimit: string
summary: string
offers: RiskOffer[]
questions: RiskQuestionPublic[]
}
export interface RiskQuestionFeedback {
id: string
earned: boolean
explanation: string
}
export interface RiskReturnFeedback {
summary: string
questions: RiskQuestionFeedback[]
}
+237 -46
View File
@@ -16,6 +16,24 @@ import type {
LevelProgressRow,
LevelStatus,
} from '../levels/level.types'
import { BudgetingGradingError, getBudgetMissionPublic, gradeBudgetSubmission } from '../budgeting/budgeting.grader'
import { BUDGET_ACTIVITY_KEY } from '../budgeting/budgeting.mission'
import { getRiskReturnPublic, gradeRiskReturnSubmission, RiskReturnGradingError } from '../risk-return/risk-return.grader'
import { RISK_ACTIVITY_KEY } from '../risk-return/risk-return.mission'
import { getPortfolioPublic, gradePortfolioSubmission, PortfolioGradingError } from '../portfolio/portfolio.grader'
import { PORTFOLIO_ACTIVITY_KEY } from '../portfolio/portfolio.mission'
import { getSmartShopPublic, gradeSmartShopSubmission, SmartShopGradingError } from '../smart-shop/shop.grader'
import { SMART_SHOP_ACTIVITY_KEY } from '../smart-shop/shop.mission'
import { getFinancialGoalPublic, gradeFinancialGoalSubmission, FinancialGoalGradingError } from '../financial-goal/goal.grader'
import { FINANCIAL_GOAL_ACTIVITY_KEY } from '../financial-goal/goal.mission'
import { getCastleExamPublic, gradeCastleExam, CastleExamGradingError } from '../castle-exam/castle.grader'
import { CASTLE_ACTIVITY_KEY } from '../castle-exam/castle.mission'
import type { BudgetMissionFeedback } from '../budgeting/budgeting.types'
import type { RiskReturnFeedback } from '../risk-return/risk-return.types'
import type { PortfolioFeedback } from '../portfolio/portfolio.types'
import type { SmartShopFeedback } from '../smart-shop/shop.types'
import type { FinancialGoalFeedback } from '../financial-goal/goal.types'
import type { CastleExamFeedback } from '../castle-exam/castle.types'
import { loadLessonForLevel } from '../lessons/lesson.loader'
import {
gradeLessonSubmission,
@@ -173,10 +191,12 @@ export class GoldLandProgressionService {
}
syncUserProgressionState(userId: string): UserState {
const state = this.users.getUserState(userId)
const items = this.buildLevelItems(userId)
this.syncLegacyMapLevels(state, items)
return this.users.saveUserState(state)
return this.database.transaction(() => {
const state = this.users.getUserState(userId)
const items = this.buildLevelItems(userId)
this.syncLegacyMapLevels(state, items)
return this.users.saveUserState(state)
})
}
getGoldLandProgress(userId: string) {
@@ -217,6 +237,60 @@ export class GoldLandProgressionService {
lesson: stripSensitiveFields(sanitizeLesson(lesson)),
}
}
} else if (
canAccessContent &&
definition?.activityType === 'game' &&
definition.activityKey === BUDGET_ACTIVITY_KEY
) {
activity = {
type: 'budgeting',
budgeting: getBudgetMissionPublic(),
}
} else if (
canAccessContent &&
definition?.activityType === 'challenge' &&
definition.activityKey === RISK_ACTIVITY_KEY
) {
activity = {
type: 'risk-return',
riskReturn: getRiskReturnPublic(),
}
} else if (
canAccessContent &&
definition?.activityType === 'game' &&
definition.activityKey === PORTFOLIO_ACTIVITY_KEY
) {
activity = {
type: 'portfolio',
portfolio: getPortfolioPublic(),
}
} else if (
canAccessContent &&
definition?.activityType === 'game' &&
definition.activityKey === SMART_SHOP_ACTIVITY_KEY
) {
activity = {
type: 'smart-shop',
smartShop: getSmartShopPublic(),
}
} else if (
canAccessContent &&
definition?.activityType === 'challenge' &&
definition.activityKey === FINANCIAL_GOAL_ACTIVITY_KEY
) {
activity = {
type: 'financial-goal',
financialGoal: getFinancialGoalPublic(),
}
} else if (
canAccessContent &&
definition?.activityType === 'final_exam' &&
definition.activityKey === CASTLE_ACTIVITY_KEY
) {
activity = {
type: 'castle-exam',
castleExam: getCastleExamPublic(),
}
}
return stripSensitiveFields({
@@ -238,38 +312,40 @@ export class GoldLandProgressionService {
}
startLevel(userId: string, levelId: number) {
this.assertLevelPlayable(userId, levelId)
return this.database.transaction(() => {
this.assertLevelPlayable(userId, levelId)
const definition = getLevelDefinition(levelId)
if (!definition?.isActive) {
throw new ProgressionError(403, 'این مرحله به‌زودی باز می‌شود')
}
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()
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)
}
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 }
return { levelId, status: 'in_progress' as const }
})
}
completeLevel(
@@ -284,22 +360,38 @@ export class GoldLandProgressionService {
throw new ProgressionError(403, 'این مرحله هنوز آماده نیست')
}
const score = this.gradeSubmission(levelId, submission)
const graded = this.gradeSubmission(levelId, submission)
const score = graded.score
const newStars = starsFromScore(score)
if (newStars === 0) {
throw new ProgressionError(400, 'امتیاز کافی برای تکمیل مرحله نبود. دوباره تلاش کن!')
if (definition.activityKey !== CASTLE_ACTIVITY_KEY || !graded.feedback) {
throw new ProgressionError(400, 'امتیاز کافی برای تکمیل مرحله نبود. دوباره تلاش کن!')
}
const existing = this.getProgressRow(userId, levelId)
const state = this.users.getUserState(userId)
return {
levelId,
score,
stars: (existing?.stars ?? 0) as 0 | 1 | 2 | 3,
previousStars: (existing?.stars ?? 0) as 0 | 1 | 2 | 3,
isFirstCompletion: false,
bestScoreImproved: false,
reward: { xp: 0, coins: 0 },
unlockedLevelId: null,
me: this.users.toMeResponse(state),
feedback: graded.feedback,
}
}
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 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()
const db = this.database.getDb()
if (existing) {
@@ -376,6 +468,7 @@ export class GoldLandProgressionService {
reward: { xp: totalXp, coins: totalCoins },
unlockedLevelId,
me: this.users.toMeResponse(state),
...(graded.feedback ? { feedback: graded.feedback } : {}),
}
})
}
@@ -391,7 +484,10 @@ export class GoldLandProgressionService {
return item
}
private gradeSubmission(levelId: number, submission: CompleteLevelSubmission): number {
private gradeSubmission(
levelId: number,
submission: CompleteLevelSubmission,
): { score: number; feedback?: BudgetMissionFeedback | RiskReturnFeedback | PortfolioFeedback | SmartShopFeedback | FinancialGoalFeedback | CastleExamFeedback | { questions: import('../lessons/lesson.types').LessonAnswerFeedback[] } } {
const definition = getLevelDefinition(levelId)
if (!definition) throw new ProgressionError(404, 'مرحله پیدا نشد')
@@ -402,10 +498,31 @@ export class GoldLandProgressionService {
if (!lesson) {
throw new ProgressionError(403, 'محتوای این درس هنوز آماده نیست')
}
if (
submission.answers?.some(
(answer) =>
answer != null &&
typeof answer === 'object' &&
'allocations' in answer &&
answer.allocations != null,
) ||
submission.answers?.some(
(answer) =>
answer != null &&
typeof answer === 'object' &&
'holdings' in answer &&
answer.holdings != null,
)
) {
throw new ProgressionError(400, 'قالب پاسخ درس نامعتبر است')
}
try {
const result = gradeLessonSubmission(lesson, submission.answers ?? [])
return result.score
const result = gradeLessonSubmission(
lesson,
(submission.answers ?? []) as import('../lessons/lesson.types').LessonSubmissionAnswer[],
)
return { score: result.score, ...(result.feedback ? { feedback: result.feedback } : {}) }
} catch (error) {
if (error instanceof LessonGradingError) {
throw new ProgressionError(error.statusCode, error.message)
@@ -414,6 +531,80 @@ export class GoldLandProgressionService {
}
}
if (definition.activityType === 'game' && definition.activityKey === BUDGET_ACTIVITY_KEY) {
try {
return gradeBudgetSubmission(
(submission.answers ?? []) as import('../budgeting/budgeting.grader').BudgetSubmissionAnswer[],
)
} catch (error) {
if (error instanceof BudgetingGradingError) {
throw new ProgressionError(error.statusCode, error.message)
}
throw error
}
}
if (definition.activityType === 'challenge' && definition.activityKey === RISK_ACTIVITY_KEY) {
try {
return gradeRiskReturnSubmission(
(submission.answers ?? []) as import('../risk-return/risk-return.grader').RiskSubmissionAnswer[],
)
} catch (error) {
if (error instanceof RiskReturnGradingError) {
throw new ProgressionError(error.statusCode, error.message)
}
throw error
}
}
if (definition.activityType === 'game' && definition.activityKey === PORTFOLIO_ACTIVITY_KEY) {
try {
return gradePortfolioSubmission(
(submission.answers ?? []) as import('../portfolio/portfolio.grader').PortfolioSubmissionAnswer[],
)
} catch (error) {
if (error instanceof PortfolioGradingError) {
throw new ProgressionError(error.statusCode, error.message)
}
throw error
}
}
if (definition.activityType === 'game' && definition.activityKey === SMART_SHOP_ACTIVITY_KEY) {
try {
return gradeSmartShopSubmission(
(submission.answers ?? []) as import('../smart-shop/shop.grader').SmartShopSubmissionAnswer[],
)
} catch (error) {
if (error instanceof SmartShopGradingError) {
throw new ProgressionError(error.statusCode, error.message)
}
throw error
}
}
if (definition.activityType === 'challenge' && definition.activityKey === FINANCIAL_GOAL_ACTIVITY_KEY) {
try {
return gradeFinancialGoalSubmission(
(submission.answers ?? []) as import('../financial-goal/goal.grader').GoalSubmissionAnswer[],
)
} catch (error) {
if (error instanceof FinancialGoalGradingError) {
throw new ProgressionError(error.statusCode, error.message)
}
throw error
}
}
if (definition.activityType === 'final_exam' && definition.activityKey === CASTLE_ACTIVITY_KEY) {
try {
return gradeCastleExam((submission.answers ?? []) as import('../castle-exam/castle.grader').CastleSubmissionAnswer[])
} catch (error) {
if (error instanceof CastleExamGradingError) throw new ProgressionError(error.statusCode, error.message)
throw error
}
}
throw new ProgressionError(403, 'ارزیابی این مرحله هنوز پیاده‌سازی نشده')
}
}
+84
View File
@@ -0,0 +1,84 @@
import { getSmartShopQuestionBank, smartShopMission } from './shop.mission'
import type { SmartShopFeedback, SmartShopPublic } from './shop.types'
export class SmartShopGradingError extends Error {
statusCode: number
constructor(statusCode: number, message: string) {
super(message)
this.statusCode = statusCode
}
}
export interface SmartShopSubmissionAnswer {
questionId?: string
answerId?: string
orderedItemIds?: string[]
allocations?: unknown
holdings?: unknown
price?: unknown
score?: unknown
reward?: unknown
}
export function gradeSmartShopSubmission(answers: SmartShopSubmissionAnswer[]): {
score: number
feedback: SmartShopFeedback
} {
if (!Array.isArray(answers)) {
throw new SmartShopGradingError(400, 'فرمت پاسخ نامعتبر است')
}
const bank = getSmartShopQuestionBank()
const seen = new Set<string>()
const chosen = new Map<string, string>()
for (const answer of answers) {
if (!answer || typeof answer.questionId !== 'string') {
throw new SmartShopGradingError(400, 'شناسهٔ پاسخ نامعتبر است')
}
if (seen.has(answer.questionId)) {
throw new SmartShopGradingError(400, `پاسخ ${answer.questionId} تکراری است`)
}
seen.add(answer.questionId)
if (
answer.orderedItemIds !== undefined ||
answer.allocations !== undefined ||
answer.holdings !== undefined ||
answer.price !== undefined ||
answer.score !== undefined ||
answer.reward !== undefined
) {
throw new SmartShopGradingError(400, 'فیلد غیرمجاز در پاسخ خرید')
}
if (typeof answer.answerId !== 'string') {
throw new SmartShopGradingError(400, 'نوع پاسخ نامعتبر است')
}
const question = bank.find((item) => item.id === answer.questionId)
if (!question) {
throw new SmartShopGradingError(400, `سؤال ${answer.questionId} پیدا نشد`)
}
if (!question.options.some((option) => option.id === answer.answerId)) {
throw new SmartShopGradingError(400, `گزینهٔ نامعتبر برای سؤال ${answer.questionId}`)
}
chosen.set(answer.questionId, answer.answerId)
}
if (chosen.size !== bank.length) {
throw new SmartShopGradingError(400, 'پاسخ همهٔ سؤال‌ها لازم است')
}
const purchases = bank.map((question) => {
const picked = question.options.find((option) => option.id === chosen.get(question.id))
const correct = question.options.find((option) => option.correct)
return {
id: question.id,
earned: picked?.id === correct?.id,
explanation: correct?.explanation ?? question.options.find((option) => option.explanation)?.explanation ?? '',
}
})
const correctCount = purchases.filter((item) => item.earned).length
return {
score: Math.round((correctCount * 100) / 6),
feedback: { summary: smartShopMission.summary, purchases },
}
}
export function getSmartShopPublic(): SmartShopPublic {
return smartShopMission
}
+12
View File
@@ -0,0 +1,12 @@
export interface ShopCost {
price: number | null
delivery: number | null
}
export function finalCost(cost: ShopCost): number | null {
if (cost.price === null || cost.delivery === null) return null
if (!Number.isInteger(cost.price) || !Number.isInteger(cost.delivery) || cost.price < 0 || cost.delivery < 0) {
return null
}
return cost.price + cost.delivery
}
+156
View File
@@ -0,0 +1,156 @@
import { finalCost } from './shop.math'
import type { ShopCardPublic, ShopMissionPublic, SmartShopPublic } from './shop.types'
export const SMART_SHOP_ACTIVITY_KEY = 'smart-shop'
interface PrivateOption {
id: string
label: string
correct?: boolean
explanation?: string
}
function card(
id: string,
label: string,
facts: string[],
price: number | null,
delivery: number | null,
discountText: string | null = null,
previousPrice: number | null = null,
): ShopCardPublic {
return {
id,
label,
facts,
price,
delivery,
discountText,
previousPrice,
finalCost: finalCost({ price, delivery }),
}
}
const notebookChoice: PrivateOption[] = [
{ id: 'a', label: 'دفتر ۴۰برگ، ۲۵ سکه، تحویل رایگان' },
{ id: 'b', label: 'دفتر ۶۰برگ، ۴۰ سکه، تحویل ۵ سکه', correct: true, explanation: 'شش‌ده برگ و سالم است و هزینه نهایی‌اش ۴۵ سکه است؛ بین گزینه‌های مناسب کمتر است.' },
{ id: 'c', label: 'دفتر ۶۰برگ، ۳۸ سکه، تحویل ۱۲ سکه' },
]
const notebookWhy: PrivateOption[] = [
{ id: 'cheapest-tag', label: 'چون عدد روی کارت کوچک‌تر است' },
{
id: 'fits-and-cheaper',
label: 'نیاز را برآورده می‌کند و هزینه نهایی‌اش بین گزینه‌های مناسب کمتر است',
correct: true,
explanation: 'دفتر ۴۰برگ نیاز را تأمین نمی‌کند. دفتر ۳۸ به‌اضافه ۱۲، هزینه نهایی ۵۰ دارد و از ۴۵ بیشتر است.',
},
]
const discountChoice: PrivateOption[] = [
{ id: 'a', label: 'مداد با تخفیف ۲۵٪ و قیمت نهایی ۳۰' },
{ id: 'b', label: 'مداد با قیمت نهایی ۲۸، بدون تخفیف', correct: true, explanation: 'هر دو یک مداد با تحویل رایگان‌اند و ۲۸ از ۳۰ کمتر است.' },
]
const discountWhy: PrivateOption[] = [
{ id: 'bigger-discount', label: 'چون درصد تخفیف بیشتر همیشه بهتر است' },
{
id: 'pay-amount',
label: 'برای کالای یکسان، مبلغ نهایی پرداختی مهم است؛ درصد تخفیف به‌تنهایی کافی نیست',
correct: true,
explanation: '۲۵٪ تخفیف قیمت را به ۳۰ می‌رساند. گزینه بدون تخفیف ۲۸ تمام می‌شود.',
},
]
const infoChoice: PrivateOption[] = [
{ id: 'buy-now', label: 'همین حالا بخر' },
{ id: 'assume', label: 'فرض کن اندازه مناسب است و تحویل رایگان است' },
{
id: 'ask',
label: 'فعلاً نخر و اندازه و هزینه نهایی را بپرس، با کمک بزرگسال قابل اعتماد',
correct: true,
explanation: 'اندازه و هزینه تحویل مشخص نیست. خرید فوری یا حدس زدن جای بررسی را نمی‌گیرد.',
},
]
const infoWhy: PrivateOption[] = [
{ id: 'hurry', label: 'چون پیام می‌گوید همین حالا بخر' },
{
id: 'incomplete',
label: 'اطلاعات لازم برای تصمیم و مقایسه هنوز کامل نیست',
correct: true,
explanation: 'وقتی اندازه یا هزینه نهایی نامشخص است، هنوز نمی‌توان مقایسه کرد.',
},
]
function options(items: PrivateOption[]) {
return items.map(({ id, label }) => ({ id, label }))
}
const missions: ShopMissionPublic[] = [
{
id: 'notebook',
title: 'دفتر مناسب',
need: 'یک دفتر حداقل ۶۰ برگ و سالم.',
budget: 50,
cards: [
card('a', 'پیشنهاد الف', ['۴۰ برگ', 'سالم'], 25, 0),
card('b', 'پیشنهاد ب', ['۶۰ برگ', 'سالم'], 40, 5),
card('c', 'پیشنهاد ج', ['۶۰ برگ', 'سالم'], 38, 12),
],
choice: { id: 'notebook-choice', prompt: 'کدام پیشنهاد را برای این نیاز انتخاب می‌کنی؟', options: options(notebookChoice) },
reason: { id: 'notebook-why', prompt: 'دلیل انتخاب تو چیست؟', options: options(notebookWhy) },
},
{
id: 'discount',
title: 'برچسب تخفیف',
need: 'یک مداد مشخص، با نوع و شرایط یکسان.',
budget: 30,
cards: [
card('a', 'پیشنهاد الف', ['همان مداد', 'تحویل رایگان'], 30, 0, 'تخفیف ۲۵٪', 40),
card('b', 'پیشنهاد ب', ['همان مداد', 'تحویل رایگان', 'بدون تخفیف'], 28, 0),
],
choice: { id: 'discount-choice', prompt: 'کدام مداد را انتخاب می‌کنی؟', options: options(discountChoice) },
reason: { id: 'discount-why', prompt: 'دلیل انتخاب تو چیست؟', options: options(discountWhy) },
},
{
id: 'info',
title: 'اطلاعات ناقص',
need: 'وسیله‌ای با اندازه مشخص، مناسب هدف معرفی‌شده.',
budget: 60,
cards: [
card('offer', 'پیشنهاد فروشنده', ['اندازه نامشخص', 'پیام: همین حالا بخر'], 45, null),
],
choice: { id: 'info-choice', prompt: 'با این اطلاعات چه می‌کنی؟', options: options(infoChoice) },
reason: { id: 'info-why', prompt: 'دلیل این تصمیم چیست؟', options: options(infoWhy) },
},
]
export const smartShopMission: SmartShopPublic = {
id: 'smart-shop',
version: 1,
title: 'خرید هوشمند',
guideTitle: 'راهنما',
guideImageKey: 'professor',
intro:
'سه خرید خیالی داری. ارزان‌ترین عدد روی کارت یا بزرگ‌ترین تخفیف همیشه بهترین انتخاب نیست. این سکه‌ها از کیف پول بازی کم نمی‌شوند.',
rules: [
'نیاز و بودجه را کنار پیشنهادها ببین.',
'هزینه نهایی قیمت به‌اضافه تحویل است.',
'اگر چیزی نامشخص است، آن را صفر فرض نکن.',
'قبل از ثبت می‌توانی انتخاب‌ها را عوض کنی.',
],
missions,
summary: 'خرید خوب به نیاز، هزینه نهایی، اطلاعات کافی و بودجه نگاه می‌کند.',
}
export function getSmartShopQuestionBank() {
return [
{ id: 'notebook-choice', options: notebookChoice },
{ id: 'notebook-why', options: notebookWhy },
{ id: 'discount-choice', options: discountChoice },
{ id: 'discount-why', options: discountWhy },
{ id: 'info-choice', options: infoChoice },
{ id: 'info-why', options: infoWhy },
]
}
+42
View File
@@ -0,0 +1,42 @@
export interface ShopCardPublic {
id: string
label: string
facts: string[]
price: number | null
delivery: number | null
discountText: string | null
previousPrice: number | null
finalCost: number | null
}
export interface ShopOptionPublic {
id: string
label: string
}
export interface ShopMissionPublic {
id: string
title: string
need: string
budget: number
cards: ShopCardPublic[]
choice: { id: string; prompt: string; options: ShopOptionPublic[] }
reason: { id: string; prompt: string; options: ShopOptionPublic[] }
}
export interface SmartShopPublic {
id: string
version: number
title: string
guideTitle: string
guideImageKey: string
intro: string
rules: string[]
missions: ShopMissionPublic[]
summary: string
}
export interface SmartShopFeedback {
summary: string
purchases: Array<{ id: string; earned: boolean; explanation: string }>
}
+21 -1
View File
@@ -1,9 +1,13 @@
import { Controller, Get } from '@nestjs/common'
import { Controller, Get, ServiceUnavailableException } from '@nestjs/common'
import { Public } from '../common/decorators/public.decorator'
import { DatabaseService } from '../database/database.service'
@Public()
@Controller()
export class HealthController {
constructor(private readonly database: DatabaseService) {}
/** Process is up (liveness). Does not verify database. */
@Get('health')
getHealth() {
return {
@@ -12,4 +16,20 @@ export class HealthController {
runtime: 'nest',
}
}
/** DB migrations applied (readiness for traffic / compose depends_on). */
@Get('health/ready')
getReady() {
const check = this.database.verifyMigrationsApplied()
if (!check.ok) {
throw new ServiceUnavailableException({
status: 'not_ready',
missingTables: check.missing,
})
}
return {
status: 'ready',
service: 'child-gold-land-backend',
}
}
}
+2
View File
@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common'
import { DatabaseModule } from '../database/database.module'
import { HealthController } from './health.controller'
@Module({
imports: [DatabaseModule],
controllers: [HealthController],
})
export class HealthModule {}
+13 -2
View File
@@ -6,10 +6,21 @@ import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify
import { AppModule } from './app.module'
import { ApiExceptionFilter } from './common/filters/api-exception.filter'
function parseCorsOrigins(raw: string): string | string[] {
const parts = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
if (parts.length === 0) return 'http://localhost:5174'
if (parts.length === 1) return parts[0]!
return parts
}
async function bootstrap() {
// Rate-limit IP: getClientIp() + TRUST_PROXY_CIDRS — not global Fastify trustProxy.
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
new FastifyAdapter({ trustProxy: false }),
)
app.enableShutdownHooks()
@@ -17,7 +28,7 @@ async function bootstrap() {
const config = app.get(ConfigService)
app.enableCors({
origin: config.get<string>('CORS_ORIGIN'),
origin: parseCorsOrigins(config.get<string>('CORS_ORIGIN') ?? 'http://localhost:5174'),
credentials: true,
})
+7 -7
View File
@@ -21,13 +21,13 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
{ 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 },
{ id: 7, order: 7, slug: 'budgeting', title: 'بودجه‌بندی', description: 'پولت را هوشمندانه تقسیم کن.', activityType: 'game', activityKey: 'budgeting', rewardXp: 45, rewardCoins: 18, isActive: true },
{ id: 8, order: 8, slug: 'risk-and-return', title: 'ریسک و بازده', description: 'ریسک و پاداش در سرمایه‌گذاری.', activityType: 'challenge', activityKey: 'risk-return', rewardXp: 50, rewardCoins: 20, isActive: true },
{ id: 9, order: 9, slug: 'portfolio', title: 'سبد سرمایه‌گذاری', description: 'یک سبد متنوع بساز.', activityType: 'game', activityKey: 'portfolio', rewardXp: 50, rewardCoins: 20, isActive: true },
{ id: 10, order: 10, slug: 'compound-interest', title: 'سود مرکب', description: 'قدرت سود مرکب را ببین.', activityType: 'lesson', activityKey: 'compound-interest', rewardXp: 45, rewardCoins: 18, isActive: true },
{ id: 11, order: 11, slug: 'smart-shopping', title: 'خرید هوشمند', description: 'هوشمندانه خرید کن.', activityType: 'game', activityKey: 'smart-shop', rewardXp: 45, rewardCoins: 18, isActive: true },
{ id: 12, order: 12, slug: 'financial-goal', title: 'هدف مالی', description: 'برنامه مالی خودت را بساز.', activityType: 'challenge', activityKey: 'financial-goal', rewardXp: 50, rewardCoins: 20, isActive: true },
{ id: 13, order: 13, slug: 'castle-exam', title: 'آزمون قلعه', description: 'آزمون نهایی سرزمین طلا.', activityType: 'final_exam', activityKey: 'castle-exam', rewardXp: 100, rewardCoins: 50, isActive: true },
]
export function getLevelDefinition(levelId: number) {
+6 -4
View File
@@ -46,10 +46,12 @@ export class ProgressionSyncService {
) {}
syncUserProgressionState(userId: string): UserState {
const state = this.users.getUserState(userId)
const items = this.buildLevelItems(userId)
this.syncLegacyMapLevels(state, items)
return this.users.saveUserState(state)
return this.database.transaction(() => {
const state = this.users.getUserState(userId)
const items = this.buildLevelItems(userId)
this.syncLegacyMapLevels(state, items)
return this.users.saveUserState(state)
})
}
private getProgressRow(userId: string, levelId: number): LevelProgressRow | undefined {
+6 -4
View File
@@ -90,10 +90,12 @@ export class UsersService {
}
updateProfile(userId: string, name: string): MeResponse {
const state = this.getUserState(userId)
state.name = name.trim()
this.saveUserState(state)
return this.toMeResponse(state)
return this.database.transaction(() => {
const state = this.getUserState(userId)
state.name = name.trim()
this.saveUserState(state)
return this.toMeResponse(state)
})
}
private parseStateJson(raw: string, userId: string): UserState {
+4 -1
View File
@@ -1,6 +1,9 @@
import { IsNumber } from 'class-validator'
import { IsNumber, Max, Min } from 'class-validator'
import { MAX_GOLD_TRADE_GRAMS, MIN_GOLD_TRADE_GRAMS } from '../gold-trade.helpers'
export class BuyGoldDto {
@IsNumber({ allowNaN: false, allowInfinity: false })
@Min(MIN_GOLD_TRADE_GRAMS, { message: 'مقدار نامعتبر است' })
@Max(MAX_GOLD_TRADE_GRAMS, { message: 'مقدار نامعتبر است' })
grams!: number
}
+4 -1
View File
@@ -1,6 +1,9 @@
import { IsNumber } from 'class-validator'
import { IsNumber, Max, Min } from 'class-validator'
import { MAX_GOLD_TRADE_GRAMS, MIN_GOLD_TRADE_GRAMS } from '../gold-trade.helpers'
export class SellGoldDto {
@IsNumber({ allowNaN: false, allowInfinity: false })
@Min(MIN_GOLD_TRADE_GRAMS, { message: 'مقدار نامعتبر است' })
@Max(MAX_GOLD_TRADE_GRAMS, { message: 'مقدار نامعتبر است' })
grams!: number
}
+113
View File
@@ -0,0 +1,113 @@
import { BadRequestException } from '@nestjs/common'
import { GOLD_PRICE } from '../shared/catalog/market.constants'
/**
* Gold trade policy (Child Gold Land):
* - cash: integer tomans in user state
* - goldGrams: grams stored with at most 6 decimal places (1 µg = 10⁻⁶ g)
* - buy notional: ceil(grams × GOLD_PRICE) tomans (no rounding arbitrage vs sell)
* - sell notional: floor(grams × GOLD_PRICE) tomans
* - tomans computed via integer microGrams × GOLD_PRICE (BigInt) when product exceeds 2⁵³
* - maximum grams per request: 10_000
*/
export const GOLD_GRAMS_DECIMALS = 6
export const MICROGRAMS_PER_GRAM = 1_000_000
export const MAX_GOLD_TRADE_GRAMS = 10_000
export const GOLD_PRICE_TOMAN_PER_GRAM = GOLD_PRICE
/** Smallest trade size allowed by DTO (6 decimal grams). Min tomans checked separately for buy/sell. */
export const MIN_GOLD_TRADE_GRAMS = 0.000_001
const MAX_MICROGRAMS = MAX_GOLD_TRADE_GRAMS * MICROGRAMS_PER_GRAM
export function normalizeGoldGrams(grams: number): number {
const factor = 10 ** GOLD_GRAMS_DECIMALS
return Math.round(grams * factor) / factor
}
export function gramsToMicroGrams(normalizedGrams: number): number {
return Math.round(normalizedGrams * MICROGRAMS_PER_GRAM)
}
/** Integer-safe tomans numerator: microGrams × GOLD_PRICE (may exceed Number.MAX_SAFE_INTEGER). */
function tomansNumeratorMicro(microGrams: number): bigint {
return BigInt(microGrams) * BigInt(GOLD_PRICE_TOMAN_PER_GRAM)
}
export function tomansForGoldPurchase(grams: number): number {
const normalized = normalizeGoldGrams(grams)
const micro = gramsToMicroGrams(normalized)
const num = tomansNumeratorMicro(micro)
return Number((num + BigInt(MICROGRAMS_PER_GRAM - 1)) / BigInt(MICROGRAMS_PER_GRAM))
}
export function tomansForGoldSale(grams: number): number {
const micro = gramsToMicroGrams(normalizeGoldGrams(grams))
const num = tomansNumeratorMicro(micro)
return Number(num / BigInt(MICROGRAMS_PER_GRAM))
}
/** @deprecated Symmetric round — only for documenting pre-fix regression; do not use in trades. */
export function tomansForGoldGramsRound(grams: number): number {
return Math.round(normalizeGoldGrams(grams) * GOLD_PRICE_TOMAN_PER_GRAM)
}
function validateTradeGramsInput(grams: number): number {
if (typeof grams !== 'number' || !Number.isFinite(grams)) {
throw new BadRequestException('مقدار نامعتبر است')
}
if (grams <= 0) {
throw new BadRequestException('مقدار نامعتبر است')
}
if (grams > MAX_GOLD_TRADE_GRAMS) {
throw new BadRequestException('مقدار نامعتبر است')
}
const normalized = normalizeGoldGrams(grams)
if (Math.abs(grams - normalized) > 1 / 10 ** (GOLD_GRAMS_DECIMALS + 2)) {
throw new BadRequestException('دقت مقدار طلا بیش از حد مجاز است')
}
const micro = gramsToMicroGrams(normalized)
if (micro <= 0 || micro > MAX_MICROGRAMS) {
throw new BadRequestException('مقدار نامعتبر است')
}
return normalized
}
function assertMinPurchaseTomans(normalized: number): void {
if (tomansForGoldPurchase(normalized) < 1) {
throw new BadRequestException('مبلغ معامله کمتر از یک تومان است')
}
}
function assertMinSaleTomans(normalized: number): void {
if (tomansForGoldSale(normalized) < 1) {
throw new BadRequestException('مبلغ فروش کمتر از یک تومان است')
}
}
export function applyGoldPurchase(state: { cash: number; goldGrams: number }, grams: number): number {
const normalized = validateTradeGramsInput(grams)
assertMinPurchaseTomans(normalized)
const cost = tomansForGoldPurchase(normalized)
if (state.cash < cost) {
throw new BadRequestException('موجودی نقد کافی نیست')
}
state.cash -= cost
state.goldGrams = normalizeGoldGrams(state.goldGrams + normalized)
return cost
}
export function applyGoldSale(state: { cash: number; goldGrams: number }, grams: number): number {
const normalized = validateTradeGramsInput(grams)
assertMinSaleTomans(normalized)
if (normalizeGoldGrams(state.goldGrams) < normalized) {
throw new BadRequestException('طلای کافی نداری')
}
const value = tomansForGoldSale(normalized)
state.goldGrams = normalizeGoldGrams(state.goldGrams - normalized)
state.cash += value
return value
}
+4 -26
View File
@@ -1,9 +1,9 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common'
import { 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'
import { applyGoldPurchase, applyGoldSale } from './gold-trade.helpers'
@Injectable()
export class WalletService {
@@ -20,18 +20,7 @@ export class WalletService {
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
const cost = applyGoldPurchase(state, grams)
addTransaction(state, {
type: 'expense',
title: 'خرید طلا',
@@ -48,18 +37,7 @@ export class WalletService {
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
const value = applyGoldSale(state, grams)
addTransaction(state, {
type: 'income',
title: 'فروش طلا',
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict'
import type { FastifyRequest } from 'fastify'
import { getClientIp } from '../src/common/http/client-ip'
import {
assertAuthRateLimit,
resetAuthRateLimitForTests,
} from '../src/common/rate-limit/auth-rate-limit'
resetAuthRateLimitForTests()
process.env.TRUST_PROXY = 'true'
const attackerPeer = '203.0.113.99'
const req = {
socket: { remoteAddress: attackerPeer },
headers: { 'x-forwarded-for': 'fresh-spoofed-ip-1' },
} as FastifyRequest
const ip = getClientIp(req)
assert.equal(ip, attackerPeer, 'forged XFF must not become rate-limit key')
for (let i = 0; i < 30; i++) {
assertAuthRateLimit(ip)
}
let blocked = false
try {
assertAuthRateLimit(ip)
blocked = true
} catch (error) {
assert.equal((error as { status?: number }).status, 429)
}
assert.equal(blocked, false, 'rate limit must apply to real peer IP')
resetAuthRateLimitForTests()
delete process.env.TRUST_PROXY
console.log('auth-rate-limit-xff tests passed')
+16
View File
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict'
import { assertAuthRateLimit } from '../src/common/rate-limit/auth-rate-limit'
let threw = false
for (let i = 0; i < 35; i++) {
try {
assertAuthRateLimit('test-client-429')
} catch (error) {
threw = true
assert.equal((error as { status?: number }).status, 429)
break
}
}
assert.equal(threw, true, 'expected rate limit after repeated auth attempts')
console.log('auth-rate-limit tests passed')
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, existsSync, readdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import Database from 'better-sqlite3'
import { up, migrationId } from '../src/database/migrations/001-initial-schema'
const backendRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
const root = mkdtempSync(join(tmpdir(), 'cgl-backup-'))
const srcDb = join(root, 'source.db')
const backupDir = join(root, 'backups')
const restoreTarget = join(root, 'restored.db')
try {
const db = new Database(srcDb)
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
);
`)
db.exec(up)
db.prepare('INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)').run(
migrationId,
new Date().toISOString(),
)
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
'u1',
'{}',
new Date().toISOString(),
)
db.close()
let r = spawnSync('node', ['scripts/sqlite-backup.mjs'], {
cwd: backendRoot,
env: { ...process.env, DB_PATH: srcDb, BACKUP_DIR: backupDir },
encoding: 'utf8',
})
assert.equal(r.status, 0, r.stderr || r.stdout)
const backupFile = readdirSync(backupDir).find((f) => f.endsWith('.db'))
assert.ok(backupFile, 'backup file created')
r = spawnSync(
'node',
['scripts/sqlite-restore.mjs', join(backupDir, backupFile!), restoreTarget],
{
cwd: backendRoot,
env: { ...process.env, RESTORE_ACK_STOPPED: 'YES' },
encoding: 'utf8',
},
)
assert.equal(r.status, 0, r.stderr || r.stdout)
assert.ok(existsSync(restoreTarget))
const restored = new Database(restoreTarget, { readonly: true })
const row = restored.prepare('SELECT id FROM users WHERE id = ?').get('u1') as { id: string }
restored.close()
assert.equal(row.id, 'u1')
} finally {
try {
rmSync(root, { recursive: true, force: true, maxRetries: 8, retryDelay: 200 })
} catch {
// Windows may briefly lock SQLite files after close.
}
}
console.log('backup-restore tests passed')
+233
View File
@@ -0,0 +1,233 @@
/**
* Budgeting mission — temporary SQLite only.
*/
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 { BudgetingGradingError, gradeBudgetSubmission } from '../src/gold-land/budgeting/budgeting.grader'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import { whatIsMoneyLesson } from '../src/gold-land/lessons/what-is-money.lesson'
import { getGradableQuestions } from '../src/gold-land/lessons/lesson.grader'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'budget-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-budget-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3996'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
function plan(transport: number, food: number, savings: number, fun: number) {
return { transport, food, savings, fun }
}
function answers(initial: ReturnType<typeof plan>, finalPlan: ReturnType<typeof plan>) {
return [
{ questionId: 'initial', allocations: initial },
{ questionId: 'final', allocations: finalPlan },
]
}
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
}
async function run() {
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const perfectA = gradeBudgetSubmission(answers(plan(20, 25, 20, 35), plan(30, 25, 20, 25)))
const perfectB = gradeBudgetSubmission(answers(plan(25, 30, 25, 0), plan(35, 30, 20, 0)))
assert.equal(perfectA.score, 100)
assert.equal(perfectB.score, 100)
const shortSavings = gradeBudgetSubmission(answers(plan(20, 25, 10, 0), plan(30, 25, 10, 0)))
assert.equal(shortSavings.score, 80)
assert.equal(shortSavings.feedback.plans[0]?.criteria.find((c) => c.id === 'savings-goal')?.earned, false)
assert.equal(shortSavings.feedback.plans[1]?.criteria.find((c) => c.id === 'transport')?.earned, true)
assert.throws(
() => gradeBudgetSubmission(answers(plan(20, 25, 20, 0), plan(20, 25, 20, 0))),
(error: unknown) => error instanceof BudgetingGradingError && error.kind === 'unmet-needs' && error.statusCode === 422,
)
assert.throws(
() => gradeBudgetSubmission(answers(plan(0, 0, 20, 0), plan(30, 25, 20, 0))),
(error: unknown) => error instanceof BudgetingGradingError && error.kind === 'unmet-needs',
)
for (const bad of [
answers(plan(-5, 25, 20, 0), plan(30, 25, 20, 0)),
answers(plan(20.5 as unknown as number, 25, 20, 0), plan(30, 25, 20, 0)),
answers(plan(12, 25, 20, 0), plan(30, 25, 20, 0)),
answers(plan(50, 50, 25, 25), plan(30, 25, 20, 0)),
]) {
assert.throws(() => gradeBudgetSubmission(bad))
}
assert.throws(() =>
gradeBudgetSubmission([
{ questionId: 'initial', allocations: { ...plan(20, 25, 20, 0), extra: 5 } as never },
{ questionId: 'final', allocations: plan(30, 25, 20, 0) },
]),
)
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
USER,
JSON.stringify(createDefaultUserState(USER)),
new Date().toISOString(),
)
const now = new Date().toISOString()
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 (?, ?, 70, 1, 1, ?, ?, ?)
`)
for (let id = 1; id <= 5; id += 1) insert.run(USER, id, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 7)?.status, 'locked')
assert.throws(() => progression.startLevel(USER, 7))
insert.run(USER, 6, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 7)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 8)?.status, 'locked')
const lessonAnswers = getGradableQuestions(whatIsMoneyLesson).map(({ questionId, step }) =>
step.type === 'sort'
? { questionId, orderedItemIds: [...step.correctOrder] }
: { questionId, answerId: step.correctAnswerId },
)
const lesson = progression.completeLevel(USER, 1, { answers: lessonAnswers })
assert.equal(lesson.score, 100)
assert.equal(lesson.feedback, undefined)
const first = progression.completeLevel(USER, 7, {
answers: answers(plan(20, 25, 10, 0), plan(30, 25, 10, 0)),
})
assert.equal(first.score, 80)
assert.equal(first.stars, 2)
assert.equal(first.isFirstCompletion, true)
assert.equal(first.reward.xp, 45 + 10)
assert.equal(first.reward.coins, 18 + 6)
const same = progression.completeLevel(USER, 7, {
answers: answers(plan(20, 25, 10, 0), plan(30, 25, 10, 0)),
})
assert.equal(same.isFirstCompletion, false)
assert.equal(same.reward.xp, 0)
assert.equal(same.reward.coins, 0)
const improved = progression.completeLevel(USER, 7, {
answers: answers(plan(20, 25, 20, 0), plan(30, 25, 20, 0)),
})
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 5)
assert.equal(improved.reward.coins, 3)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 8)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 9)?.status, 'locked')
const reg = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { username: `budget_http_${Date.now()}`, password: 'pass1234', name: 'Budget' },
})
assert.equal(reg.statusCode, 200)
const token = JSON.parse(reg.payload) as { token: string }
const bad = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/7/complete',
headers: { authorization: `Bearer ${token.token}` },
payload: {
answers: [
{ questionId: 'initial', allocations: { transport: 20, food: 25, savings: 20, fun: 0, bonus: 1 } },
{ questionId: 'final', allocations: plan(30, 25, 20, 0) },
],
},
})
assert.equal(bad.statusCode, 400)
const badBody = JSON.parse(bad.payload) as { error: string }
assert.match(badBody.error, /property bonus should not exist|نباید/)
const parallelId = 'budget-parallel-user'
db.prepare('DELETE FROM reward_ledger WHERE user_id = ?').run(parallelId)
db.prepare('DELETE FROM user_level_progress WHERE user_id = ?').run(parallelId)
db.prepare('DELETE FROM users WHERE id = ?').run(parallelId)
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
parallelId,
JSON.stringify(createDefaultUserState(parallelId)),
now,
)
for (let id = 1; id <= 6; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const parallelToken = app.get(JwtService).sign({ userId: parallelId })
const parallelPayload = {
answers: answers(plan(20, 25, 20, 0), plan(30, 25, 20, 0)),
}
const [firstRace, secondRace] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/gold-land/levels/7/complete',
headers: { authorization: `Bearer ${parallelToken}` },
payload: parallelPayload,
}),
app.inject({
method: 'POST',
url: '/api/gold-land/levels/7/complete',
headers: { authorization: `Bearer ${parallelToken}` },
payload: parallelPayload,
}),
])
assert.equal(firstRace.statusCode, 200)
assert.equal(secondRace.statusCode, 200)
const raceBodies = [firstRace, secondRace].map(
(response) => JSON.parse(response.payload) as { reward: { xp: number; coins: number }; stars: number },
)
assert.equal(raceBodies.reduce((sum, body) => sum + body.reward.coins, 0), 18 + 9)
assert.equal(raceBodies.reduce((sum, body) => sum + body.reward.xp, 0), 45 + 15)
const ledger = db.prepare(
'SELECT reward_key, xp_delta, coins_delta FROM reward_ledger WHERE user_id = ? ORDER BY reward_key',
).all(parallelId) as Array<{ reward_key: string; xp_delta: number; coins_delta: number }>
assert.deepEqual(ledger.map((row) => row.reward_key), [
'level:7:first-completion',
'level:7:star:1',
'level:7:star:2',
'level:7:star:3',
])
const me = await app.inject({
method: 'GET',
url: '/api/me',
headers: { authorization: `Bearer ${parallelToken}` },
})
const meBody = JSON.parse(me.payload) as { user: { coins: number } }
assert.equal(meBody.user.coins, 1350 + 18 + 9)
console.log('budgeting tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// Windows may keep the sqlite handle briefly.
}
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+141
View File
@@ -0,0 +1,141 @@
/**
* Castle exam — temporary SQLite only.
*/
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 { gradeCastleExam } from '../src/gold-land/castle-exam/castle.grader'
import { castleExamPublic, getCastleQuestionBank } from '../src/gold-land/castle-exam/castle.mission'
import { GOLD_LAND_LEVELS } from '../src/gold-land/levels/levels'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'castle-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-castle-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '4001'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
const correctIds = ['exchange', 'regular', 'water', 'moves', 'no', 'less', 'valid', 'keep', 'no', '121', 'b', '6']
function answersFor(count: number) {
return getCastleQuestionBank().map((question, index) => ({
questionId: question.id,
answerId: index < count ? question.correctOptionId : question.options.find((option) => option.id !== question.correctOptionId)!.id,
}))
}
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
}
async function run() {
const scores = Array.from({ length: 13 }, (_, count) => gradeCastleExam(answersFor(count)).score)
assert.deepEqual(scores, [0, 8, 17, 25, 33, 42, 50, 58, 67, 75, 83, 92, 100])
assert.equal(gradeCastleExam(answersFor(7)).feedback.completed, false)
assert.equal(gradeCastleExam(answersFor(8)).feedback.completed, true)
assert.equal(JSON.stringify(castleExamPublic).includes('correctOptionId'), false)
assert.equal(JSON.stringify(castleExamPublic).includes('explanation'), false)
assert.deepEqual(castleExamPublic.questions.map((question) => question.sourceLevelId), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
assert.throws(() => gradeCastleExam(answersFor(12).slice(0, 11)))
assert.throws(() => gradeCastleExam([...answersFor(12), answersFor(1)[0]!]))
assert.throws(() => gradeCastleExam(answersFor(12).map((answer, index) => (index === 0 ? { ...answer, answerId: 'nope' } : answer))))
assert.throws(() => gradeCastleExam(answersFor(12).map((answer) => ({ ...answer, score: 100 }))))
assert.deepEqual(correctIds, getCastleQuestionBank().map((question) => question.correctOptionId))
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(USER, JSON.stringify(createDefaultUserState(USER)), now)
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 (?, ?, 100, 3, 1, ?, ?, ?)
`)
for (let id = 1; id <= 11; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 13))
insert.run(USER, 12, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 13)?.status, 'available')
const coinsOf = () => JSON.parse((db.prepare('SELECT state_json FROM users WHERE id = ?').get(USER) as { state_json: string }).state_json).coins as number
const beforeCoins = coinsOf()
const failed = progression.completeLevel(USER, 13, { answers: answersFor(7) })
assert.equal(failed.score, 58)
assert.equal(failed.stars, 0)
assert.equal(failed.reward.coins, 0)
assert.equal(failed.unlockedLevelId, null)
assert.equal(failed.feedback && 'completed' in failed.feedback && failed.feedback.completed, false)
const row = db.prepare('SELECT completed_at FROM user_level_progress WHERE user_id = ? AND level_id = 13').get(USER) as { completed_at: string | null } | undefined
assert.equal(row?.completed_at ?? null, null)
assert.equal(coinsOf(), beforeCoins)
const first = progression.completeLevel(USER, 13, { answers: answersFor(8) })
assert.equal(first.score, 67)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 100 + 5)
assert.equal(first.reward.coins, 50 + 3)
assert.equal(first.unlockedLevelId, null)
const saved = db.prepare('SELECT best_score, stars, completed_at FROM user_level_progress WHERE user_id = ? AND level_id = 13').get(USER) as { best_score: number; stars: number; completed_at: string }
const afterPass = progression.completeLevel(USER, 13, { answers: answersFor(7) })
assert.equal(afterPass.score, 58)
assert.equal(afterPass.reward.coins, 0)
assert.equal(afterPass.feedback && 'completed' in afterPass.feedback && afterPass.feedback.completed, false)
assert.equal(afterPass.previousStars, 1)
const still = db.prepare('SELECT best_score, stars, completed_at FROM user_level_progress WHERE user_id = ? AND level_id = 13').get(USER) as { best_score: number; stars: number; completed_at: string }
assert.equal(still.best_score, saved.best_score)
assert.equal(still.stars, saved.stars)
assert.equal(still.completed_at, saved.completed_at)
const replay = progression.completeLevel(USER, 13, { answers: answersFor(8) })
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 13, { answers: answersFor(12) })
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(improved.unlockedLevelId, null)
assert.equal(GOLD_LAND_LEVELS.length, 13)
assert.equal(progression.buildLevelItems(USER).some((level) => level.id === 14), false)
const parallelId = 'castle-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(parallelId, JSON.stringify(createDefaultUserState(parallelId)), now)
for (let id = 1; id <= 12; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const body = { answers: answersFor(11) }
const [left, right] = await Promise.all([
app.inject({ method: 'POST', url: '/api/gold-land/levels/13/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
app.inject({ method: 'POST', url: '/api/gold-land/levels/13/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map((response) => JSON.parse(response.payload) as { reward: { coins: number; xp: number }; unlockedLevelId: number | null })
assert.equal(bodies.reduce((sum, item) => sum + item.reward.coins, 0), 50 + 9)
assert.equal(bodies.reduce((sum, item) => sum + item.reward.xp, 0), 100 + 15)
assert.equal(bodies.every((item) => item.unlockedLevelId === null), true)
console.log('castle-exam tests passed')
} finally {
await app.close()
try { rmSync(testDir, { recursive: true, force: true }) } catch { /* ignore */ }
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict'
import type { FastifyRequest } from 'fastify'
import { getClientIp, resetClientIpConfigForTests } from '../src/common/http/client-ip'
function mockReq(peer: string, headers: Record<string, string | undefined> = {}): FastifyRequest {
return {
socket: { remoteAddress: peer },
headers,
} as FastifyRequest
}
resetClientIpConfigForTests()
process.env.TRUST_PROXY = 'true'
const untrustedPeer = '203.0.113.50'
const trustedPeer = '172.20.0.5'
assert.equal(
getClientIp(mockReq(untrustedPeer, { 'x-forwarded-for': '1.2.3.4' })),
untrustedPeer,
'untrusted peer must ignore forged XFF',
)
assert.equal(
getClientIp(mockReq(trustedPeer, { 'x-forwarded-for': '198.51.100.10' })),
'198.51.100.10',
'trusted proxy peer may use first XFF hop',
)
delete process.env.TRUST_PROXY
assert.equal(
getClientIp(mockReq(trustedPeer, { 'x-forwarded-for': '198.51.100.10' })),
trustedPeer,
'TRUST_PROXY off ignores XFF even from docker peer',
)
console.log('client-ip tests passed')
+172
View File
@@ -0,0 +1,172 @@
/**
* Compound-interest lesson — temporary SQLite only.
*/
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 { buildGrowthExample } from '../src/gold-land/lessons/growth-example'
import { compoundInterestLesson } from '../src/gold-land/lessons/compound-interest.lesson'
import { gradeLessonSubmission } from '../src/gold-land/lessons/lesson.grader'
import { sanitizeLesson } from '../src/gold-land/lessons/lesson.sanitizer'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'compound-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-compound-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3997'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
const correct = [
{ questionId: 'compound-q1', answerId: 'on-balance' },
{ questionId: 'compound-q2', answerId: '121' },
{ questionId: 'compound-q3', answerId: 'from-110' },
{ questionId: 'compound-q4', answerId: 'compound' },
{ questionId: 'compound-q5', answerId: 'false' },
]
const wrong = ['initial-only', '120', 'from-100', 'simple', 'true']
function withCount(count: number) {
return correct.map((answer, index) =>
index < count ? answer : { questionId: answer.questionId, answerId: wrong[index] ?? 'true' },
)
}
function ends(methodId: 'simple' | 'compound') {
return buildGrowthExample().periods.map((period) => period.rows.find((row) => row.methodId === methodId)?.endTenths)
}
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
}
async function run() {
assert.deepEqual(ends('simple'), [1000, 1100, 1200, 1300])
assert.deepEqual(ends('compound'), [1000, 1100, 1210, 1331])
const period2 = buildGrowthExample().periods[2]
const compound2 = period2?.rows.find((row) => row.methodId === 'compound')
assert.equal(compound2?.startTenths, 1100)
assert.equal(compound2?.growthTenths, 110)
assert.equal(compound2?.endTenths, 1210)
const before = JSON.stringify(buildGrowthExample().periods)
const viewed = buildGrowthExample().periods.slice(0, 2)
assert.equal(viewed.length, 2)
assert.equal(JSON.stringify(buildGrowthExample().periods), before)
assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(0)).score, 0)
assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(2)).score, 40)
assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(3)).score, 60)
assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(4)).score, 80)
assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(5)).score, 100)
const perfect = gradeLessonSubmission(compoundInterestLesson, correct)
assert.equal(perfect.feedback?.questions.length, 5)
assert.equal(perfect.feedback?.questions.every((item) => item.earned), true)
assert.throws(() => gradeLessonSubmission(compoundInterestLesson, correct.slice(0, 4)))
assert.throws(() => gradeLessonSubmission(compoundInterestLesson, [...correct, correct[0]!]))
assert.throws(() => gradeLessonSubmission(compoundInterestLesson, [{ questionId: 'missing', answerId: '121' }]))
assert.throws(() =>
gradeLessonSubmission(compoundInterestLesson, correct.map((answer) => ({ ...answer, orderedItemIds: ['x'] }))),
)
const publicLesson = JSON.stringify(sanitizeLesson(compoundInterestLesson))
assert.equal(publicLesson.includes('correctAnswerId'), false)
assert.equal(publicLesson.includes('explanation'), false)
assert.equal(publicLesson.includes('1331'), true)
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
USER,
JSON.stringify(createDefaultUserState(USER)),
now,
)
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 (?, ?, 80, 2, 1, ?, ?, ?)
`)
for (let id = 1; id <= 8; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 10))
insert.run(USER, 9, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 10)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 11)?.status, 'locked')
assert.throws(() => progression.completeLevel(USER, 10, { answers: withCount(2) }))
const first = progression.completeLevel(USER, 10, { answers: withCount(3) })
assert.equal(first.score, 60)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 45 + 5)
assert.equal(first.reward.coins, 18 + 3)
const replay = progression.completeLevel(USER, 10, { answers: withCount(3) })
assert.equal(replay.reward.xp, 0)
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 10, { answers: correct })
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 11)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 12)?.status, 'locked')
const parallelId = 'compound-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
parallelId,
JSON.stringify(createDefaultUserState(parallelId)),
now,
)
for (let id = 1; id <= 9; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const [left, right] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/gold-land/levels/10/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: correct },
}),
app.inject({
method: 'POST',
url: '/api/gold-land/levels/10/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: correct },
}),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map((response) => JSON.parse(response.payload) as { reward: { xp: number; coins: number } })
assert.equal(bodies.reduce((sum, body) => sum + body.reward.coins, 0), 18 + 9)
assert.equal(bodies.reduce((sum, body) => sum + body.reward.xp, 0), 45 + 15)
console.log('compound-interest tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+294
View File
@@ -0,0 +1,294 @@
/**
* Concurrency / RMW safety — temp SQLite only.
* Run: npm run test:concurrency
*/
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 { DatabaseService } from '../src/database/database.service'
import { getGradableQuestions } from '../src/gold-land/lessons/lesson.grader'
import { loadLessonForLevel } from '../src/gold-land/lessons/lesson.loader'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-concurrency-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3997'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
function parseJson<T>(payload: string): T {
return JSON.parse(payload) as T
}
function authHeader(token: string) {
return { authorization: `Bearer ${token}` }
}
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
}
async function registerUser(app: NestFastifyApplication, username: string): Promise<string> {
const res = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { username, password: '123456', name: username },
})
assert.ok(res.statusCode === 200 || res.statusCode === 201, `register status ${res.statusCode}`)
return parseJson<{ token: string }>(res.payload).token
}
function setUserCoins(db: DatabaseService, userId: string, coins: number) {
const row = db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as
| { state_json: string }
| undefined
assert.ok(row)
const state = JSON.parse(row.state_json) as ReturnType<typeof createDefaultUserState>
state.coins = coins
db.getDb()
.prepare('UPDATE users SET state_json = ?, updated_at = ? WHERE id = ?')
.run(JSON.stringify(state), new Date().toISOString(), userId)
}
function setUserCash(db: DatabaseService, userId: string, cash: number, goldGrams = 0) {
const row = db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as
| { state_json: string }
| undefined
assert.ok(row)
const state = JSON.parse(row.state_json) as ReturnType<typeof createDefaultUserState>
state.cash = cash
state.goldGrams = goldGrams
db.getDb()
.prepare('UPDATE users SET state_json = ?, updated_at = ? WHERE id = ?')
.run(JSON.stringify(state), new Date().toISOString(), userId)
}
function readCoins(db: DatabaseService, userId: string): number {
const row = db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as {
state_json: string
}
return (JSON.parse(row.state_json) as { coins: number }).coins
}
function countLedger(db: DatabaseService, userId: string, rewardKey: string): number {
const row = db
.getDb()
.prepare('SELECT COUNT(*) AS c FROM reward_ledger WHERE user_id = ? AND reward_key = ?')
.get(userId, rewardKey) as { c: number }
return row.c
}
async function runTests() {
const app = await createApp()
const db = app.get(DatabaseService)
try {
// --- parallel store purchases: combined cost exceeds balance ---
{
const token = await registerUser(app, `store-race-${Date.now()}`)
const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const userId = parseJson<{ user: { id: string } }>(me.payload).user.id
setUserCoins(db, userId, 6000)
const [castle, crown] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/store/purchases',
headers: authHeader(token),
payload: { productId: 'castle' },
}),
app.inject({
method: 'POST',
url: '/api/store/purchases',
headers: authHeader(token),
payload: { productId: 'crown' },
}),
])
const okCount = [castle, crown].filter((r) => r.statusCode === 200).length
const failCount = [castle, crown].filter((r) => r.statusCode >= 400).length
assert.equal(okCount, 1)
assert.equal(failCount, 1)
const finalCoins = readCoins(db, userId)
assert.ok(finalCoins >= 0)
assert.ok(finalCoins < 6000)
}
// --- parallel lesson completes: first-completion ledger once ---
{
const token = await registerUser(app, `lesson-race-${Date.now()}`)
const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const userId = parseJson<{ user: { id: string } }>(me.payload).user.id
await app.inject({
method: 'POST',
url: '/api/gold-land/levels/1/start',
headers: authHeader(token),
})
const lesson = loadLessonForLevel('what-is-money')
assert.ok(lesson)
const answers = getGradableQuestions(lesson).map(({ questionId, step }) => {
if (step.type === 'sort') {
return { questionId, orderedItemIds: [...step.correctOrder] }
}
return { questionId, answerId: step.correctAnswerId }
})
const payload = JSON.stringify({ answers, durationMs: 3000 })
const [a, b] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/gold-land/levels/1/complete',
headers: { ...authHeader(token), 'content-type': 'application/json' },
payload,
}),
app.inject({
method: 'POST',
url: '/api/gold-land/levels/1/complete',
headers: { ...authHeader(token), 'content-type': 'application/json' },
payload,
}),
])
assert.ok(a.statusCode === 200 || b.statusCode === 200)
assert.equal(countLedger(db, userId, 'level:1:first-completion'), 1)
}
// --- parallel daily challenge completes ---
{
const token = await registerUser(app, `challenge-race-${Date.now()}`)
const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const userId = parseJson<{ user: { id: string } }>(me.payload).user.id
const coinsBefore = readCoins(db, userId)
const [a, b] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/challenges/daily/complete',
headers: authHeader(token),
payload: { optionId: 'gold' },
}),
app.inject({
method: 'POST',
url: '/api/challenges/daily/complete',
headers: authHeader(token),
payload: { optionId: 'gold' },
}),
])
assert.equal(a.statusCode, 200)
assert.equal(b.statusCode, 200)
const coinsAfter = readCoins(db, userId)
assert.equal(coinsAfter, coinsBefore + 50)
}
// --- parallel gold buys: only one should succeed when cash fits one ---
{
const token = await registerUser(app, `wallet-race-${Date.now()}`)
const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const userId = parseJson<{ user: { id: string } }>(me.payload).user.id
const { GOLD_PRICE } = await import('../src/shared/catalog/market.constants')
setUserCash(db, userId, GOLD_PRICE * 2, 0)
const [buy1, buy2] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: authHeader(token),
payload: { grams: 2 },
}),
app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: authHeader(token),
payload: { grams: 2 },
}),
])
const results = [buy1, buy2]
const ok = results.filter((r) => r.statusCode === 200).length
const bad = results.filter((r) => r.statusCode >= 400).length
assert.equal(ok, 1)
assert.equal(bad, 1)
const state = JSON.parse(
(db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as {
state_json: string
}).state_json,
) as { cash: number; goldGrams: number }
assert.ok(state.cash >= 0)
assert.equal(state.goldGrams, 2)
assert.ok(state.cash < GOLD_PRICE * 2)
}
// --- parallel game complete: first-completion reward once ---
{
const token = await registerUser(app, `game-race-${Date.now()}`)
const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const userId = parseJson<{ user: { id: string; coins: number; xp: number } }>(me.payload).user
const coinsBefore = userId.coins
const xpBefore = userId.xp
const [g1, g2] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/games/gold-guess/complete',
headers: authHeader(token),
payload: {},
}),
app.inject({
method: 'POST',
url: '/api/games/gold-guess/complete',
headers: authHeader(token),
payload: {},
}),
])
assert.equal(g1.statusCode, 200)
assert.equal(g2.statusCode, 200)
const after = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const body = parseJson<{ user: { coins: number; xp: number } }>(after.payload)
assert.equal(body.user.coins, coinsBefore + 20)
assert.equal(body.user.xp, xpBefore + 50)
}
// Level 7 stays locked until level 6 has a star. Inactive level 8 is covered in budgeting tests.
{
const token = await registerUser(app, `locked7-${Date.now()}`)
const res = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/7/start',
headers: authHeader(token),
})
assert.equal(res.statusCode, 403)
}
console.log('concurrency tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
runTests().catch((error) => {
console.error(error)
process.exit(1)
})
+97
View File
@@ -0,0 +1,97 @@
/**
* Two independent SQLite connections on one file — one writer wins, no corrupt cash.
* Run: npm run test:dual-connection
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Worker } from 'node:worker_threads'
import Database from 'better-sqlite3'
import { runSchemaMigrations } from '../src/database/migration.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-dual-sqlite-'))
const dbPath = join(testDir, 'dual.db')
const userId = 'dual-user'
function seedUser(db: Database.Database) {
const state = createDefaultUserState(userId)
state.cash = 1000
state.goldGrams = 0
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
userId,
JSON.stringify(state),
new Date().toISOString(),
)
}
function readCash(): number {
const db = new Database(dbPath, { readonly: true })
const row = db.prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as {
state_json: string
}
db.close()
return (JSON.parse(row.state_json) as { cash: number }).cash
}
function spendInWorker(amount: number): Promise<'ok' | 'fail'> {
return new Promise((resolve, reject) => {
const worker = new Worker(
`
const Database = require('better-sqlite3');
const { parentPort, workerData } = require('worker_threads');
const db = new Database(workerData.dbPath);
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
const spend = db.transaction(() => {
const row = db.prepare('SELECT state_json FROM users WHERE id = ?').get(workerData.userId);
const state = JSON.parse(row.state_json);
if (state.cash < workerData.amount) return false;
state.cash -= workerData.amount;
db.prepare('UPDATE users SET state_json = ?, updated_at = ? WHERE id = ?').run(
JSON.stringify(state),
new Date().toISOString(),
workerData.userId,
);
return true;
}).immediate;
parentPort.postMessage(spend() ? 'ok' : 'fail');
db.close();
`,
{ eval: true, workerData: { dbPath, userId, amount } },
)
worker.on('message', (msg) => resolve(msg as 'ok' | 'fail'))
worker.on('error', reject)
})
}
async function runTests() {
const bootstrap = new Database(dbPath)
bootstrap.pragma('journal_mode = WAL')
runSchemaMigrations(bootstrap)
seedUser(bootstrap)
bootstrap.close()
const [a, b] = await Promise.all([spendInWorker(700), spendInWorker(700)])
const outcomes = [a, b]
assert.equal(outcomes.filter((x) => x === 'ok').length, 1)
assert.equal(outcomes.filter((x) => x === 'fail').length, 1)
const finalCash = readCash()
assert.equal(finalCash, 300)
assert.ok(finalCash >= 0)
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
console.log('dual-connection tests passed')
}
runTests().catch((error) => {
console.error(error)
process.exit(1)
})
+149
View File
@@ -0,0 +1,149 @@
/**
* Financial goal — temporary SQLite only.
*/
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 { initialPlan, revisedPlan, INITIAL_RATES, EXCEPTION_RATES, CONTINUE_RATES } from '../src/gold-land/financial-goal/goal.math'
import { gradeFinancialGoalSubmission } from '../src/gold-land/financial-goal/goal.grader'
import { financialGoalMission } from '../src/gold-land/financial-goal/goal.mission'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'goal-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-goal-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3999'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
const correct = [
{ questionId: 'q-goal', answerId: 'specific' },
{ questionId: 'q-left', answerId: '90' },
{ questionId: 'q-weeks', answerId: '6' },
{ questionId: 'q-need', answerId: 'adjust' },
{ questionId: 'q-change', answerId: 'update' },
]
const wrong = ['vague', '120', '5', 'drop', 'keep']
function submission(plan: { initialWeekly: number; exceptionWeekly: number; continueWeekly: number }, count: number) {
return [
{ questionId: 'plan', plan },
...correct.map((answer, index) => (index < count ? answer : { questionId: answer.questionId, answerId: wrong[index]! })),
]
}
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
}
async function run() {
assert.deepEqual(INITIAL_RATES.map((rate) => initialPlan(rate).weeks), [18, 9, 6, 5])
const sample = revisedPlan(15, 5, 15)
assert.equal(sample.balanceAfterTwo, 50)
assert.equal(sample.remainingWeeks, 5)
assert.equal(sample.totalWeeks, 7)
assert.equal(sample.finalBalance, 125)
const paused = revisedPlan(15, 0, 15)
assert.equal(paused.balanceAfterTwo, 45)
assert.equal(paused.totalWeeks, 7)
const totals: number[] = []
for (const initial of INITIAL_RATES) {
for (const exception of EXCEPTION_RATES) {
for (const cont of CONTINUE_RATES) {
const result = revisedPlan(initial, exception, cont)
assert.equal(result.totalWeeks, 2 + result.remainingWeeks)
assert.equal(result.finalBalance, result.balanceAfterTwo + result.remainingWeeks * cont)
assert.ok(result.finalBalance >= 120)
totals.push(result.totalWeeks)
}
}
}
assert.equal(Math.min(...totals), 5)
const slow = { initialWeekly: 5, exceptionWeekly: 0, continueWeekly: 5 }
const fast = { initialWeekly: 20, exceptionWeekly: 10, continueWeekly: 20 }
assert.equal(gradeFinancialGoalSubmission(submission(slow, 0)).score, 0)
assert.equal(gradeFinancialGoalSubmission(submission(fast, 2)).score, 40)
assert.equal(gradeFinancialGoalSubmission(submission(slow, 3)).score, 60)
assert.equal(gradeFinancialGoalSubmission(submission(fast, 4)).score, 80)
assert.equal(gradeFinancialGoalSubmission(submission(slow, 5)).score, 100)
assert.equal(gradeFinancialGoalSubmission(submission(fast, 5)).score, 100)
assert.throws(() => gradeFinancialGoalSubmission(submission({ initialWeekly: 15, exceptionWeekly: 5, continueWeekly: 0 }, 5)))
assert.throws(() => gradeFinancialGoalSubmission([...submission(fast, 5), correct[0]!]))
assert.throws(() => gradeFinancialGoalSubmission(submission(fast, 5).map((answer) => ({ ...answer, weeks: 7 }))))
assert.equal(JSON.stringify(financialGoalMission).includes('correctOptionId'), false)
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(USER, JSON.stringify(createDefaultUserState(USER)), now)
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 (?, ?, 80, 2, 1, ?, ?, ?)
`)
for (let id = 1; id <= 10; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 12))
insert.run(USER, 11, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 12)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 13)?.status, 'locked')
assert.throws(() => progression.completeLevel(USER, 12, { answers: submission(fast, 2) }))
const first = progression.completeLevel(USER, 12, { answers: submission(slow, 3) })
assert.equal(first.score, 60)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 50 + 5)
assert.equal(first.reward.coins, 20 + 3)
const replay = progression.completeLevel(USER, 12, { answers: submission(fast, 3) })
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 12, { answers: submission({ initialWeekly: 15, exceptionWeekly: 5, continueWeekly: 15 }, 5) })
assert.equal(improved.feedback?.timeline.totalWeeks, 7)
assert.equal(improved.feedback?.timeline.finalBalance, 125)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 13)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).length, 13)
const parallelId = 'goal-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(parallelId, JSON.stringify(createDefaultUserState(parallelId)), now)
for (let id = 1; id <= 11; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const body = { answers: submission(fast, 5) }
const [left, right] = await Promise.all([
app.inject({ method: 'POST', url: '/api/gold-land/levels/12/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
app.inject({ method: 'POST', url: '/api/gold-land/levels/12/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map((response) => JSON.parse(response.payload) as { reward: { coins: number; xp: number } })
assert.equal(bodies.reduce((sum, item) => sum + item.reward.coins, 0), 20 + 9)
assert.equal(bodies.reduce((sum, item) => sum + item.reward.xp, 0), 50 + 15)
console.log('financial-goal tests passed')
} finally {
await app.close()
try { rmSync(testDir, { recursive: true, force: true }) } catch { /* ignore */ }
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+276
View File
@@ -0,0 +1,276 @@
/**
* Gold trade precision + no rounding-arbitrage — temp SQLite only.
* Run: npm run test:gold-precision
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ValidationPipe, BadRequestException } 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 {
applyGoldPurchase,
applyGoldSale,
gramsToMicroGrams,
normalizeGoldGrams,
tomansForGoldGramsRound,
tomansForGoldPurchase,
tomansForGoldSale,
GOLD_PRICE_TOMAN_PER_GRAM,
MAX_GOLD_TRADE_GRAMS,
} from '../src/wallet/gold-trade.helpers'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
import { GOLD_PRICE } from '../src/shared/catalog/market.constants'
import { DatabaseService } from '../src/database/database.service'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-gold-precision-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3996'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
function parseJson<T>(payload: string): T {
return JSON.parse(payload) as T
}
function authHeader(token: string) {
return { authorization: `Bearer ${token}` }
}
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
}
async function registerUser(app: NestFastifyApplication, username: string): Promise<string> {
const res = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { username, password: '123456', name: username },
})
assert.ok(res.statusCode === 200 || res.statusCode === 201)
return parseJson<{ token: string }>(res.payload).token
}
function assertNoCashArbitrage(initialCash: number, finalCash: number) {
assert.ok(
finalCash <= initialCash,
`cash must not increase from rounding (initial=${initialCash}, final=${finalCash})`,
)
}
function unitTests() {
assert.equal(GOLD_PRICE_TOMAN_PER_GRAM, GOLD_PRICE)
// Pre-fix symmetric round produced +1 toman on this split (5 buy, 6 sell).
assert.equal(tomansForGoldGramsRound(0.000_003), 5)
assert.equal(tomansForGoldGramsRound(0.000_001), 2)
assert.equal(tomansForGoldPurchase(0.000_003), 6)
assert.equal(tomansForGoldSale(0.000_001), 1)
assert.equal(tomansForGoldPurchase(0.000_001), 2)
assert.equal(tomansForGoldPurchase(1), GOLD_PRICE)
assert.equal(tomansForGoldSale(1), GOLD_PRICE)
assert.equal(tomansForGoldPurchase(0.05), Math.ceil(0.05 * GOLD_PRICE))
assert.equal(tomansForGoldSale(0.05), Math.floor(0.05 * GOLD_PRICE))
assert.throws(() => applyGoldPurchase({ cash: 1e12, goldGrams: 0 }, Number.POSITIVE_INFINITY), BadRequestException)
assert.throws(() => applyGoldPurchase({ cash: 1e12, goldGrams: 0 }, 0), BadRequestException)
assert.throws(() => applyGoldPurchase({ cash: 1e12, goldGrams: 0 }, 0.000_000_4), BadRequestException)
const maxMicro = gramsToMicroGrams(MAX_GOLD_TRADE_GRAMS)
assert.ok(maxMicro * GOLD_PRICE > Number.MAX_SAFE_INTEGER)
assert.equal(tomansForGoldPurchase(MAX_GOLD_TRADE_GRAMS), MAX_GOLD_TRADE_GRAMS * GOLD_PRICE)
const state = { cash: 10_000_000, goldGrams: 42.5 }
const cost = applyGoldPurchase(state, 0.05)
assert.equal(cost, Math.ceil(0.05 * GOLD_PRICE))
assert.equal(state.goldGrams, normalizeGoldGrams(42.5 + 0.05))
applyGoldSale(state, 0.001)
assert.equal(state.goldGrams, normalizeGoldGrams(42.5 + 0.05 - 0.001))
// 3 µg buy then three 1 µg sells — no cash gain.
const arb = createDefaultUserState('arb')
arb.cash = 1000
arb.goldGrams = 0
applyGoldPurchase(arb, 0.000_003)
applyGoldSale(arb, 0.000_001)
applyGoldSale(arb, 0.000_001)
applyGoldSale(arb, 0.000_001)
assert.equal(arb.goldGrams, 0)
assertNoCashArbitrage(1000, arb.cash)
// Aggregated sell vs split sells — same gold out, cash never above start.
const agg = createDefaultUserState('agg')
agg.cash = 500_000
agg.goldGrams = 0
const aggStart = agg.cash
applyGoldPurchase(agg, 0.000_003)
applyGoldSale(agg, 0.000_003)
assert.equal(agg.goldGrams, 0)
assertNoCashArbitrage(aggStart, agg.cash)
// Many micro cycles at fixed rate.
const repeat = createDefaultUserState('u')
const startCash = GOLD_PRICE * 100
repeat.cash = startCash
repeat.goldGrams = 0
for (let i = 0; i < 50; i++) {
applyGoldPurchase(repeat, 0.000_001)
applyGoldSale(repeat, 0.000_001)
}
assert.equal(repeat.goldGrams, 0)
assertNoCashArbitrage(startCash, repeat.cash)
// Buy small chunks, sell in one lump.
const lump = createDefaultUserState('lump')
lump.cash = 1_000_000
lump.goldGrams = 0
const lumpStart = lump.cash
for (let i = 0; i < 10; i++) {
applyGoldPurchase(lump, 0.000_001)
}
applyGoldSale(lump, 0.000_01)
assert.equal(lump.goldGrams, 0)
assertNoCashArbitrage(lumpStart, lump.cash)
}
async function seedWallet(
app: NestFastifyApplication,
token: string,
cash: number,
goldGrams: number,
): Promise<string> {
const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const userId = parseJson<{ user: { id: string } }>(me.payload).user.id
const db = app.get(DatabaseService)
const row = db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as {
state_json: string
}
const state = JSON.parse(row.state_json) as ReturnType<typeof createDefaultUserState>
state.cash = cash
state.goldGrams = goldGrams
db.getDb()
.prepare('UPDATE users SET state_json = ?, updated_at = ? WHERE id = ?')
.run(JSON.stringify(state), new Date().toISOString(), userId)
return userId
}
async function apiRegressionScenario(app: NestFastifyApplication) {
const token = await registerUser(app, `gold-round-${Date.now()}`)
const initialCash = 1000
await seedWallet(app, token, initialCash, 0)
const buy = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: { ...authHeader(token), 'content-type': 'application/json' },
payload: JSON.stringify({ grams: 0.000_003 }),
})
assert.equal(buy.statusCode, 200)
const afterBuy = parseJson<{ wallet: { cash: number; goldGrams: number } }>(buy.payload)
assert.equal(afterBuy.wallet.goldGrams, 0.000_003)
assert.equal(afterBuy.wallet.cash, initialCash - tomansForGoldPurchase(0.000_003))
let cash = afterBuy.wallet.cash
let gold = afterBuy.wallet.goldGrams
for (let i = 0; i < 3; i++) {
const sell = await app.inject({
method: 'POST',
url: '/api/wallet/gold/sell',
headers: { ...authHeader(token), 'content-type': 'application/json' },
payload: JSON.stringify({ grams: 0.000_001 }),
})
assert.equal(sell.statusCode, 200)
const body = parseJson<{ wallet: { cash: number; goldGrams: number } }>(sell.payload)
cash = body.wallet.cash
gold = body.wallet.goldGrams
}
assert.equal(gold, 0)
assertNoCashArbitrage(initialCash, cash)
assert.notEqual(cash, initialCash + 1, 'old symmetric round would have created +1 toman')
}
async function apiTests(app: NestFastifyApplication) {
await apiRegressionScenario(app)
const token = await registerUser(app, `gold-precision-${Date.now()}`)
await seedWallet(app, token, 500_000, 0)
const subToman = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: { ...authHeader(token), 'content-type': 'application/json' },
payload: JSON.stringify({ grams: 0.000_000_5 }),
})
assert.equal(subToman.statusCode, 400)
const highPrecision = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: { ...authHeader(token), 'content-type': 'application/json' },
payload: JSON.stringify({ grams: 0.000_001_5 }),
})
assert.equal(highPrecision.statusCode, 400)
assert.equal(parseJson<{ error: string }>(highPrecision.payload).error, 'دقت مقدار طلا بیش از حد مجاز است')
const userId = await seedWallet(app, token, 500_000, 0)
const db = app.get(DatabaseService)
const stateBeforeFail = (
db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as { state_json: string }
).state_json
const insufficient = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: authHeader(token),
payload: { grams: 1000 },
})
assert.equal(insufficient.statusCode, 400)
const stateAfterFail = (
db.getDb().prepare('SELECT state_json FROM users WHERE id = ?').get(userId) as { state_json: string }
).state_json
assert.equal(stateAfterFail, stateBeforeFail)
const buy = await app.inject({
method: 'POST',
url: '/api/wallet/gold/buy',
headers: { ...authHeader(token), 'content-type': 'application/json' },
payload: JSON.stringify({ grams: 0.000_001 }),
})
assert.equal(buy.statusCode, 200)
const buyBody = parseJson<{ wallet: { cash: number; goldGrams: number } }>(buy.payload)
assert.equal(buyBody.wallet.goldGrams, 0.000_001)
assert.equal(buyBody.wallet.cash, 500_000 - tomansForGoldPurchase(0.000_001))
}
async function runTests() {
unitTests()
const app = await createApp()
try {
await apiTests(app)
console.log('gold-precision tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
runTests().catch((error) => {
console.error(error)
process.exit(1)
})
+2 -2
View File
@@ -428,7 +428,7 @@ async function runTests() {
assert.equal(levelsMeta.statusCode, 200)
const levelsBody = parseJson<{ levels: Array<{ id: number; isActive: boolean }> }>(levelsMeta.payload)
assert.equal(levelsBody.levels.length, GOLD_LAND_LEVELS.length)
assert.equal(levelsBody.levels.filter((l) => l.isActive).length, 6)
assert.equal(levelsBody.levels.filter((l) => l.isActive).length, 13)
const noAuthProgress = await app.inject({ method: 'GET', url: '/api/gold-land/progress' })
assert.equal(noAuthProgress.statusCode, 401)
@@ -1825,7 +1825,7 @@ async function runTests() {
catalogSnapshot,
)
const { createDefaultUserState: createDefaultUserStateF0 } = await import(
const { createDefaultUserState } = await import(
'../src/shared/catalog/default-user-state'
)
const defaultState1 = createDefaultUserState('f4-default-1')
+211
View File
@@ -0,0 +1,211 @@
/**
* Lesson reward rules via HTTP — four replay scenarios on level 6 (inflation).
* Run: npm run test:lesson-rewards
*/
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 { inflationLesson } from '../src/gold-land/lessons/inflation.lesson'
import { DatabaseService } from '../src/database/database.service'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-lesson-rewards-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3995'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
function parseJson<T>(payload: string): T {
return JSON.parse(payload) as T
}
function authHeader(token: string) {
return { authorization: `Bearer ${token}` }
}
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 buildAllCorrect(lesson: LessonDefinition): LessonSubmissionAnswer[] {
return getGradableQuestions(lesson).map(({ questionId, step }) => {
if (step.type === 'sort') {
return { questionId, orderedItemIds: [...step.correctOrder] }
}
return { questionId, answerId: step.correctAnswerId }
})
}
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
}
async function registerUser(app: NestFastifyApplication): Promise<{ token: string; userId: string }> {
const username = `lesson-rewards-${Date.now()}`
const res = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { username, password: '123456', name: username },
})
assert.ok(res.statusCode === 200 || res.statusCode === 201)
const token = parseJson<{ token: string }>(res.payload).token
const me = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const userId = parseJson<{ user: { id: string } }>(me.payload).user.id
return { token, userId }
}
function unlockThroughLevel5(db: DatabaseService, userId: string) {
const now = new Date().toISOString()
for (let levelId = 1; levelId <= 5; levelId++) {
db.getDb().prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, 65, 1, 1, ?, ?, ?)
`).run(userId, levelId, now, now, now)
}
}
function countLedger(db: DatabaseService, userId: string, rewardKey: string): number {
const row = db
.getDb()
.prepare('SELECT COUNT(*) AS c FROM reward_ledger WHERE user_id = ? AND reward_key = ?')
.get(userId, rewardKey) as { c: number }
return row.c
}
type CompletePayload = {
levelId: number
score: number
stars: number
previousStars: number
isFirstCompletion: boolean
bestScoreImproved: boolean
reward: { xp: number; coins: number }
me: { user: { xp: number; coins: number }; progress: { totalStars: number } }
}
async function completeLevel(
app: NestFastifyApplication,
token: string,
answers: LessonSubmissionAnswer[],
): Promise<CompletePayload> {
const res = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/6/complete',
headers: { ...authHeader(token), 'content-type': 'application/json' },
payload: JSON.stringify({ answers, durationMs: 4000 }),
})
assert.equal(res.statusCode, 200, res.payload)
return parseJson<CompletePayload>(res.payload)
}
async function runTests() {
const app = await createApp()
const db = app.get(DatabaseService)
try {
const { token, userId } = await registerUser(app)
unlockThroughLevel5(db, userId)
await app.inject({
method: 'POST',
url: '/api/gold-land/levels/6/start',
headers: authHeader(token),
})
const THREE = buildPartialCorrect(inflationLesson, 3)
const ALL = buildAllCorrect(inflationLesson)
const FOUR = buildPartialCorrect(inflationLesson, 4)
const me0 = await app.inject({ method: 'GET', url: '/api/me', headers: authHeader(token) })
const base = parseJson<{ user: { xp: number; coins: number } }>(me0.payload)
// 1) First completion with 1★ (score 60)
const first = await completeLevel(app, token, THREE)
assert.equal(first.score, 60)
assert.equal(first.stars, 1)
assert.equal(first.previousStars, 0)
assert.equal(first.isFirstCompletion, true)
assert.equal(first.bestScoreImproved, true)
assert.equal(first.reward.xp, 40 + 5)
assert.equal(first.reward.coins, 15 + 3)
assert.equal(countLedger(db, userId, 'level:6:first-completion'), 1)
assert.equal(countLedger(db, userId, 'level:6:star:1'), 1)
assert.equal(first.me.progress.mapLevels.find((l) => l.id === 6)?.stars, 1)
// 2) Repeat same score — no extra reward
const repeat = await completeLevel(app, token, THREE)
assert.equal(repeat.stars, 1)
assert.equal(repeat.isFirstCompletion, false)
assert.equal(repeat.bestScoreImproved, false)
assert.equal(repeat.reward.xp, 0)
assert.equal(repeat.reward.coins, 0)
assert.equal(repeat.me.user.xp, base.user.xp + first.reward.xp)
assert.equal(repeat.me.user.coins, base.user.coins + first.reward.coins)
// 3) Improve 1★ → 3★
const improved = await completeLevel(app, token, ALL)
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.previousStars, 1)
assert.equal(improved.bestScoreImproved, true)
assert.equal(improved.reward.xp, 5 + 5)
assert.equal(improved.reward.coins, 3 + 3)
assert.equal(countLedger(db, userId, 'level:6:star:2'), 1)
assert.equal(countLedger(db, userId, 'level:6:star:3'), 1)
// 4) Lower run after best — stars stay 3, no reward
const lower = await completeLevel(app, token, FOUR)
assert.equal(lower.score, 80)
assert.equal(lower.stars, 3)
assert.equal(lower.previousStars, 3)
assert.equal(lower.bestScoreImproved, false)
assert.equal(lower.reward.xp, 0)
assert.equal(lower.reward.coins, 0)
console.log('lesson-rewards-api tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
runTests().catch((error) => {
console.error(error)
process.exit(1)
})
+199
View File
@@ -0,0 +1,199 @@
/**
* Portfolio game — temporary SQLite only.
*/
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 { gradePortfolioSubmission } from '../src/gold-land/portfolio/portfolio.grader'
import { simulatePortfolio } from '../src/gold-land/portfolio/portfolio.math'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'portfolio-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-portfolio-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3996'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
const correctQuestions = [
{ questionId: 'q-one', answerId: 'depend' },
{ questionId: 'q-loss', answerId: 'no' },
{ questionId: 'q-name', answerId: 'no' },
{ questionId: 'q-best', answerId: 'no' },
{ questionId: 'q-approach', answerId: 'balanced' },
]
const wrongIds = ['safe', 'yes', 'yes', 'yes', 'count']
function mix(holding: { sun: number; rain: number; wind: number }, count: number) {
return [
{ questionId: 'portfolio', holdings: holding },
...correctQuestions.map((answer, index) =>
index < count ? answer : { questionId: answer.questionId, answerId: wrongIds[index] ?? 'safe' },
),
]
}
function scenario(holding: { sun: number; rain: number; wind: number }, id: string) {
const found = simulatePortfolio(holding).find((item) => item.id === id)
assert.ok(found)
return found
}
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
}
async function run() {
const concentrated = { sun: 100, rain: 0, wind: 0 }
const split = { sun: 40, rain: 30, wind: 30 }
const other = { sun: 20, rain: 50, wind: 30 }
assert.deepEqual(
[scenario(concentrated, 'sunny').total, scenario(concentrated, 'rainy').total, scenario(concentrated, 'drop').total],
[120, 80, 90],
)
assert.equal(scenario(concentrated, 'sunny').changeLabel, 'سود 20 سکه')
assert.equal(scenario(concentrated, 'rainy').changeLabel, 'زیان 20 سکه')
assert.deepEqual(
[scenario(split, 'sunny').total, scenario(split, 'rainy').total, scenario(split, 'drop').total],
[105, 95, 90],
)
assert.equal(scenario(split, 'sunny').changeLabel, 'سود 5 سکه')
assert.equal(scenario(split, 'rainy').changeLabel, 'زیان 5 سکه')
assert.deepEqual(
[scenario(other, 'sunny').total, scenario(other, 'rainy').total, scenario(other, 'drop').total],
[99, 101, 90],
)
for (const holding of [concentrated, split, other]) {
const drop = scenario(holding, 'drop')
assert.equal(drop.total, 90)
assert.equal(drop.changeLabel, 'زیان 10 سکه')
}
assert.equal(gradePortfolioSubmission(mix(concentrated, 0)).score, 0)
assert.equal(gradePortfolioSubmission(mix(split, 2)).score, 40)
assert.equal(gradePortfolioSubmission(mix(other, 3)).score, 60)
assert.equal(gradePortfolioSubmission(mix(concentrated, 4)).score, 80)
assert.equal(gradePortfolioSubmission(mix(split, 5)).score, 100)
assert.equal(gradePortfolioSubmission(mix(concentrated, 5)).score, 100)
assert.throws(() => gradePortfolioSubmission(mix({ sun: 90, rain: 0, wind: 0 }, 5)))
assert.throws(() => gradePortfolioSubmission(mix({ sun: 12, rain: 44, wind: 44 }, 5)))
assert.throws(() => gradePortfolioSubmission(mix({ sun: -10, rain: 60, wind: 50 }, 5)))
assert.throws(() =>
gradePortfolioSubmission([{ questionId: 'portfolio', holdings: { sun: 100, rain: 0, wind: 0, gold: 0 } as never }, ...correctQuestions]),
)
assert.throws(() => gradePortfolioSubmission([...mix(split, 5), correctQuestions[0]!]))
assert.throws(() =>
gradePortfolioSubmission(mix(split, 5).map((answer, index) => (index === 1 ? { ...answer, answerId: 'nope' } : answer))),
)
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
USER,
JSON.stringify(createDefaultUserState(USER)),
now,
)
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 (?, ?, 80, 2, 1, ?, ?, ?)
`)
for (let id = 1; id <= 7; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 9))
insert.run(USER, 8, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 9)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 10)?.status, 'locked')
const detail = progression.getGoldLandLevel(USER, 9)
const payload = JSON.stringify(detail.activity)
assert.equal(payload.includes('correctOptionId'), false)
assert.equal(payload.includes('آفتاب'), true)
assert.throws(() => progression.completeLevel(USER, 9, { answers: mix(split, 2) }))
const first = progression.completeLevel(USER, 9, { answers: mix(concentrated, 3) })
assert.equal(first.score, 60)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 50 + 5)
assert.equal(first.reward.coins, 20 + 3)
const replay = progression.completeLevel(USER, 9, { answers: mix(split, 3) })
assert.equal(replay.score, 60)
assert.equal(replay.reward.xp, 0)
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 9, { answers: mix(other, 5) })
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 10)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 11)?.status, 'locked')
const parallelId = 'portfolio-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
parallelId,
JSON.stringify(createDefaultUserState(parallelId)),
now,
)
for (let id = 1; id <= 8; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const body = { answers: mix(split, 5) }
const [left, right] = await Promise.all([
app.inject({ method: 'POST', url: '/api/gold-land/levels/9/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
app.inject({ method: 'POST', url: '/api/gold-land/levels/9/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map((response) => JSON.parse(response.payload) as { reward: { xp: number; coins: number } })
assert.equal(bodies.reduce((sum, item) => sum + item.reward.coins, 0), 20 + 9)
assert.equal(bodies.reduce((sum, item) => sum + item.reward.xp, 0), 50 + 15)
const keys = db.prepare('SELECT reward_key FROM reward_ledger WHERE user_id = ? ORDER BY reward_key').all(parallelId) as Array<{ reward_key: string }>
assert.deepEqual(keys.map((row) => row.reward_key), [
'level:9:first-completion',
'level:9:star:1',
'level:9:star:2',
'level:9:star:3',
])
const bad = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/9/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: [{ questionId: 'portfolio', holdings: { sun: 50, rain: 50 } }] },
})
assert.equal(bad.statusCode, 400)
console.log('portfolio tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+67
View File
@@ -0,0 +1,67 @@
import 'reflect-metadata'
import assert from 'node:assert/strict'
import { validateEnv } from '../src/config/env.validation'
const VALID_PROD_SECRET = 'a'.repeat(48)
assert.throws(
() =>
validateEnv({
NODE_ENV: 'production',
PORT: 3000,
HOST: '0.0.0.0',
CORS_ORIGIN: 'https://app.example.com',
JWT_SECRET: '',
}),
/JWT_SECRET is required/,
)
assert.throws(
() =>
validateEnv({
NODE_ENV: 'production',
PORT: 3000,
HOST: '0.0.0.0',
CORS_ORIGIN: 'https://app.example.com',
JWT_SECRET: 'gold-land-dev-secret-change-in-production',
}),
/placeholder/,
)
assert.throws(
() =>
validateEnv({
NODE_ENV: 'production',
PORT: 3000,
HOST: '0.0.0.0',
CORS_ORIGIN: 'https://app.example.com',
JWT_SECRET: 'short',
}),
/32 characters/,
)
assert.throws(
() =>
validateEnv({
NODE_ENV: 'production',
PORT: 3000,
HOST: '0.0.0.0',
CORS_ORIGIN: 'https://app.example.com',
JWT_SECRET: VALID_PROD_SECRET,
TRUST_PROXY: 'true',
}),
/TRUST_PROXY_CIDRS is required/,
)
const ok = validateEnv({
NODE_ENV: 'production',
PORT: 3000,
HOST: '0.0.0.0',
CORS_ORIGIN: 'https://app.example.com',
JWT_SECRET: VALID_PROD_SECRET,
TRUST_PROXY: 'true',
TRUST_PROXY_CIDRS: '172.20.0.0/16',
})
assert.equal(ok.NODE_ENV, 'production')
console.log('production-config tests passed')
+46
View File
@@ -0,0 +1,46 @@
import 'reflect-metadata'
import assert from 'node:assert/strict'
import { HttpException, HttpStatus } from '@nestjs/common'
import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter'
const filter = new ApiExceptionFilter()
function runFilter(exception: unknown): { status: number; body: { error: string } } {
let status = 0
let body: { error: string } = { error: '' }
const host = {
switchToHttp: () => ({
getResponse: () => ({
status(code: number) {
status = code
return {
send(payload: { error: string }) {
body = payload
},
}
},
}),
}),
}
filter.catch(exception, host as never)
return { status, body }
}
const prev = process.env.NODE_ENV
process.env.NODE_ENV = 'production'
const internal = runFilter(
new Error('SqliteError: constraint at /secret/path/app.db\n at foo.ts:1:1'),
)
assert.equal(internal.status, HttpStatus.INTERNAL_SERVER_ERROR)
assert.equal(internal.body.error, 'خطای سرور')
assert.ok(!JSON.stringify(internal.body).includes('app.db'))
const httpErr = runFilter(
new HttpException('SQLITE_BUSY: database is locked', HttpStatus.BAD_REQUEST),
)
assert.equal(httpErr.body.error, 'درخواست نامعتبر است')
process.env.NODE_ENV = prev
console.log('production-error tests passed')
+1 -1
View File
@@ -139,7 +139,7 @@ async function runTests() {
let items = progression.buildLevelItems(TEST_USER)
const playable = getAllPlayableLessons()
assert.equal(playable.length, 6)
assert.equal(playable.length, 7)
for (const lesson of playable) {
assert.equal(countGradableQuestions(lesson), 5, `${lesson.id} should have 5 gradable questions`)
}
+81
View File
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import Database from 'better-sqlite3'
import { up, migrationId } from '../src/database/migrations/001-initial-schema'
const backendRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
const root = mkdtempSync(join(tmpdir(), 'cgl-restore-safety-'))
function runRestore(backup: string, target: string, env: Record<string, string> = {}) {
return spawnSync('node', ['scripts/sqlite-restore.mjs', backup, target], {
cwd: backendRoot,
env: { ...process.env, ...env },
encoding: 'utf8',
})
}
function seedAppDb(path: string) {
const db = new Database(path)
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
);
`)
db.exec(up)
db.prepare('INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)').run(
migrationId,
new Date().toISOString(),
)
db.close()
}
try {
const goodBackup = join(root, 'good.db')
seedAppDb(goodBackup)
const wrongSchema = join(root, 'wrong.db')
const w = new Database(wrongSchema)
w.exec('CREATE TABLE users (id TEXT PRIMARY KEY);')
w.close()
writeFileSync(join(root, 'corrupt.db'), 'not-a-sqlite-file')
let r = runRestore(join(root, 'corrupt.db'), join(root, 't1.db'))
assert.notEqual(r.status, 0)
r = runRestore(wrongSchema, join(root, 't2.db'))
assert.notEqual(r.status, 0)
const existing = join(root, 'existing.db')
seedAppDb(existing)
const before = readFileSync(existing)
r = runRestore(goodBackup, existing, {})
assert.notEqual(r.status, 0)
assert.deepEqual(readFileSync(existing), before, 'failed restore must not change target')
const freshTarget = join(root, 'fresh.db')
r = runRestore(goodBackup, freshTarget, { RESTORE_ACK_STOPPED: 'YES' })
assert.equal(r.status, 0, r.stderr || r.stdout)
assert.ok(existsSync(freshTarget))
const liveConfigured = join(root, 'live-configured.db')
seedAppDb(liveConfigured)
r = runRestore(goodBackup, liveConfigured, {
DB_PATH: liveConfigured,
RESTORE_ACK_STOPPED: 'YES',
})
assert.notEqual(r.status, 0, 'must refuse restore onto configured live DB_PATH')
} finally {
try {
rmSync(root, { recursive: true, force: true, maxRetries: 8, retryDelay: 200 })
} catch {
// Windows may briefly lock SQLite files after close.
}
}
console.log('restore-safety tests passed')
+166
View File
@@ -0,0 +1,166 @@
/**
* Risk-return challenge — temporary SQLite only.
*/
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 { gradeRiskReturnSubmission } from '../src/gold-land/risk-return/risk-return.grader'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'risk-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-risk-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3995'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
const correct = [
{ questionId: 'q-loss', answerId: 'b' },
{ questionId: 'q-certain', answerId: 'no' },
{ questionId: 'q-need', answerId: 'keep' },
{ questionId: 'q-compare', answerId: 'range' },
{ questionId: 'q-promise', answerId: 'check' },
]
const wrongIds = ['a', 'yes', 'a', 'certain-gain', 'trust']
function withCorrectCount(count: number) {
return correct.map((answer, index) =>
index < count ? answer : { questionId: answer.questionId, answerId: wrongIds[index] ?? 'a' },
)
}
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
}
async function run() {
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
assert.equal(gradeRiskReturnSubmission(withCorrectCount(0)).score, 0)
assert.equal(gradeRiskReturnSubmission(withCorrectCount(2)).score, 40)
assert.equal(gradeRiskReturnSubmission(withCorrectCount(3)).score, 60)
assert.equal(gradeRiskReturnSubmission(withCorrectCount(4)).score, 80)
const perfect = gradeRiskReturnSubmission(correct)
const perfectAgain = gradeRiskReturnSubmission([...correct].reverse())
assert.equal(perfect.score, 100)
assert.equal(perfectAgain.score, 100)
assert.equal(perfect.feedback.summary.includes('تضمین سود نیست'), true)
assert.throws(() => gradeRiskReturnSubmission([...correct, correct[0]!]))
assert.throws(() =>
gradeRiskReturnSubmission(correct.map((answer) => ({ ...answer, orderedItemIds: ['x'] }))),
)
assert.throws(() =>
gradeRiskReturnSubmission(correct.map((answer, index) => (index === 0 ? { ...answer, questionId: 'nope' } : answer))),
)
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
USER,
JSON.stringify(createDefaultUserState(USER)),
now,
)
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 (?, ?, 80, 2, 1, ?, ?, ?)
`)
for (let id = 1; id <= 6; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 8))
insert.run(USER, 7, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 8)?.status, 'available')
const detail = progression.getGoldLandLevel(USER, 8)
const payload = JSON.stringify(detail.activity)
assert.equal(payload.includes('correctOptionId'), false)
assert.equal(payload.includes('[20,20,22,22]') || payload.includes('20,20,22,22') || payload.includes('22'), true)
assert.equal(payload.includes('8,16,28,36') || payload.includes('[8,16,28,36]'), true)
assert.throws(() => progression.completeLevel(USER, 8, { answers: withCorrectCount(2) }))
const first = progression.completeLevel(USER, 8, { answers: withCorrectCount(3) })
assert.equal(first.score, 60)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 50 + 5)
assert.equal(first.reward.coins, 20 + 3)
const replay = progression.completeLevel(USER, 8, { answers: withCorrectCount(3) })
assert.equal(replay.reward.xp, 0)
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 8, { answers: correct })
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 9)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 10)?.status, 'locked')
const parallelId = 'risk-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
parallelId,
JSON.stringify(createDefaultUserState(parallelId)),
now,
)
for (let id = 1; id <= 7; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const [left, right] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/gold-land/levels/8/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: correct },
}),
app.inject({
method: 'POST',
url: '/api/gold-land/levels/8/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: correct },
}),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map(
(response) => JSON.parse(response.payload) as { reward: { xp: number; coins: number } },
)
assert.equal(bodies.reduce((sum, body) => sum + body.reward.coins, 0), 20 + 9)
assert.equal(bodies.reduce((sum, body) => sum + body.reward.xp, 0), 50 + 15)
const keys = db.prepare('SELECT reward_key FROM reward_ledger WHERE user_id = ? ORDER BY reward_key').all(parallelId) as Array<{ reward_key: string }>
assert.deepEqual(keys.map((row) => row.reward_key), [
'level:8:first-completion',
'level:8:star:1',
'level:8:star:2',
'level:8:star:3',
])
console.log('risk-return tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+138
View File
@@ -0,0 +1,138 @@
/**
* Smart shop — temporary SQLite only.
*/
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 { finalCost } from '../src/gold-land/smart-shop/shop.math'
import { gradeSmartShopSubmission } from '../src/gold-land/smart-shop/shop.grader'
import { smartShopMission } from '../src/gold-land/smart-shop/shop.mission'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'shop-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-shop-'))
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'
const correct = [
{ questionId: 'notebook-choice', answerId: 'b' },
{ questionId: 'notebook-why', answerId: 'fits-and-cheaper' },
{ questionId: 'discount-choice', answerId: 'b' },
{ questionId: 'discount-why', answerId: 'pay-amount' },
{ questionId: 'info-choice', answerId: 'ask' },
{ questionId: 'info-why', answerId: 'incomplete' },
]
const wrong = ['a', 'cheapest-tag', 'a', 'bigger-discount', 'buy-now', 'hurry']
function withCount(count: number) {
return correct.map((answer, index) => (index < count ? answer : { questionId: answer.questionId, answerId: wrong[index]! }))
}
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
}
async function run() {
assert.equal(finalCost({ price: 40, delivery: 5 }), 45)
assert.equal(finalCost({ price: 38, delivery: 12 }), 50)
assert.equal(finalCost({ price: 30, delivery: 0 }), 30)
assert.equal(finalCost({ price: 28, delivery: 0 }), 28)
assert.equal(finalCost({ price: 45, delivery: null }), null)
assert.notEqual(finalCost({ price: 45, delivery: null }), 0)
const scores = [0, 1, 2, 3, 4, 5, 6].map((count) => gradeSmartShopSubmission(withCount(count)).score)
assert.deepEqual(scores, [0, 17, 33, 50, 67, 83, 100])
assert.throws(() => gradeSmartShopSubmission(correct.slice(0, 5)))
assert.throws(() => gradeSmartShopSubmission([...correct, correct[0]!]))
assert.throws(() => gradeSmartShopSubmission(correct.map((answer, index) => (index === 0 ? { ...answer, answerId: 'ask' } : answer))))
assert.throws(() => gradeSmartShopSubmission(correct.map((answer) => ({ ...answer, price: 1 }))))
const payload = JSON.stringify(smartShopMission)
assert.equal(payload.includes('correctAnswerId'), false)
assert.equal(payload.includes('"explanation"'), false)
const info = smartShopMission.missions[2]?.cards[0]
assert.equal(info?.delivery, null)
assert.equal(info?.finalCost, null)
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(USER, JSON.stringify(createDefaultUserState(USER)), now)
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 (?, ?, 80, 2, 1, ?, ?, ?)
`)
for (let id = 1; id <= 9; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 11))
insert.run(USER, 10, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 11)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 12)?.status, 'locked')
assert.throws(() => progression.completeLevel(USER, 11, { answers: withCount(3) }))
const first = progression.completeLevel(USER, 11, { answers: withCount(4) })
assert.equal(first.score, 67)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 45 + 5)
assert.equal(first.reward.coins, 18 + 3)
const replay = progression.completeLevel(USER, 11, { answers: withCount(4) })
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 11, { answers: correct })
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 12)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 13)?.status, 'locked')
const parallelId = 'shop-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(parallelId, JSON.stringify(createDefaultUserState(parallelId)), now)
for (let id = 1; id <= 10; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const [left, right] = await Promise.all([
app.inject({ method: 'POST', url: '/api/gold-land/levels/11/complete', headers: { authorization: `Bearer ${token}` }, payload: { answers: correct } }),
app.inject({ method: 'POST', url: '/api/gold-land/levels/11/complete', headers: { authorization: `Bearer ${token}` }, payload: { answers: correct } }),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map((response) => JSON.parse(response.payload) as { reward: { coins: number; xp: number } })
assert.equal(bodies.reduce((sum, body) => sum + body.reward.coins, 0), 18 + 9)
assert.equal(bodies.reduce((sum, body) => sum + body.reward.xp, 0), 45 + 15)
const bad = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/11/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: [{ questionId: 'notebook-choice', answerId: 'b', score: 100 }] },
})
assert.equal(bad.statusCode, 400)
console.log('smart-shop tests passed')
} finally {
await app.close()
try { rmSync(testDir, { recursive: true, force: true }) } catch { /* ignore */ }
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})

Some files were not shown because too many files have changed in this diff Show More