Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
866be22395 | ||
|
|
dfb20ca92c | ||
|
|
5e5a14030a | ||
|
|
2c5aa01a9a | ||
|
|
1261a9b482 | ||
|
|
e42386a5ee | ||
|
|
d512863aa7 |
@@ -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
|
||||||
@@ -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
@@ -1,13 +1,19 @@
|
|||||||
# Server — production cutover uses PORT=3000 (replaces legacy Fastify)
|
# Server — production trial uses PORT=3000
|
||||||
PORT=3000
|
PORT=3000
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# CORS (Vue dev server)
|
# CORS — exact browser origin(s); comma-separated for multiple trial hosts
|
||||||
CORS_ORIGIN=http://localhost:5173
|
CORS_ORIGIN=http://localhost:5174
|
||||||
|
|
||||||
# SQLite — production database
|
# SQLite — persistent path (container: /app/data/app.db)
|
||||||
# DB_PATH=E:/absolute/path/to/backend-nest/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
|
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
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,15 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
.idea/
|
||||||
data/
|
data/
|
||||||
.env
|
.env
|
||||||
|
.env.docker
|
||||||
*.db
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
*.tsbuildinfo
|
||||||
|
backups/
|
||||||
|
docker-data/
|
||||||
|
data/*.db
|
||||||
|
data/*.db-wal
|
||||||
|
data/*.db-shm
|
||||||
|
|||||||
+53
@@ -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"]
|
||||||
@@ -6,23 +6,111 @@ Legacy Fastify `backend/` was removed in G4 (2026-08-01).
|
|||||||
|
|
||||||
## Requirements
|
## 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
|
## 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` |
|
| `PORT` | `3000` |
|
||||||
| `DB_PATH` | `backend-nest/data/app.db` |
|
| `CORS_ORIGIN` | `http://localhost:5174` (Vite dev server) |
|
||||||
| `JWT_SECRET` | Same as legacy Fastify |
|
| `DB_PATH` | optional; defaults to `backend-nest/data/app.db` |
|
||||||
| `CORS_ORIGIN` | `http://localhost:5173` |
|
| `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
|
```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
|
## Production
|
||||||
@@ -32,10 +120,43 @@ npm run build
|
|||||||
npm run start
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Set `NODE_ENV=production` and a strong `JWT_SECRET` via environment or `.env` on the server.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
Uses **temporary SQLite files only** — not `data/app.db`.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm run type-check
|
npm run test
|
||||||
npm run test:progression
|
```
|
||||||
npm run test:integration
|
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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 است.
|
||||||
@@ -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 و ۱۸ سکه است.
|
||||||
@@ -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 }` است. مبلغ، تخفیف، امتیاز و پاداش کلاینت پذیرفته نمیشود.
|
||||||
@@ -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 }`. زمان و موجودی کلاینت پذیرفته نمیشود.
|
||||||
@@ -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 عمومی نیست.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# مرحله ۷ — مأموریت بودجهبندی
|
||||||
|
|
||||||
|
- گروه سنی: ۹ تا ۱۲ سال.
|
||||||
|
- مدت هدف: حدود ۵ تا ۸ دقیقه، بدون محدودیت زمانی.
|
||||||
|
- لحن: فارسی ساده و صمیمی.
|
||||||
|
- راهنما: تصویر موجود `professor` با عنوان «راهنما». شخصیت جدید ساخته نشده است.
|
||||||
|
- نوع فعالیت: `game` با کلید `budgeting`. این مرحله درس پنجسؤالی نیست.
|
||||||
|
|
||||||
|
## هدف آموزشی
|
||||||
|
|
||||||
|
کودک ۱۰۰ سکهٔ آموزشی را بین نیاز سفر (رفتوآمد و آب و غذا)، هدف پسانداز، و تفریح اختیاری تقسیم میکند و بعد از یک تغییر مشخص، برنامه را اصلاح میکند. این سکهها از کیف پول کاربر کم نمیشوند.
|
||||||
|
|
||||||
|
## سناریو
|
||||||
|
|
||||||
|
- رفتوآمد: ابتدا حداقل ۲۰، بعد از اتفاق حداقل ۳۰.
|
||||||
|
- آب و غذا: حداقل ۲۵.
|
||||||
|
- پسانداز برای هدف: هدف ۲۰. حداقل ۱۰ برای بخشی از امتیاز.
|
||||||
|
- تفریح: اختیاری و بدون امتیاز اضافه.
|
||||||
|
- ذخیره: باقیماندهٔ تخصیصنشده. صفر بودن آن خطا نیست و تنها راه درست معرفی نمیشود.
|
||||||
|
- گام تغییر: ۵ سکه.
|
||||||
|
- اتفاق ثابت: «هزینه رفتوآمد ۱۰ سکه بیشتر شد؛ حالا ۳۰ سکه لازم داری.» معرفی فقط میگوید ممکن است شرایط تغییر کند.
|
||||||
|
|
||||||
|
## Rubric
|
||||||
|
|
||||||
|
هر کدام از برنامهٔ اولیه و برنامهٔ نهایی پنج معیار ۱۰امتیازی دارد. مجموع ۱۰۰ است.
|
||||||
|
|
||||||
|
1. مجموع تخصیص حداکثر ۱۰۰.
|
||||||
|
2. تأمین حداقل رفتوآمد همان مرحله.
|
||||||
|
3. تأمین آب و غذا.
|
||||||
|
4. حداقل ۱۰ سکه پسانداز.
|
||||||
|
5. رسیدن پسانداز به ۲۰.
|
||||||
|
|
||||||
|
ورودی منفی، غیرصحیح، خارج از گام ۵، مجموع بیش از ۱۰۰، یا فیلد ناشناخته با خطای ورودی رد میشود. برنامهٔ اول بدون حداقل ۲۰ رفتوآمد و ۲۵ آبوغذا، و برنامهٔ نهایی بدون حداقل ۳۰ رفتوآمد و ۲۵ آبوغذا، با خطای جداگانهٔ نیاز ضروری رد میشوند و امتیاز نمیگیرند. پسانداز، تفریح و ذخیره شرط تأیید نیستند.
|
||||||
|
|
||||||
|
پاداش پایهٔ ۴۵ XP و ۱۸ سکه و پاداش هر ستارهٔ تازه (۵ XP و ۳ سکه) فقط از سرور و دفتر پاداش است. امتیاز زیر ۶۰ تکمیل نمیشود. مرحلهٔ ۸ غیرفعال میماند.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# مرحله ۸ — ریسک و بازده
|
||||||
|
|
||||||
|
- گروه سنی: ۹ تا ۱۲ سال.
|
||||||
|
- مدت هدف: حدود ۵ تا ۸ دقیقه، بدون محدودیت زمانی.
|
||||||
|
- راهنما: تصویر موجود `professor`.
|
||||||
|
- نوع: `challenge` با کلید `risk-return`.
|
||||||
|
|
||||||
|
## هدف آموزشی
|
||||||
|
|
||||||
|
کودک تفاوت نتیجهٔ ممکن و نتیجهٔ تضمینی را میفهمد، احتمال زیان را تشخیص میدهد و انتخاب را با هدف و زمان نیاز به پول هماهنگ میکند. امتیاز برای فهم مفهوم و دلیل انتخاب است، نه برای انتخاب پرریسک یا سود اتفاقی.
|
||||||
|
|
||||||
|
## مثال ساختگی
|
||||||
|
|
||||||
|
برای ۲۰ سکهٔ آموزشی که از کیف پول بازی کم نمیشود:
|
||||||
|
|
||||||
|
- پیشنهاد الف، چهار پیامد هماحتمال: ۲۰، ۲۰، ۲۲، ۲۲.
|
||||||
|
- پیشنهاد ب، چهار پیامد هماحتمال: ۸، ۱۶، ۲۸، ۳۶.
|
||||||
|
|
||||||
|
در این مثال هر کارت شانس یکسان دارد. در دنیای واقعی همیشه احتمالها را دقیق نمیدانیم. نبود زیان اسمی در پیشنهاد الف فقط مربوط به همین مثال است و تضمین عمومی سرمایهگذاری نیست. هیچ دارایی واقعی توصیه نمیشود.
|
||||||
|
|
||||||
|
## Rubric
|
||||||
|
|
||||||
|
پنج فعالیت، هر کدام ۲۰ امتیاز. جمع ۱۰۰.
|
||||||
|
|
||||||
|
1. امکان برگشت کمتر از ۲۰: پیشنهاد ب.
|
||||||
|
2. دریافت قطعی ۳۶ در پیشنهاد ب: خیر.
|
||||||
|
3. نیاز ضروری تا دو روز دیگر: پول را وارد پیشنهادهای نامطمئن نکن.
|
||||||
|
4. مقایسه: پیشنهاد ب امکان نتیجهٔ بالاتر و زیان بیشتر دارد؛ سود بیشتر قطعی نیست.
|
||||||
|
5. وعدهٔ سود زیاد بدون ریسک: تضمین معتبر فرض نکن، بررسی کن و از بزرگسال قابل اعتماد کمک بگیر.
|
||||||
|
|
||||||
|
امتیاز به قرعه وابسته نیست. پاسخ درست در محتوای عمومی سؤال نیست. پاداش پایه ۵۰ XP و ۲۰ سکه است. مرحلهٔ ۹ غیرفعال میماند.
|
||||||
@@ -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`.
|
||||||
@@ -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
@@ -8,12 +8,30 @@
|
|||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"start:prod": "node dist/main.js",
|
"start:prod": "node dist/main.js",
|
||||||
"type-check": "tsc --noEmit -p tsconfig.json",
|
"type-check": "tsc --noEmit -p tsconfig.json",
|
||||||
"test:integration": "tsx test/integration.test.ts",
|
"test:integration": "tsx --tsconfig tsconfig.json test/integration.test.ts",
|
||||||
"test:progression": "tsx test/progression.test.ts",
|
"test:progression": "tsx --tsconfig tsconfig.json test/progression.test.ts",
|
||||||
"test:cutover:g1a": "tsx ../scripts/cutover/run-g1a.ts",
|
"test:concurrency": "tsx --tsconfig tsconfig.json test/concurrency.test.ts",
|
||||||
"test:cutover:g1b": "tsx ../scripts/cutover/run-g1b.ts",
|
"test:gold-precision": "tsx --tsconfig tsconfig.json test/gold-precision.test.ts",
|
||||||
"test:cutover:g1b2b": "tsx ../scripts/cutover/run-g1b2b.ts",
|
"test:lesson-rewards": "tsx --tsconfig tsconfig.json test/lesson-rewards-api.test.ts",
|
||||||
"test:cutover:g1b:dry-run": "tsx ../scripts/cutover/g1b-dry-run.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": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.12",
|
"@nestjs/common": "^11.0.12",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
@@ -8,6 +8,10 @@ export class AuthSeedService implements OnModuleInit {
|
|||||||
constructor(@Inject(forwardRef(() => AuthService)) private readonly auth: AuthService) {}
|
constructor(@Inject(forwardRef(() => AuthService)) private readonly auth: AuthService) {}
|
||||||
|
|
||||||
onModuleInit(): void {
|
onModuleInit(): void {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
this.logger.log('Auth seed skipped in production (no demo account)')
|
||||||
|
return
|
||||||
|
}
|
||||||
this.auth.seedDemoAccount()
|
this.auth.seedDemoAccount()
|
||||||
this.logger.log('Auth seed completed (demo account if missing)')
|
this.logger.log('Auth seed completed (demo account if missing)')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 { strictBodyPipe } from '../common/pipes/strict-body.pipe'
|
||||||
import { AuthService } from './auth.service'
|
import { AuthService } from './auth.service'
|
||||||
import { LoginDto } from './dto/login.dto'
|
import { LoginDto } from './dto/login.dto'
|
||||||
@@ -12,14 +15,16 @@ export class AuthController {
|
|||||||
@Public()
|
@Public()
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@Post('register')
|
@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)
|
return this.auth.register(dto.username, dto.password, dto.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@Post('login')
|
@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)
|
return this.auth.login(dto.username, dto.password)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,14 +99,29 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
seedDemoAccount(): void {
|
seedDemoAccount(): void {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const demoPasswordHash = bcrypt.hashSync('123456', 10)
|
||||||
const demo = this.findAccountByUsername('demo')
|
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')
|
this.users.ensureUser('demo')
|
||||||
const account: AuthAccount = {
|
const account: AuthAccount = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
username: 'demo',
|
username: 'demo',
|
||||||
passwordHash: bcrypt.hashSync('123456', 10),
|
passwordHash: demoPasswordHash,
|
||||||
userId: 'demo',
|
userId: 'demo',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
import type { FastifyReply } from 'fastify'
|
import type { FastifyReply } from 'fastify'
|
||||||
|
import { safeErrorMessage } from '../http/safe-log'
|
||||||
|
|
||||||
interface ApiErrorBody {
|
interface ApiErrorBody {
|
||||||
error: string
|
error: string
|
||||||
@@ -19,43 +20,53 @@ export class ApiExceptionFilter implements ExceptionFilter {
|
|||||||
catch(exception: unknown, host: ArgumentsHost): void {
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
const ctx = host.switchToHttp()
|
const ctx = host.switchToHttp()
|
||||||
const reply = ctx.getResponse<FastifyReply>()
|
const reply = ctx.getResponse<FastifyReply>()
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
if (exception instanceof HttpException) {
|
if (exception instanceof HttpException) {
|
||||||
const status = exception.getStatus()
|
const status = exception.getStatus()
|
||||||
const message = this.extractMessage(exception)
|
const message = this.extractMessage(exception, isProduction)
|
||||||
reply.status(status).send({ error: message } satisfies ApiErrorBody)
|
reply.status(status).send({ error: message } satisfies ApiErrorBody)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error(exception)
|
this.logger.error(`Unhandled error: ${safeErrorMessage(exception)}`)
|
||||||
reply.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
|
reply.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
|
||||||
error: 'خطای سرور',
|
error: 'خطای سرور',
|
||||||
} satisfies ApiErrorBody)
|
} satisfies ApiErrorBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractMessage(exception: HttpException): string {
|
private extractMessage(exception: HttpException, isProduction: boolean): string {
|
||||||
const response = exception.getResponse()
|
const response = exception.getResponse()
|
||||||
|
|
||||||
if (typeof response === 'string') {
|
if (typeof response === 'string') {
|
||||||
return response
|
return isProduction ? this.sanitizeClientMessage(response) : response
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof response === 'object' && response !== null) {
|
if (typeof response === 'object' && response !== null) {
|
||||||
const body = response as Record<string, unknown>
|
const body = response as Record<string, unknown>
|
||||||
|
|
||||||
if (typeof body.message === 'string') {
|
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) {
|
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') {
|
if (typeof body.error === 'string') {
|
||||||
return body.error
|
return isProduction ? this.sanitizeClientMessage(body.error) : body.error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'درخواست نامعتبر است'
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import { plainToInstance } from 'class-transformer'
|
import { plainToInstance } from 'class-transformer'
|
||||||
import { IsIn, IsInt, IsOptional, IsString, Min, validateSync } from 'class-validator'
|
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 {
|
export class EnvironmentVariables {
|
||||||
@IsIn(['development', 'production', 'test'])
|
@IsIn(['development', 'production', 'test'])
|
||||||
NODE_ENV!: 'development' | 'production' | 'test'
|
NODE_ENV!: 'development' | 'production' | 'test'
|
||||||
@@ -19,17 +28,50 @@ export class EnvironmentVariables {
|
|||||||
@IsString()
|
@IsString()
|
||||||
DB_PATH?: string
|
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()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
JWT_SECRET?: string
|
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 {
|
export function validateEnv(config: Record<string, unknown>): EnvironmentVariables {
|
||||||
const nodeEnv = (config.NODE_ENV as string | undefined) ?? 'development'
|
const nodeEnv = (config.NODE_ENV as string | undefined) ?? 'development'
|
||||||
const jwtSecret = config.JWT_SECRET as string | undefined
|
const jwtSecret = config.JWT_SECRET as string | undefined
|
||||||
|
|
||||||
if (nodeEnv === 'production' && (!jwtSecret || jwtSecret.trim().length === 0)) {
|
const trustProxyEnabled =
|
||||||
throw new Error('JWT_SECRET is required when NODE_ENV=production')
|
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) {
|
if (nodeEnv !== 'production' && !jwtSecret) {
|
||||||
@@ -40,11 +82,12 @@ export function validateEnv(config: Record<string, unknown>): EnvironmentVariabl
|
|||||||
|
|
||||||
const normalized = {
|
const normalized = {
|
||||||
NODE_ENV: nodeEnv,
|
NODE_ENV: nodeEnv,
|
||||||
PORT: Number(config.PORT ?? 3001),
|
PORT: Number(config.PORT ?? 3000),
|
||||||
HOST: (config.HOST as string | undefined) ?? '0.0.0.0',
|
HOST: (config.HOST as string | undefined) ?? '0.0.0.0',
|
||||||
CORS_ORIGIN: (config.CORS_ORIGIN as string | undefined) ?? 'http://localhost:5173',
|
CORS_ORIGIN: (config.CORS_ORIGIN as string | undefined) ?? 'http://localhost:5174',
|
||||||
DB_PATH: config.DB_PATH as string | undefined,
|
DB_PATH: (config.DB_PATH ?? config.DATABASE_PATH) as string | undefined,
|
||||||
JWT_SECRET: jwtSecret ?? 'gold-land-dev-secret-change-in-production',
|
JWT_SECRET: jwtSecret ?? DEV_JWT_FALLBACK,
|
||||||
|
TRUST_PROXY: (config.TRUST_PROXY as string | undefined) ?? 'false',
|
||||||
}
|
}
|
||||||
|
|
||||||
const validated = plainToInstance(EnvironmentVariables, normalized, {
|
const validated = plainToInstance(EnvironmentVariables, normalized, {
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export class DatabaseService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly dbPath: string
|
private readonly dbPath: string
|
||||||
|
|
||||||
constructor(@Optional() @Inject(ConfigService) private readonly config?: ConfigService) {
|
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')
|
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 })
|
mkdirSync(dirname(this.dbPath), { recursive: true })
|
||||||
this.db = new Database(this.dbPath)
|
this.db = new Database(this.dbPath)
|
||||||
this.db.pragma('journal_mode = WAL')
|
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}`)
|
this.logger.log(`SQLite ready at ${this.dbPath}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,8 +58,9 @@ export class DatabaseService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return this.db
|
return this.db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** IMMEDIATE acquires the write lock before reads — avoids lost updates on state_json RMW. */
|
||||||
transaction<T>(fn: () => T): T {
|
transaction<T>(fn: () => T): T {
|
||||||
return this.getDb().transaction(fn)()
|
return this.getDb().transaction(fn).immediate()
|
||||||
}
|
}
|
||||||
|
|
||||||
verifyMigrationsApplied(): { ok: boolean; tables: string[]; missing: string[] } {
|
verifyMigrationsApplied(): { ok: boolean; tables: string[]; missing: string[] } {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,55 +1,17 @@
|
|||||||
import type Database from 'better-sqlite3'
|
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). */
|
/** Apply all pending versioned SQL migrations (safe to call on every startup). */
|
||||||
export function runSchemaMigrations(db: Database.Database): void {
|
export function runSchemaMigrations(db: Database.Database): string[] {
|
||||||
db.exec(`
|
return runPendingMigrations(db)
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
state_json TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_level_progress (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
level_id INTEGER NOT NULL,
|
|
||||||
best_score INTEGER NOT NULL DEFAULT 0,
|
|
||||||
stars INTEGER NOT NULL DEFAULT 0,
|
|
||||||
attempts_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
started_at TEXT,
|
|
||||||
completed_at TEXT,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
UNIQUE(user_id, level_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS reward_ledger (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
source_type TEXT NOT NULL,
|
|
||||||
source_id TEXT NOT NULL,
|
|
||||||
reward_key TEXT NOT NULL,
|
|
||||||
xp_delta INTEGER NOT NULL DEFAULT 0,
|
|
||||||
coins_delta INTEGER NOT NULL DEFAULT 0,
|
|
||||||
metadata_json TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
UNIQUE(user_id, reward_key)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_level_progress_user
|
|
||||||
ON user_level_progress(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_reward_ledger_user
|
|
||||||
ON reward_ledger(user_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS auth_accounts (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
username TEXT NOT NULL UNIQUE,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { listAppliedMigrationIds, listPendingMigrationIds }
|
||||||
|
|
||||||
export const EXPECTED_TABLES = [
|
export const EXPECTED_TABLES = [
|
||||||
'users',
|
'users',
|
||||||
'user_level_progress',
|
'user_level_progress',
|
||||||
@@ -61,5 +23,7 @@ export function listUserTables(db: Database.Database): string[] {
|
|||||||
const rows = db
|
const rows = db
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")
|
||||||
.all() as Array<{ name: string }>
|
.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
|
||||||
|
);
|
||||||
|
`
|
||||||
@@ -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 },
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 =
|
||||||
|
'آفرین! مسیر آموزشی قلعه را کامل کردی. میتوانی برای مرور و بهترشدن دوباره بازی کنی.'
|
||||||
@@ -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
|
||||||
|
}>
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Type } from 'class-transformer'
|
import { Type } from 'class-transformer'
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsInt,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@@ -8,6 +9,52 @@ import {
|
|||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator'
|
} 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 {
|
export class LessonAnswerDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
questionId!: string
|
questionId!: string
|
||||||
@@ -20,6 +67,21 @@ export class LessonAnswerDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
orderedItemIds?: string[]
|
orderedItemIds?: string[]
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => BudgetAllocationDto)
|
||||||
|
allocations?: BudgetAllocationDto
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => PortfolioHoldingDto)
|
||||||
|
holdings?: PortfolioHoldingDto
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => GoalPlanDto)
|
||||||
|
plan?: GoalPlanDto
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CompleteLevelDto {
|
export class CompleteLevelDto {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }>
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import { CurrentUser } from '../common/decorators/current-user.decorator'
|
|||||||
import { strictBodyPipe } from '../common/pipes/strict-body.pipe'
|
import { strictBodyPipe } from '../common/pipes/strict-body.pipe'
|
||||||
import { GOLD_LAND_LEVELS } from './levels/levels'
|
import { GOLD_LAND_LEVELS } from './levels/levels'
|
||||||
import { CompleteLevelDto } from './dto/complete-level.dto'
|
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'
|
import { GoldLandProgressionService } from './services/progression.service'
|
||||||
|
|
||||||
@Controller('api/gold-land')
|
@Controller('api/gold-land')
|
||||||
@@ -80,7 +80,7 @@ export class GoldLandController {
|
|||||||
@Body(strictBodyPipe(CompleteLevelDto)) dto: CompleteLevelDto,
|
@Body(strictBodyPipe(CompleteLevelDto)) dto: CompleteLevelDto,
|
||||||
) {
|
) {
|
||||||
return this.progression.completeLevel(userId, levelId, {
|
return this.progression.completeLevel(userId, levelId, {
|
||||||
answers: dto.answers as LessonSubmissionAnswer[],
|
answers: dto.answers as LevelSubmissionAnswer[],
|
||||||
durationMs: dto.durationMs,
|
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: 'رشد روی موجودی جدید، تضمین سود واقعی نیست.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -121,7 +121,7 @@ export function gradeLessonSubmission(
|
|||||||
}
|
}
|
||||||
validateSortAnswer(step, submitted.orderedItemIds)
|
validateSortAnswer(step, submitted.orderedItemIds)
|
||||||
} else {
|
} else {
|
||||||
if (!isChoiceLessonAnswer(submitted)) {
|
if (!isChoiceLessonAnswer(submitted) || ('orderedItemIds' in submitted && submitted.orderedItemIds != null)) {
|
||||||
throw new LessonGradingError(400, `فرمت پاسخ برای سؤال ${questionId} نامعتبر است`)
|
throw new LessonGradingError(400, `فرمت پاسخ برای سؤال ${questionId} نامعتبر است`)
|
||||||
}
|
}
|
||||||
validateChoiceAnswer(step, submitted.answerId)
|
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) {
|
for (const answer of answers) {
|
||||||
const known = gradable.some((g) => g.questionId === answer.questionId)
|
const known = gradable.some((g) => g.questionId === answer.questionId)
|
||||||
if (!known) {
|
if (!known) {
|
||||||
@@ -142,5 +152,12 @@ export function gradeLessonSubmission(
|
|||||||
const totalQuestions = gradable.length
|
const totalQuestions = gradable.length
|
||||||
const score = Math.round((correctCount / totalQuestions) * 100)
|
const score = Math.round((correctCount / totalQuestions) * 100)
|
||||||
|
|
||||||
return { score, correctCount, totalQuestions }
|
return {
|
||||||
|
score,
|
||||||
|
correctCount,
|
||||||
|
totalQuestions,
|
||||||
|
...(feedbackQuestions.length === gradable.length
|
||||||
|
? { feedback: { questions: feedbackQuestions } }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { LessonDefinition } from './lesson.types.js'
|
import type { LessonDefinition } from './lesson.types.js'
|
||||||
|
import { compoundInterestLesson } from './compound-interest.lesson.js'
|
||||||
import { inflationLesson } from './inflation.lesson.js'
|
import { inflationLesson } from './inflation.lesson.js'
|
||||||
import { whatIsMoneyLesson } from './what-is-money.lesson.js'
|
import { whatIsMoneyLesson } from './what-is-money.lesson.js'
|
||||||
import { savingLesson } from './saving.lesson.js'
|
import { savingLesson } from './saving.lesson.js'
|
||||||
@@ -14,6 +15,7 @@ const PLAYABLE_LESSONS: Record<string, LessonDefinition> = {
|
|||||||
'what-is-gold': whatIsGoldLesson,
|
'what-is-gold': whatIsGoldLesson,
|
||||||
'gold-market': goldMarketLesson,
|
'gold-market': goldMarketLesson,
|
||||||
inflation: inflationLesson,
|
inflation: inflationLesson,
|
||||||
|
'compound-interest': compoundInterestLesson,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPlayableLesson(activityKey: string): LessonDefinition | null {
|
export function getPlayableLesson(activityKey: string): LessonDefinition | null {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const STRIP_KEYS = new Set([
|
|||||||
'correctOption',
|
'correctOption',
|
||||||
'answerKey',
|
'answerKey',
|
||||||
'correctOrder',
|
'correctOrder',
|
||||||
|
'explanation',
|
||||||
])
|
])
|
||||||
|
|
||||||
function sanitizeStep(step: LessonStep): SanitizedLessonStep {
|
function sanitizeStep(step: LessonStep): SanitizedLessonStep {
|
||||||
|
|||||||
@@ -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 {
|
export interface LessonOption {
|
||||||
id: string
|
id: string
|
||||||
@@ -22,12 +22,38 @@ export interface SingleChoiceStep extends LessonStepBase {
|
|||||||
question: string
|
question: string
|
||||||
options: LessonOption[]
|
options: LessonOption[]
|
||||||
correctAnswerId: string
|
correctAnswerId: string
|
||||||
|
explanation?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrueFalseStep extends LessonStepBase {
|
export interface TrueFalseStep extends LessonStepBase {
|
||||||
type: 'true_false'
|
type: 'true_false'
|
||||||
question: string
|
question: string
|
||||||
correctAnswerId: 'true' | 'false'
|
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 {
|
export interface SortStep extends LessonStepBase {
|
||||||
@@ -49,6 +75,7 @@ export type LessonStep =
|
|||||||
| TrueFalseStep
|
| TrueFalseStep
|
||||||
| SortStep
|
| SortStep
|
||||||
| SummaryStep
|
| SummaryStep
|
||||||
|
| GrowthTableStep
|
||||||
|
|
||||||
export interface LessonDefinition {
|
export interface LessonDefinition {
|
||||||
id: string
|
id: string
|
||||||
@@ -76,10 +103,17 @@ export interface SortLessonAnswer {
|
|||||||
|
|
||||||
export type LessonSubmissionAnswer = ChoiceLessonAnswer | SortLessonAnswer
|
export type LessonSubmissionAnswer = ChoiceLessonAnswer | SortLessonAnswer
|
||||||
|
|
||||||
|
export interface LessonAnswerFeedback {
|
||||||
|
id: string
|
||||||
|
earned: boolean
|
||||||
|
explanation: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface GradeLessonResult {
|
export interface GradeLessonResult {
|
||||||
score: number
|
score: number
|
||||||
correctCount: number
|
correctCount: number
|
||||||
totalQuestions: number
|
totalQuestions: number
|
||||||
|
feedback?: { questions: LessonAnswerFeedback[] }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SanitizedLessonStep =
|
export type SanitizedLessonStep =
|
||||||
@@ -88,6 +122,7 @@ export type SanitizedLessonStep =
|
|||||||
| Omit<TrueFalseStep, 'correctAnswerId'>
|
| Omit<TrueFalseStep, 'correctAnswerId'>
|
||||||
| Omit<SortStep, 'correctOrder'>
|
| Omit<SortStep, 'correctOrder'>
|
||||||
| Omit<SummaryStep, never>
|
| Omit<SummaryStep, never>
|
||||||
|
| GrowthTableStep
|
||||||
|
|
||||||
export interface SanitizedLessonDefinition {
|
export interface SanitizedLessonDefinition {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
import type { MeResponse } from '../../shared/types/user.types'
|
import type { MeResponse } from '../../shared/types/user.types'
|
||||||
import type { sanitizeLesson } from '../lessons/lesson.sanitizer'
|
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 =
|
export type LevelStatus =
|
||||||
| 'locked'
|
| 'locked'
|
||||||
@@ -21,7 +34,7 @@ export interface LevelProgressRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CompleteLevelSubmission {
|
export interface CompleteLevelSubmission {
|
||||||
answers: LessonSubmissionAnswer[]
|
answers: LevelSubmissionAnswer[]
|
||||||
durationMs?: number
|
durationMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +72,12 @@ export interface LevelDetailResponse {
|
|||||||
activity: {
|
activity: {
|
||||||
type: string
|
type: string
|
||||||
lesson?: ReturnType<typeof sanitizeLesson>
|
lesson?: ReturnType<typeof sanitizeLesson>
|
||||||
|
budgeting?: BudgetMissionPublic
|
||||||
|
riskReturn?: RiskReturnPublic
|
||||||
|
portfolio?: PortfolioMissionPublic
|
||||||
|
smartShop?: SmartShopPublic
|
||||||
|
financialGoal?: FinancialGoalPublic
|
||||||
|
castleExam?: CastleExamPublic
|
||||||
} | null
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,4 +91,12 @@ export interface CompleteLevelResponse {
|
|||||||
reward: { xp: number; coins: number }
|
reward: { xp: number; coins: number }
|
||||||
unlockedLevelId: number | null
|
unlockedLevelId: number | null
|
||||||
me: MeResponse
|
me: MeResponse
|
||||||
|
feedback?:
|
||||||
|
| BudgetMissionFeedback
|
||||||
|
| RiskReturnFeedback
|
||||||
|
| PortfolioFeedback
|
||||||
|
| SmartShopFeedback
|
||||||
|
| FinancialGoalFeedback
|
||||||
|
| CastleExamFeedback
|
||||||
|
| { questions: LessonAnswerFeedback[] }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
|
|||||||
activityKey: 'budgeting',
|
activityKey: 'budgeting',
|
||||||
rewardXp: 45,
|
rewardXp: 45,
|
||||||
rewardCoins: 18,
|
rewardCoins: 18,
|
||||||
isActive: false,
|
isActive: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 8,
|
id: 8,
|
||||||
@@ -111,7 +111,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
|
|||||||
activityKey: 'risk-return',
|
activityKey: 'risk-return',
|
||||||
rewardXp: 50,
|
rewardXp: 50,
|
||||||
rewardCoins: 20,
|
rewardCoins: 20,
|
||||||
isActive: false,
|
isActive: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 9,
|
id: 9,
|
||||||
@@ -123,7 +123,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
|
|||||||
activityKey: 'portfolio',
|
activityKey: 'portfolio',
|
||||||
rewardXp: 50,
|
rewardXp: 50,
|
||||||
rewardCoins: 20,
|
rewardCoins: 20,
|
||||||
isActive: false,
|
isActive: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 10,
|
id: 10,
|
||||||
@@ -135,7 +135,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
|
|||||||
activityKey: 'compound-interest',
|
activityKey: 'compound-interest',
|
||||||
rewardXp: 45,
|
rewardXp: 45,
|
||||||
rewardCoins: 18,
|
rewardCoins: 18,
|
||||||
isActive: false,
|
isActive: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 11,
|
id: 11,
|
||||||
@@ -147,7 +147,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
|
|||||||
activityKey: 'smart-shop',
|
activityKey: 'smart-shop',
|
||||||
rewardXp: 45,
|
rewardXp: 45,
|
||||||
rewardCoins: 18,
|
rewardCoins: 18,
|
||||||
isActive: false,
|
isActive: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 12,
|
id: 12,
|
||||||
@@ -159,7 +159,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
|
|||||||
activityKey: 'financial-goal',
|
activityKey: 'financial-goal',
|
||||||
rewardXp: 50,
|
rewardXp: 50,
|
||||||
rewardCoins: 20,
|
rewardCoins: 20,
|
||||||
isActive: false,
|
isActive: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 13,
|
id: 13,
|
||||||
@@ -171,7 +171,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
|
|||||||
activityKey: 'castle-exam',
|
activityKey: 'castle-exam',
|
||||||
rewardXp: 100,
|
rewardXp: 100,
|
||||||
rewardCoins: 50,
|
rewardCoins: 50,
|
||||||
isActive: false,
|
isActive: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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[]
|
||||||
|
}
|
||||||
@@ -16,6 +16,24 @@ import type {
|
|||||||
LevelProgressRow,
|
LevelProgressRow,
|
||||||
LevelStatus,
|
LevelStatus,
|
||||||
} from '../levels/level.types'
|
} 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 { loadLessonForLevel } from '../lessons/lesson.loader'
|
||||||
import {
|
import {
|
||||||
gradeLessonSubmission,
|
gradeLessonSubmission,
|
||||||
@@ -173,10 +191,12 @@ export class GoldLandProgressionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncUserProgressionState(userId: string): UserState {
|
syncUserProgressionState(userId: string): UserState {
|
||||||
const state = this.users.getUserState(userId)
|
return this.database.transaction(() => {
|
||||||
const items = this.buildLevelItems(userId)
|
const state = this.users.getUserState(userId)
|
||||||
this.syncLegacyMapLevels(state, items)
|
const items = this.buildLevelItems(userId)
|
||||||
return this.users.saveUserState(state)
|
this.syncLegacyMapLevels(state, items)
|
||||||
|
return this.users.saveUserState(state)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getGoldLandProgress(userId: string) {
|
getGoldLandProgress(userId: string) {
|
||||||
@@ -217,6 +237,60 @@ export class GoldLandProgressionService {
|
|||||||
lesson: stripSensitiveFields(sanitizeLesson(lesson)),
|
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({
|
return stripSensitiveFields({
|
||||||
@@ -238,38 +312,40 @@ export class GoldLandProgressionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startLevel(userId: string, levelId: number) {
|
startLevel(userId: string, levelId: number) {
|
||||||
this.assertLevelPlayable(userId, levelId)
|
return this.database.transaction(() => {
|
||||||
|
this.assertLevelPlayable(userId, levelId)
|
||||||
|
|
||||||
const definition = getLevelDefinition(levelId)
|
const definition = getLevelDefinition(levelId)
|
||||||
if (!definition?.isActive) {
|
if (!definition?.isActive) {
|
||||||
throw new ProgressionError(403, 'این مرحله بهزودی باز میشود')
|
throw new ProgressionError(403, 'این مرحله بهزودی باز میشود')
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const existing = this.getProgressRow(userId, levelId)
|
const existing = this.getProgressRow(userId, levelId)
|
||||||
const db = this.database.getDb()
|
const db = this.database.getDb()
|
||||||
|
|
||||||
if (existing?.completed_at) {
|
if (existing?.completed_at) {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE user_level_progress
|
UPDATE user_level_progress
|
||||||
SET attempts_count = attempts_count + 1, started_at = ?, updated_at = ?
|
SET attempts_count = attempts_count + 1, started_at = ?, updated_at = ?
|
||||||
WHERE user_id = ? AND level_id = ?
|
WHERE user_id = ? AND level_id = ?
|
||||||
`).run(now, now, userId, levelId)
|
`).run(now, now, userId, levelId)
|
||||||
} else if (existing) {
|
} else if (existing) {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE user_level_progress
|
UPDATE user_level_progress
|
||||||
SET started_at = COALESCE(started_at, ?), updated_at = ?
|
SET started_at = COALESCE(started_at, ?), updated_at = ?
|
||||||
WHERE user_id = ? AND level_id = ?
|
WHERE user_id = ? AND level_id = ?
|
||||||
`).run(now, now, userId, levelId)
|
`).run(now, now, userId, levelId)
|
||||||
} else {
|
} else {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO user_level_progress (
|
INSERT INTO user_level_progress (
|
||||||
user_id, level_id, best_score, stars, attempts_count, started_at, updated_at
|
user_id, level_id, best_score, stars, attempts_count, started_at, updated_at
|
||||||
) VALUES (?, ?, 0, 0, 1, ?, ?)
|
) VALUES (?, ?, 0, 0, 1, ?, ?)
|
||||||
`).run(userId, levelId, now, now)
|
`).run(userId, levelId, now, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { levelId, status: 'in_progress' as const }
|
return { levelId, status: 'in_progress' as const }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
completeLevel(
|
completeLevel(
|
||||||
@@ -284,22 +360,38 @@ export class GoldLandProgressionService {
|
|||||||
throw new ProgressionError(403, 'این مرحله هنوز آماده نیست')
|
throw new ProgressionError(403, 'این مرحله هنوز آماده نیست')
|
||||||
}
|
}
|
||||||
|
|
||||||
const score = this.gradeSubmission(levelId, submission)
|
const graded = this.gradeSubmission(levelId, submission)
|
||||||
|
const score = graded.score
|
||||||
const newStars = starsFromScore(score)
|
const newStars = starsFromScore(score)
|
||||||
if (newStars === 0) {
|
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(() => {
|
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()
|
const db = this.database.getDb()
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -376,6 +468,7 @@ export class GoldLandProgressionService {
|
|||||||
reward: { xp: totalXp, coins: totalCoins },
|
reward: { xp: totalXp, coins: totalCoins },
|
||||||
unlockedLevelId,
|
unlockedLevelId,
|
||||||
me: this.users.toMeResponse(state),
|
me: this.users.toMeResponse(state),
|
||||||
|
...(graded.feedback ? { feedback: graded.feedback } : {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -391,7 +484,10 @@ export class GoldLandProgressionService {
|
|||||||
return item
|
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)
|
const definition = getLevelDefinition(levelId)
|
||||||
if (!definition) throw new ProgressionError(404, 'مرحله پیدا نشد')
|
if (!definition) throw new ProgressionError(404, 'مرحله پیدا نشد')
|
||||||
|
|
||||||
@@ -402,10 +498,31 @@ export class GoldLandProgressionService {
|
|||||||
if (!lesson) {
|
if (!lesson) {
|
||||||
throw new ProgressionError(403, 'محتوای این درس هنوز آماده نیست')
|
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 {
|
try {
|
||||||
const result = gradeLessonSubmission(lesson, submission.answers ?? [])
|
const result = gradeLessonSubmission(
|
||||||
return result.score
|
lesson,
|
||||||
|
(submission.answers ?? []) as import('../lessons/lesson.types').LessonSubmissionAnswer[],
|
||||||
|
)
|
||||||
|
return { score: result.score, ...(result.feedback ? { feedback: result.feedback } : {}) }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof LessonGradingError) {
|
if (error instanceof LessonGradingError) {
|
||||||
throw new ProgressionError(error.statusCode, error.message)
|
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, 'ارزیابی این مرحله هنوز پیادهسازی نشده')
|
throw new ProgressionError(403, 'ارزیابی این مرحله هنوز پیادهسازی نشده')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 }>
|
||||||
|
}
|
||||||
@@ -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 { Public } from '../common/decorators/public.decorator'
|
||||||
|
import { DatabaseService } from '../database/database.service'
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Controller()
|
@Controller()
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
|
constructor(private readonly database: DatabaseService) {}
|
||||||
|
|
||||||
|
/** Process is up (liveness). Does not verify database. */
|
||||||
@Get('health')
|
@Get('health')
|
||||||
getHealth() {
|
getHealth() {
|
||||||
return {
|
return {
|
||||||
@@ -12,4 +16,20 @@ export class HealthController {
|
|||||||
runtime: 'nest',
|
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',
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common'
|
import { Module } from '@nestjs/common'
|
||||||
|
import { DatabaseModule } from '../database/database.module'
|
||||||
import { HealthController } from './health.controller'
|
import { HealthController } from './health.controller'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [DatabaseModule],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
export class HealthModule {}
|
export class HealthModule {}
|
||||||
|
|||||||
+13
-2
@@ -6,10 +6,21 @@ import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify
|
|||||||
import { AppModule } from './app.module'
|
import { AppModule } from './app.module'
|
||||||
import { ApiExceptionFilter } from './common/filters/api-exception.filter'
|
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() {
|
async function bootstrap() {
|
||||||
|
// Rate-limit IP: getClientIp() + TRUST_PROXY_CIDRS — not global Fastify trustProxy.
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
AppModule,
|
AppModule,
|
||||||
new FastifyAdapter(),
|
new FastifyAdapter({ trustProxy: false }),
|
||||||
)
|
)
|
||||||
|
|
||||||
app.enableShutdownHooks()
|
app.enableShutdownHooks()
|
||||||
@@ -17,7 +28,7 @@ async function bootstrap() {
|
|||||||
const config = app.get(ConfigService)
|
const config = app.get(ConfigService)
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: config.get<string>('CORS_ORIGIN'),
|
origin: parseCorsOrigins(config.get<string>('CORS_ORIGIN') ?? 'http://localhost:5174'),
|
||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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: 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: 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: 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: 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: false },
|
{ 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: false },
|
{ 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: false },
|
{ 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: false },
|
{ 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: false },
|
{ 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: false },
|
{ 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) {
|
export function getLevelDefinition(levelId: number) {
|
||||||
|
|||||||
@@ -46,10 +46,12 @@ export class ProgressionSyncService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
syncUserProgressionState(userId: string): UserState {
|
syncUserProgressionState(userId: string): UserState {
|
||||||
const state = this.users.getUserState(userId)
|
return this.database.transaction(() => {
|
||||||
const items = this.buildLevelItems(userId)
|
const state = this.users.getUserState(userId)
|
||||||
this.syncLegacyMapLevels(state, items)
|
const items = this.buildLevelItems(userId)
|
||||||
return this.users.saveUserState(state)
|
this.syncLegacyMapLevels(state, items)
|
||||||
|
return this.users.saveUserState(state)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private getProgressRow(userId: string, levelId: number): LevelProgressRow | undefined {
|
private getProgressRow(userId: string, levelId: number): LevelProgressRow | undefined {
|
||||||
|
|||||||
@@ -90,10 +90,12 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateProfile(userId: string, name: string): MeResponse {
|
updateProfile(userId: string, name: string): MeResponse {
|
||||||
const state = this.getUserState(userId)
|
return this.database.transaction(() => {
|
||||||
state.name = name.trim()
|
const state = this.getUserState(userId)
|
||||||
this.saveUserState(state)
|
state.name = name.trim()
|
||||||
return this.toMeResponse(state)
|
this.saveUserState(state)
|
||||||
|
return this.toMeResponse(state)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseStateJson(raw: string, userId: string): UserState {
|
private parseStateJson(raw: string, userId: string): UserState {
|
||||||
|
|||||||
@@ -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 {
|
export class BuyGoldDto {
|
||||||
@IsNumber({ allowNaN: false, allowInfinity: false })
|
@IsNumber({ allowNaN: false, allowInfinity: false })
|
||||||
|
@Min(MIN_GOLD_TRADE_GRAMS, { message: 'مقدار نامعتبر است' })
|
||||||
|
@Max(MAX_GOLD_TRADE_GRAMS, { message: 'مقدار نامعتبر است' })
|
||||||
grams!: number
|
grams!: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
export class SellGoldDto {
|
||||||
@IsNumber({ allowNaN: false, allowInfinity: false })
|
@IsNumber({ allowNaN: false, allowInfinity: false })
|
||||||
|
@Min(MIN_GOLD_TRADE_GRAMS, { message: 'مقدار نامعتبر است' })
|
||||||
|
@Max(MAX_GOLD_TRADE_GRAMS, { message: 'مقدار نامعتبر است' })
|
||||||
grams!: number
|
grams!: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common'
|
import { Inject, Injectable } from '@nestjs/common'
|
||||||
import { DatabaseService } from '../database/database.service'
|
import { DatabaseService } from '../database/database.service'
|
||||||
import { GOLD_PRICE } from '../shared/catalog/market.constants'
|
|
||||||
import type { MeResponse, Transaction } from '../shared/types/user.types'
|
import type { MeResponse, Transaction } from '../shared/types/user.types'
|
||||||
import { UsersService } from '../users/users.service'
|
import { UsersService } from '../users/users.service'
|
||||||
import { addTransaction, incrementMission, unlockAchievement } from '../shared/economy/user-economy.helpers'
|
import { addTransaction, incrementMission, unlockAchievement } from '../shared/economy/user-economy.helpers'
|
||||||
|
import { applyGoldPurchase, applyGoldSale } from './gold-trade.helpers'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WalletService {
|
export class WalletService {
|
||||||
@@ -20,18 +20,7 @@ export class WalletService {
|
|||||||
buyGold(userId: string, grams: number): MeResponse {
|
buyGold(userId: string, grams: number): MeResponse {
|
||||||
return this.database.transaction(() => {
|
return this.database.transaction(() => {
|
||||||
const state = this.users.getUserState(userId)
|
const state = this.users.getUserState(userId)
|
||||||
const pricePerGram = GOLD_PRICE
|
const cost = applyGoldPurchase(state, grams)
|
||||||
const cost = grams * pricePerGram
|
|
||||||
|
|
||||||
if (grams <= 0) {
|
|
||||||
throw new BadRequestException('مقدار نامعتبر است')
|
|
||||||
}
|
|
||||||
if (state.cash < cost) {
|
|
||||||
throw new BadRequestException('موجودی نقد کافی نیست')
|
|
||||||
}
|
|
||||||
|
|
||||||
state.cash -= cost
|
|
||||||
state.goldGrams += grams
|
|
||||||
addTransaction(state, {
|
addTransaction(state, {
|
||||||
type: 'expense',
|
type: 'expense',
|
||||||
title: 'خرید طلا',
|
title: 'خرید طلا',
|
||||||
@@ -48,18 +37,7 @@ export class WalletService {
|
|||||||
sellGold(userId: string, grams: number): MeResponse {
|
sellGold(userId: string, grams: number): MeResponse {
|
||||||
return this.database.transaction(() => {
|
return this.database.transaction(() => {
|
||||||
const state = this.users.getUserState(userId)
|
const state = this.users.getUserState(userId)
|
||||||
const pricePerGram = GOLD_PRICE
|
const value = applyGoldSale(state, grams)
|
||||||
const value = grams * pricePerGram
|
|
||||||
|
|
||||||
if (grams <= 0) {
|
|
||||||
throw new BadRequestException('مقدار نامعتبر است')
|
|
||||||
}
|
|
||||||
if (state.goldGrams < grams) {
|
|
||||||
throw new BadRequestException('طلای کافی نداری')
|
|
||||||
}
|
|
||||||
|
|
||||||
state.goldGrams -= grams
|
|
||||||
state.cash += value
|
|
||||||
addTransaction(state, {
|
addTransaction(state, {
|
||||||
type: 'income',
|
type: 'income',
|
||||||
title: 'فروش طلا',
|
title: 'فروش طلا',
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -428,7 +428,7 @@ async function runTests() {
|
|||||||
assert.equal(levelsMeta.statusCode, 200)
|
assert.equal(levelsMeta.statusCode, 200)
|
||||||
const levelsBody = parseJson<{ levels: Array<{ id: number; isActive: boolean }> }>(levelsMeta.payload)
|
const levelsBody = parseJson<{ levels: Array<{ id: number; isActive: boolean }> }>(levelsMeta.payload)
|
||||||
assert.equal(levelsBody.levels.length, GOLD_LAND_LEVELS.length)
|
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' })
|
const noAuthProgress = await app.inject({ method: 'GET', url: '/api/gold-land/progress' })
|
||||||
assert.equal(noAuthProgress.statusCode, 401)
|
assert.equal(noAuthProgress.statusCode, 401)
|
||||||
@@ -1825,7 +1825,7 @@ async function runTests() {
|
|||||||
catalogSnapshot,
|
catalogSnapshot,
|
||||||
)
|
)
|
||||||
|
|
||||||
const { createDefaultUserState: createDefaultUserStateF0 } = await import(
|
const { createDefaultUserState } = await import(
|
||||||
'../src/shared/catalog/default-user-state'
|
'../src/shared/catalog/default-user-state'
|
||||||
)
|
)
|
||||||
const defaultState1 = createDefaultUserState('f4-default-1')
|
const defaultState1 = createDefaultUserState('f4-default-1')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
@@ -139,7 +139,7 @@ async function runTests() {
|
|||||||
let items = progression.buildLevelItems(TEST_USER)
|
let items = progression.buildLevelItems(TEST_USER)
|
||||||
|
|
||||||
const playable = getAllPlayableLessons()
|
const playable = getAllPlayableLessons()
|
||||||
assert.equal(playable.length, 6)
|
assert.equal(playable.length, 7)
|
||||||
for (const lesson of playable) {
|
for (const lesson of playable) {
|
||||||
assert.equal(countGradableQuestions(lesson), 5, `${lesson.id} should have 5 gradable questions`)
|
assert.equal(countGradableQuestions(lesson), 5, `${lesson.id} should have 5 gradable questions`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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
Reference in New Issue
Block a user