feat: complete the thirteen-stage financial learning path
Backend CI / type-check, build, tests (push) Successful in 1m16s

Server grades levels 7-13 and keeps castle attempt results separate from stored progress.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-22 19:41:24 +03:30
co-authored by Cursor
parent dfb20ca92c
commit 866be22395
52 changed files with 3840 additions and 30 deletions
+57
View File
@@ -0,0 +1,57 @@
# وضعیت مسیر آموزشی ۱۳ مرحله
تاریخ بررسی: ۲۰۲۶-۱۰-۲۲. این سند وضعیت محلی commit‌نشده است. موفقیت CI قبلی با SHA قدیمی backend این تغییرات را تأیید نمی‌کند.
## وضعیت مراحل
هر ۱۳ مرحله در هر دو فهرست `gold-land/levels` و `progression/levels` فعال است. مرحله ۱۴ وجود ندارد.
| مرحله | عنوان | نوع | کلید |
| ---: | --- | --- | --- |
| ۱ | پول چیه؟ | lesson | what-is-money |
| ۲ | پس‌انداز | lesson | saving |
| ۳ | نیاز و خواسته | lesson | needs-and-wants |
| ۴ | طلا چیه؟ | lesson | what-is-gold |
| ۵ | بازار طلا | lesson | gold-market |
| ۶ | تورم | lesson | inflation |
| ۷ | بودجه‌بندی | game | budgeting |
| ۸ | ریسک و بازده | challenge | risk-return |
| ۹ | سبد سرمایه‌گذاری | game | portfolio |
| ۱۰ | سود مرکب | lesson | compound-interest |
| ۱۱ | خرید هوشمند | game | smart-shop |
| ۱۲ | هدف مالی | challenge | financial-goal |
| ۱۳ | آزمون قلعه | final_exam | castle-exam |
کارت «چالش آموزشی» خانه و مسیر `/challenge` چالش روزانه کاتالوگ است و آزمون نقشه نیست.
## اجرای بررسی
Node مورد انتظار در تنظیمات `22.18.0` است. اگر همان نسخه نصب نباشد، نسخه واقعی را گزارش کن و `.nvmrc`، engines و CI را عوض نکن.
```text
cd backend-nest
npm run type-check
npm run build
npm test
cd frontend
npm test
npm run type-check
npm run build
$env:PLAYWRIGHT_CHROME_CHANNEL='chrome'
npx playwright test -c e2e/playwright.config.ts
```
`npm test` در backend شامل suiteهای مراحل ۷ تا ۱۳ است. `frontend/test/run-all-tests.mjs` هم همان‌ها را دارد. Playwright همه فایل‌های `e2e/*.spec.ts` را، از جمله `full-path.spec.ts`، اجرا می‌کند. journey کامل فقط روی پروژه desktop است تا زمان کنترل شود. تست‌های موبایل و دسکتاپ اختصاصی مراحل جدا مانده‌اند.
تست‌ها باید با دیتابیس و حساب موقت باشند.
## نتیجه تلاش و بهترین پیشرفت
امتیاز پاسخ complete، نتیجه همان تلاش است. ستاره ذخیره‌شده، بهترین امتیاز و `completed_at` فقط وقتی تلاش به ستاره برسد به‌روز می‌شوند.
تلاش زیر ۶۰ در مراحل ۱ تا ۱۲ رد می‌شود. در آزمون قلعه همان شکل پاسخ برمی‌گردد، ولی completion و پاداش ثبت نمی‌شود. اگر قبلاً قبول شده باشد، تلاش ناموفق بعدی ستاره، بهترین امتیاز و تاریخ تکمیل قبلی را نگه می‌دارد و پاداش صفر است. پیام «آفرین» فقط وقتی نتیجه همین تلاش معتبر است نشان داده می‌شود، نه به‌خاطر ستاره قبلی.
## کارهای باز
Docker آزمایشی، انتشار، چالش روزانه واقعی، ارزیابی بازی عمومی، و Friends هنوز انجام نشده‌اند. `pinnedRef` در قرارداد E2E هنوز SHA قدیمی backend است.
+48
View File
@@ -0,0 +1,48 @@
# مرحله ۱۰ — سود مرکب
## هدف
کودک فرق رشد بر مبلغ اولیه و رشد بر موجودی جدید را در یک مثال ریاضی ببیند. این مرحله وعده سود واقعی یا پیشنهاد سرمایه‌گذاری نیست. موجودی شبیه‌سازی از کیف پول کاربر جداست.
## فرض‌ها
- مبلغ اولیه ۱۰۰ سکه آموزشی.
- نرخ فرضی ثابت ۱۰٪ در هر دوره.
- هزینه، زیان و تورم در محاسبه مثال وارد نمی‌شوند و در متن توضیح داده می‌شوند.
- واحد زمان «دوره فرضی» است، نه سال یا ماه.
- احتمال و قرعه‌کشی وجود ندارد.
## فرمول
مبالغ با واحد صحیح یک‌دهم سکه نگه داشته می‌شوند تا ۱۳۳٫۱ به اعشار شناور خراب نشود. ۱۰۰ سکه = ۱۰۰۰ واحد.
رشد ساده در دوره `n` (از ۱):
`شروع = ۱۰۰۰ + ۱۰۰ × (n − ۱)` و `رشد = ۱۰۰` و `پایان = شروع + رشد`
رشد مرکب:
`رشد = شروع × ۱۰ / ۱۰۰` و `پایان = شروع + رشد` و شروع دوره بعد برابر پایان همین دوره است.
| دوره | ساده | مرکب |
| --- | ---: | ---: |
| ۰ | ۱۰۰ | ۱۰۰ |
| ۱ | ۱۱۰ | ۱۱۰ |
| ۲ | ۱۲۰ | ۱۲۱ |
| ۳ | ۱۳۰ | ۱۳۳٫۱ |
دوره دوم مرکب: ۱۱۰ + ۱۱ = ۱۲۱. دوره سوم مرکب: ۱۲۱ + ۱۲٫۱ = ۱۳۳٫۱.
مرور دکمه‌های دوره فقط تعداد ردیف‌های دیده‌شده را عوض می‌کند و مبلغ‌ها را تغییر نمی‌دهد.
## نمره
پنج سؤال، هرکدام ۲۰. زیر ۶۰ تکمیل نمی‌شود. سرعت و کلیک امتیاز نیست. پاسخ درست و توضیح در GET عمومی نیست؛ توضیح بعد از complete می‌آید.
1. `compound-q1` = `on-balance`
2. `compound-q2` = `121`
3. `compound-q3` = `from-110`
4. `compound-q4` = `compound`
5. `compound-q5` = `false`
پاداش پایه ۴۵ XP و ۱۸ سکه است.
+35
View File
@@ -0,0 +1,35 @@
# مرحله ۱۱ — خرید هوشمند
## هدف
کودک خرید را با نیاز، هزینه نهایی، اطلاعات کافی و بودجه مقایسه کند. ارزان‌ترین عدد روی کارت یا بزرگ‌ترین تخفیف همیشه بهترین انتخاب نیست. فروشگاه، کالا و سکه خیالی‌اند و از کیف پول بازی کم نمی‌شوند.
## هزینه‌ها
هزینه نهایی فقط وقتی هر دو مبلغ معلوم باشند: `قیمت + تحویل`. مبلغ نامشخص صفر نیست و جمع نهایی هم نامشخص می‌ماند.
| پیشنهاد | قیمت | تحویل | جمع |
| --- | ---: | ---: | ---: |
| دفتر ب | ۴۰ | ۵ | ۴۵ |
| دفتر ج | ۳۸ | ۱۲ | ۵۰ |
| مداد با تخفیف ۲۵٪ | ۳۰ | رایگان | ۳۰ |
| مداد بدون تخفیف | ۲۸ | رایگان | ۲۸ |
| وسیله با اندازه نامشخص | ۴۵ | نامشخص | نامشخص |
انتخاب مناسب دفتر، پیشنهاد ب است. برای دو مداد یکسان، ۲۸ از ۳۰ کمتر است. اگر اندازه یا تحویل نامشخص باشد، خرید فوری مناسب نیست.
## نمره
شش پاسخ با وزن برابر:
`score = Math.round(correctCount × 100 / 6)`
| درست | ۰ | ۱ | ۲ | ۳ | ۴ | ۵ | ۶ |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| امتیاز | ۰ | ۱۷ | ۳۳ | ۵۰ | ۶۷ | ۸۳ | ۱۰۰ |
زیر ۶۰ تکمیل نمی‌شود. ۴ درست یک ستاره، ۵ درست دو ستاره، ۶ درست سه ستاره. پاداش پایه ۴۵ XP و ۱۸ سکه است. سرعت و تعداد باز کردن کارت‌ها امتیاز نیست.
شناسه‌ها: `notebook-choice=b`، `notebook-why=fits-and-cheaper`، `discount-choice=b`، `discount-why=pay-amount`، `info-choice=ask`، `info-why=incomplete`.
ارسال فقط `{ questionId, answerId }` است. مبلغ، تخفیف، امتیاز و پاداش کلاینت پذیرفته نمی‌شود.
+36
View File
@@ -0,0 +1,36 @@
# مرحله ۱۲ — هدف مالی
## هدف
کودک یک خواسته را به هدف مشخص تبدیل کند، مبلغ باقی‌مانده و زمان لازم را حساب کند و بعد از تغییر شرایط برنامه را اصلاح کند. مبالغ خیالی‌اند و از کیف پول بازی کم نمی‌شوند. بدهی، درآمد تضمینی و حذف نیاز ضروری راه‌حل نیستند.
## سناریو
بسته ساخت کاردستی قلعه ۱۲۰ سکه است. موجودی ۳۰، پول هفتگی ۳۰ و نیاز ضروری عادی ۱۰ سکه است. پس‌انداز قابل انتخاب در حالت عادی ۵، ۱۰، ۱۵ یا ۲۰ است. خرج اختیاری = ۳۰ − ۱۰ − پس‌انداز. صفر بودن آن خطا نیست.
مبلغ باقی‌مانده ۹۰ است. تعداد هفته = `ceil(۹۰ / پس‌انداز هفتگی)`.
| پس‌انداز | هفته |
| ---: | ---: |
| ۵ | ۱۸ |
| ۱۰ | ۹ |
| ۱۵ | ۶ |
| ۲۰ | ۵ |
در هفته دوم نیاز ضروری ۲۰ سکه می‌شود. پس‌انداز همان هفته ۰، ۵ یا ۱۰ است. هفته اول با نرخ اولیه ثبت شده می‌ماند. از هفته سوم نرخ ادامه دوباره یکی از ۵، ۱۰، ۱۵ یا ۲۰ است و خودکار پر نمی‌شود.
`موجودی پس از هفته دوم = ۳۰ + نرخ اولیه + نرخ هفته دوم`
`هفته‌های باقی‌مانده = ceil(max(0, ۱۲۰ − موجودی) / نرخ ادامه)`
`کل زمان = ۲ + هفته‌های باقی‌مانده`
مثال ۱۵ / ۵ / ۱۵: موجودی ۵۰، پنج هفته دیگر، کل ۷ هفته، موجودی نهایی ۱۲۵.
## نمره
پنج سؤال ۲۰ امتیازی: ۰، ۴۰، ۶۰، ۸۰، ۱۰۰. سرعت برنامه نمره نیست. زیر ۶۰ تکمیل نمی‌شود. پاداش پایه ۵۰ XP و ۲۰ سکه است.
شناسه‌ها: `q-goal=specific`، `q-left=90`، `q-weeks=6`، `q-need=adjust`، `q-change=update`.
ارسال: `{ questionId: "plan", plan: { initialWeekly, exceptionWeekly, continueWeekly } }` به‌علاوه پنج `{ questionId, answerId }`. زمان و موجودی کلاینت پذیرفته نمی‌شود.
+32
View File
@@ -0,0 +1,32 @@
# مرحله ۱۳ — آزمون قلعه
## هدف
آزمون، آموخته‌های مراحل ۱ تا ۱۲ را مرور می‌کند و مفهوم تازه یاد نمی‌دهد. مثال‌ها خیالی و جدا از کیف پول‌اند. سرعت و حفظ اصطلاح نمره نیست. شکست پایان بازی نیست.
## نگاشت سؤال‌ها
| سؤال | مرحله | پاسخ |
| --- | ---: | --- |
| castle-q1 | ۱ | exchange |
| castle-q2 | ۲ | regular |
| castle-q3 | ۳ | water |
| castle-q4 | ۴ | moves |
| castle-q5 | ۵ | no |
| castle-q6 | ۶ | less |
| castle-q7 | ۷ | valid |
| castle-q8 | ۸ | keep |
| castle-q9 | ۹ | no |
| castle-q10 | ۱۰ | 121 |
| castle-q11 | ۱۱ | b |
| castle-q12 | ۱۲ | 6 |
## امتیاز
`score = Math.round(correctCount × 100 / 12)`
۰، ۸، ۱۷، ۲۵، ۳۳، ۴۲، ۵۰، ۵۸، ۶۷، ۷۵، ۸۳، ۹۲، ۱۰۰.
کمتر از ۶۰ تکمیل نمی‌شود و پاداش ندارد، ولی توضیح سؤال‌ها برمی‌گردد. ۸ درست یک ستاره، ۹ یا ۱۰ دو ستاره، ۱۱ یا ۱۲ سه ستاره. پاداش پایه ۱۰۰ XP و ۵۰ سکه است. بعد از مرحله ۱۳ مرحله دیگری باز نمی‌شود.
ارسال فقط دوازده `{ questionId, answerId }` است. امتیاز و ستاره کلاینت پذیرفته نمی‌شود. پاسخ درست در GET عمومی نیست.
+35
View File
@@ -0,0 +1,35 @@
# مرحله ۷ — مأموریت بودجه‌بندی
- گروه سنی: ۹ تا ۱۲ سال.
- مدت هدف: حدود ۵ تا ۸ دقیقه، بدون محدودیت زمانی.
- لحن: فارسی ساده و صمیمی.
- راهنما: تصویر موجود `professor` با عنوان «راهنما». شخصیت جدید ساخته نشده است.
- نوع فعالیت: `game` با کلید `budgeting`. این مرحله درس پنج‌سؤالی نیست.
## هدف آموزشی
کودک ۱۰۰ سکهٔ آموزشی را بین نیاز سفر (رفت‌وآمد و آب و غذا)، هدف پس‌انداز، و تفریح اختیاری تقسیم می‌کند و بعد از یک تغییر مشخص، برنامه را اصلاح می‌کند. این سکه‌ها از کیف پول کاربر کم نمی‌شوند.
## سناریو
- رفت‌وآمد: ابتدا حداقل ۲۰، بعد از اتفاق حداقل ۳۰.
- آب و غذا: حداقل ۲۵.
- پس‌انداز برای هدف: هدف ۲۰. حداقل ۱۰ برای بخشی از امتیاز.
- تفریح: اختیاری و بدون امتیاز اضافه.
- ذخیره: باقی‌ماندهٔ تخصیص‌نشده. صفر بودن آن خطا نیست و تنها راه درست معرفی نمی‌شود.
- گام تغییر: ۵ سکه.
- اتفاق ثابت: «هزینه رفت‌وآمد ۱۰ سکه بیشتر شد؛ حالا ۳۰ سکه لازم داری.» معرفی فقط می‌گوید ممکن است شرایط تغییر کند.
## Rubric
هر کدام از برنامهٔ اولیه و برنامهٔ نهایی پنج معیار ۱۰امتیازی دارد. مجموع ۱۰۰ است.
1. مجموع تخصیص حداکثر ۱۰۰.
2. تأمین حداقل رفت‌وآمد همان مرحله.
3. تأمین آب و غذا.
4. حداقل ۱۰ سکه پس‌انداز.
5. رسیدن پس‌انداز به ۲۰.
ورودی منفی، غیرصحیح، خارج از گام ۵، مجموع بیش از ۱۰۰، یا فیلد ناشناخته با خطای ورودی رد می‌شود. برنامهٔ اول بدون حداقل ۲۰ رفت‌وآمد و ۲۵ آب‌وغذا، و برنامهٔ نهایی بدون حداقل ۳۰ رفت‌وآمد و ۲۵ آب‌وغذا، با خطای جداگانهٔ نیاز ضروری رد می‌شوند و امتیاز نمی‌گیرند. پس‌انداز، تفریح و ذخیره شرط تأیید نیستند.
پاداش پایهٔ ۴۵ XP و ۱۸ سکه و پاداش هر ستارهٔ تازه (۵ XP و ۳ سکه) فقط از سرور و دفتر پاداش است. امتیاز زیر ۶۰ تکمیل نمی‌شود. مرحلهٔ ۸ غیرفعال می‌ماند.
+31
View File
@@ -0,0 +1,31 @@
# مرحله ۸ — ریسک و بازده
- گروه سنی: ۹ تا ۱۲ سال.
- مدت هدف: حدود ۵ تا ۸ دقیقه، بدون محدودیت زمانی.
- راهنما: تصویر موجود `professor`.
- نوع: `challenge` با کلید `risk-return`.
## هدف آموزشی
کودک تفاوت نتیجهٔ ممکن و نتیجهٔ تضمینی را می‌فهمد، احتمال زیان را تشخیص می‌دهد و انتخاب را با هدف و زمان نیاز به پول هماهنگ می‌کند. امتیاز برای فهم مفهوم و دلیل انتخاب است، نه برای انتخاب پرریسک یا سود اتفاقی.
## مثال ساختگی
برای ۲۰ سکهٔ آموزشی که از کیف پول بازی کم نمی‌شود:
- پیشنهاد الف، چهار پیامد هم‌احتمال: ۲۰، ۲۰، ۲۲، ۲۲.
- پیشنهاد ب، چهار پیامد هم‌احتمال: ۸، ۱۶، ۲۸، ۳۶.
در این مثال هر کارت شانس یکسان دارد. در دنیای واقعی همیشه احتمال‌ها را دقیق نمی‌دانیم. نبود زیان اسمی در پیشنهاد الف فقط مربوط به همین مثال است و تضمین عمومی سرمایه‌گذاری نیست. هیچ دارایی واقعی توصیه نمی‌شود.
## Rubric
پنج فعالیت، هر کدام ۲۰ امتیاز. جمع ۱۰۰.
1. امکان برگشت کمتر از ۲۰: پیشنهاد ب.
2. دریافت قطعی ۳۶ در پیشنهاد ب: خیر.
3. نیاز ضروری تا دو روز دیگر: پول را وارد پیشنهادهای نامطمئن نکن.
4. مقایسه: پیشنهاد ب امکان نتیجهٔ بالاتر و زیان بیشتر دارد؛ سود بیشتر قطعی نیست.
5. وعدهٔ سود زیاد بدون ریسک: تضمین معتبر فرض نکن، بررسی کن و از بزرگسال قابل اعتماد کمک بگیر.
امتیاز به قرعه وابسته نیست. پاسخ درست در محتوای عمومی سؤال نیست. پاداش پایه ۵۰ XP و ۲۰ سکه است. مرحلهٔ ۹ غیرفعال می‌ماند.
+49
View File
@@ -0,0 +1,49 @@
# مرحله ۹ — سبد سرمایه‌گذاری
## هدف آموزشی
کودک ببیند تقسیم ۱۰۰ سکهٔ آموزشی بین گزینه‌هایی با رفتار متفاوت می‌تواند وابستگی به یک گزینه را کم کند. این تقسیم زیان را حذف نمی‌کند و سود را تضمین نمی‌کند. زیاد بودن تعداد اسم‌ها به‌تنهایی تنوع مؤثر نیست.
صندوق‌های «آفتاب»، «باران» و «باد» خیالی‌اند و نمایندهٔ دارایی واقعی نیستند. سکه‌ها از کیف پول بازی کم نمی‌شوند.
## فرض شبیه‌سازی
- فقط سه سناریوی ثابت نشان داده می‌شود. قرعه‌کشی و احتمال وجود ندارد.
- سناریوها مثال‌اند، نه پیش‌بینی و نه مجموعهٔ کامل حالت‌های واقعی.
- هیچ سبدی به‌خاطر بهتر بودن در یک سناریو «بهترین» معرفی نمی‌شود.
| سناریو | آفتاب | باران | باد |
| --- | ---: | ---: | ---: |
| هوای آفتابی | +۲۰٪ | −۱۰٪ | ۰٪ |
| روز بارانی | −۲۰٪ | +۱۰٪ | ۰٪ |
| افت همگانی | −۱۰٪ | −۱۰٪ | −۱۰٪ |
سبد مرجع همیشه ۱۰۰ سکه فقط در آفتاب است و برای مقایسه نمایش داده می‌شود.
## فرمول
مبالغ مضرب ۱۰ و درصدها مضرب ۱۰ هستند. نتیجه با حساب صحیح این فرمول دقیق است و اعشار ندارد:
`ارزش = مبلغ × (۱۰۰ + درصد) / ۱۰۰`
تغییر نسبت به ۱۰۰ با متن «سود N سکه»، «زیان N سکه» یا «بدون تغییر» نوشته می‌شود.
نمونه‌ها:
- ۱۰۰ / ۰ / ۰: آفتابی ۱۲۰ (سود ۲۰)، بارانی ۸۰ (زیان ۲۰)، افت ۹۰ (زیان ۱۰).
- ۴۰ / ۳۰ / ۳۰: آفتابی ۱۰۵ (سود ۵)، بارانی ۹۵ (زیان ۵)، افت ۹۰ (زیان ۱۰).
- هر ترکیب معتبری در افت همگانی ۹۰ سکه است.
## نمره
امتیاز فقط از پنج سؤال، هرکدام ۲۰، می‌آید. ترکیب سبد و سود یا زیان شبیه‌سازی نمره نیستند. زیر ۶۰ تکمیل نمی‌شود. پاسخ درست در GET عمومی نیست.
1. `q-one` = `depend`
2. `q-loss` = `no`
3. `q-name` = `no`
4. `q-best` = `no`
5. `q-approach` = `balanced`
ارسال: `{ questionId: 'portfolio', holdings: { sun, rain, wind } }` به‌علاوهٔ پنج `{ questionId, answerId }`. سرور ترکیب و پاسخ‌ها را خودش اعتبارسنجی و محاسبه می‌کند.
پاداش پایه ۵۰ XP و ۲۰ سکه است و از complete همان مرحلهٔ Gold Land می‌آید، نه از کارت کاتالوگ `portfolio`.
+8 -1
View File
@@ -19,12 +19,19 @@
"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"
"test": "npm run test:progression && npm run test:integration && npm run test:concurrency && npm run test:gold-precision && npm run test:lesson-rewards && npm run test:dual-connection && npm run test:production-config && npm run test:production-error && npm run test:client-ip && npm run test:auth-rate-limit && npm run test:auth-rate-limit-xff && npm run test:backup-restore && npm run test:restore-safety && npm run test:budgeting && npm run test:risk-return && npm run test:portfolio && npm run test:compound-interest && npm run test:smart-shop && npm run test:financial-goal && npm run test:castle-exam"
},
"dependencies": {
"@nestjs/common": "^11.0.12",
+175
View File
@@ -0,0 +1,175 @@
import { budgetingMission, BUDGET_STEP, BUDGET_TOTAL, FOOD_MIN, SAVINGS_GOAL, SAVINGS_PARTIAL, TRANSPORT_FINAL_MIN, TRANSPORT_INITIAL_MIN } from './budgeting.mission'
import type { BudgetAllocation, BudgetCriterionFeedback, BudgetMissionFeedback, BudgetMissionPublic, BudgetPlanFeedback } from './budgeting.types'
export class BudgetingGradingError extends Error {
statusCode: number
kind: 'invalid' | 'unmet-needs'
constructor(statusCode: number, message: string, kind: 'invalid' | 'unmet-needs' = 'invalid') {
super(message)
this.statusCode = statusCode
this.kind = kind
}
}
const CATEGORY_IDS = ['transport', 'food', 'savings', 'fun'] as const
export interface BudgetSubmissionAnswer {
questionId: string
allocations?: BudgetAllocation
answerId?: string
orderedItemIds?: string[]
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function assertAllocation(value: unknown, phase: string): BudgetAllocation {
if (!isPlainObject(value)) {
throw new BudgetingGradingError(400, `بودجهٔ ${phase} نامعتبر است`)
}
const unknown = Object.keys(value).filter((key) => !CATEGORY_IDS.includes(key as (typeof CATEGORY_IDS)[number]))
if (unknown.length > 0) {
throw new BudgetingGradingError(400, 'فیلد ناشناخته در بودجه')
}
const allocation = {} as BudgetAllocation
for (const id of CATEGORY_IDS) {
const amount = value[id]
if (typeof amount !== 'number' || !Number.isInteger(amount)) {
throw new BudgetingGradingError(400, 'مبلغ بودجه باید عدد صحیح باشد')
}
if (amount < 0) {
throw new BudgetingGradingError(400, 'مبلغ بودجه نمی‌تواند منفی باشد')
}
if (amount % BUDGET_STEP !== 0) {
throw new BudgetingGradingError(400, `مبلغ‌ها باید مضرب ${BUDGET_STEP} باشند`)
}
allocation[id] = amount
}
const sum = allocation.transport + allocation.food + allocation.savings + allocation.fun
if (sum > BUDGET_TOTAL) {
throw new BudgetingGradingError(400, 'مجموع تخصیص از ۱۰۰ سکه بیشتر است')
}
return allocation
}
function criterion(
id: string,
earned: boolean,
label: string,
detail: string,
): BudgetCriterionFeedback {
return { id, label, earned, detail }
}
function assertEssentialNeeds(allocation: BudgetAllocation, phase: 'initial' | 'final'): void {
const transportMin = phase === 'initial' ? TRANSPORT_INITIAL_MIN : TRANSPORT_FINAL_MIN
const gaps: string[] = []
if (allocation.transport < transportMin) {
gaps.push(`رفت‌وآمد حداقل ${transportMin} سکه`)
}
if (allocation.food < FOOD_MIN) {
gaps.push(`آب و غذا حداقل ${FOOD_MIN} سکه`)
}
if (gaps.length === 0) return
const planLabel = phase === 'initial' ? 'برنامهٔ اول' : 'برنامهٔ بعد از تغییر'
throw new BudgetingGradingError(
422,
`نیاز ضروری ${planLabel} کامل نیست: ${gaps.join(' و ')}. مقدارها را خودت اصلاح کن.`,
'unmet-needs',
)
}
function scorePlan(allocation: BudgetAllocation, transportMin: number, phase: 'initial' | 'final'): BudgetPlanFeedback {
const sum = allocation.transport + allocation.food + allocation.savings + allocation.fun
const criteria = [
criterion(
'sum',
sum <= BUDGET_TOTAL,
'جمع تخصیص از ۱۰۰ بیشتر نیست',
'سکه‌ها باید داخل همین ۱۰۰ سکه جا شوند.',
),
criterion(
'transport',
allocation.transport >= transportMin,
phase === 'initial' ? 'رفت‌وآمد حداقل ۲۰ سکه دارد' : 'رفت‌وآمد حداقل ۳۰ سکه دارد',
phase === 'initial'
? 'برای شروع گردش حداقل ۲۰ سکه رفت‌وآمد لازم است.'
: 'بعد از گران شدن، ۳۰ سکه رفت‌وآمد لازم است.',
),
criterion(
'food',
allocation.food >= FOOD_MIN,
'آب و غذا حداقل ۲۵ سکه دارد',
'آب و غذا نیاز این گردش است، نه پس‌انداز هدف.',
),
criterion(
'savings-start',
allocation.savings >= SAVINGS_PARTIAL,
'حداقل ۱۰ سکه برای هدف پس‌انداز',
'پس‌انداز هدف با نیاز سفر یکی نیست. از ۱۰ سکه به بالا این بخش را شروع کرده‌ای.',
),
criterion(
'savings-goal',
allocation.savings >= SAVINGS_GOAL,
'پس‌انداز به هدف ۲۰ سکه رسیده',
'هدف این مأموریت ۲۰ سکه پس‌انداز است.',
),
]
return {
phase,
title: phase === 'initial' ? 'برنامهٔ اول' : 'برنامهٔ بعد از تغییر',
criteria,
}
}
export function gradeBudgetSubmission(answers: BudgetSubmissionAnswer[]): {
score: number
feedback: BudgetMissionFeedback
} {
if (!Array.isArray(answers) || answers.length !== 2) {
throw new BudgetingGradingError(400, 'هر دو بودجهٔ اولیه و نهایی لازم است')
}
const byId = new Map<string, BudgetSubmissionAnswer>()
for (const answer of answers) {
if (!answer || typeof answer.questionId !== 'string') {
throw new BudgetingGradingError(400, 'شناسهٔ بودجه نامعتبر است')
}
if (byId.has(answer.questionId)) {
throw new BudgetingGradingError(400, 'بودجهٔ تکراری است')
}
if (answer.answerId !== undefined || answer.orderedItemIds !== undefined) {
throw new BudgetingGradingError(400, 'قالب پاسخ بودجه نامعتبر است')
}
byId.set(answer.questionId, answer)
}
const initialRaw = byId.get('initial')
const finalRaw = byId.get('final')
if (!initialRaw || !finalRaw || byId.size !== 2) {
throw new BudgetingGradingError(400, 'بودجهٔ اولیه و نهایی باید هر دو ارسال شوند')
}
const initial = assertAllocation(initialRaw.allocations, 'اولیه')
const finalPlan = assertAllocation(finalRaw.allocations, 'نهایی')
assertEssentialNeeds(initial, 'initial')
assertEssentialNeeds(finalPlan, 'final')
const plans = [
scorePlan(initial, TRANSPORT_INITIAL_MIN, 'initial'),
scorePlan(finalPlan, TRANSPORT_FINAL_MIN, 'final'),
]
const earned = plans.reduce(
(sum, plan) => sum + plan.criteria.filter((item) => item.earned).length,
0,
)
return {
score: earned * 10,
feedback: { plans },
}
}
export function getBudgetMissionPublic(): BudgetMissionPublic {
return budgetingMission
}
@@ -0,0 +1,61 @@
import type { BudgetMissionPublic } from './budgeting.types'
export const BUDGET_ACTIVITY_KEY = 'budgeting'
export const BUDGET_TOTAL = 100
export const BUDGET_STEP = 5
export const TRANSPORT_INITIAL_MIN = 20
export const TRANSPORT_FINAL_MIN = 30
export const FOOD_MIN = 25
export const SAVINGS_PARTIAL = 10
export const SAVINGS_GOAL = 20
export const budgetingMission: BudgetMissionPublic = {
id: 'budgeting',
version: 1,
title: 'مأموریت بودجه‌بندی',
guideTitle: 'راهنما',
guideImageKey: 'professor',
ageRange: '۹ تا ۱۲ سال',
totalCoins: BUDGET_TOTAL,
step: BUDGET_STEP,
intro:
'برای گردش قلعهٔ سرزمین طلا ۱۰۰ سکهٔ آموزشی داری. این سکه‌ها از کیف پول واقعی‌ات کم نمی‌شوند. پول را بین رفت‌وآمد، آب و غذا، پس‌انداز هدف و تفریح تقسیم کن. ممکن است وسط راه شرایط تغییر کند.',
rules: [
'هر بار فقط ۵ سکه اضافه یا کم می‌شود.',
'جمع تخصیص‌ها نباید از ۱۰۰ سکه بیشتر شود.',
'سکه‌ای که هنوز جایی نگذاشتی، ذخیره است. ذخیره اجباری نیست.',
'رفت‌وآمد و آب و غذا نیاز این گردش‌اند. پس‌انداز برای هدف جداست و تفریح اختیاری است.',
],
eventMessage: 'هزینه رفت‌وآمد ۱۰ سکه بیشتر شد؛ حالا ۳۰ سکه لازم داری.',
reserveLabel: 'ذخیره',
categories: [
{
id: 'transport',
label: 'رفت‌وآمد',
hint: 'اول حداقل ۲۰ سکه لازم است. بعد از تغییر شرایط ۳۰ سکه.',
requiredInitial: TRANSPORT_INITIAL_MIN,
requiredFinal: TRANSPORT_FINAL_MIN,
},
{
id: 'food',
label: 'آب و غذا',
hint: 'حداقل ۲۵ سکه برای این گردش.',
requiredInitial: FOOD_MIN,
requiredFinal: FOOD_MIN,
},
{
id: 'savings',
label: 'پس‌انداز برای هدف',
hint: 'هدف ۲۰ سکه است. این نیاز ضروری سفر نیست.',
requiredInitial: null,
requiredFinal: null,
},
{
id: 'fun',
label: 'تفریح',
hint: 'اختیاری است و امتیاز اضافه ندارد.',
requiredInitial: null,
requiredFinal: null,
},
],
}
@@ -0,0 +1,45 @@
export interface BudgetAllocation {
transport: number
food: number
savings: number
fun: number
}
export interface BudgetCriterionFeedback {
id: string
label: string
earned: boolean
detail: string
}
export interface BudgetPlanFeedback {
phase: 'initial' | 'final'
title: string
criteria: BudgetCriterionFeedback[]
}
export interface BudgetMissionFeedback {
plans: BudgetPlanFeedback[]
}
export interface BudgetMissionPublic {
id: 'budgeting'
version: number
title: string
guideTitle: string
guideImageKey: string
ageRange: string
totalCoins: number
step: number
intro: string
rules: string[]
eventMessage: string
reserveLabel: string
categories: Array<{
id: keyof BudgetAllocation
label: string
hint: string
requiredInitial: number | null
requiredFinal: number | null
}>
}
@@ -0,0 +1,82 @@
import { CASTLE_COMPLETE_MESSAGE, castleExamPublic, getCastleQuestionBank } from './castle.mission'
import type { CastleExamFeedback, CastleExamPublic } from './castle.types'
export class CastleExamGradingError extends Error {
statusCode: number
constructor(statusCode: number, message: string) {
super(message)
this.statusCode = statusCode
}
}
export interface CastleSubmissionAnswer {
questionId?: string
answerId?: string
orderedItemIds?: unknown
allocations?: unknown
holdings?: unknown
plan?: unknown
score?: unknown
stars?: unknown
reward?: unknown
completed?: unknown
}
export function gradeCastleExam(answers: CastleSubmissionAnswer[]): {
score: number
feedback: CastleExamFeedback
} {
if (!Array.isArray(answers)) throw new CastleExamGradingError(400, 'فرمت پاسخ نامعتبر است')
const bank = getCastleQuestionBank()
const seen = new Set<string>()
const chosen = new Map<string, string>()
for (const answer of answers) {
if (!answer || typeof answer.questionId !== 'string' || typeof answer.answerId !== 'string') {
throw new CastleExamGradingError(400, 'شناسهٔ پاسخ نامعتبر است')
}
if (seen.has(answer.questionId)) {
throw new CastleExamGradingError(400, `پاسخ ${answer.questionId} تکراری است`)
}
seen.add(answer.questionId)
if (
answer.orderedItemIds !== undefined ||
answer.allocations !== undefined ||
answer.holdings !== undefined ||
answer.plan !== undefined ||
answer.score !== undefined ||
answer.stars !== undefined ||
answer.reward !== undefined ||
answer.completed !== undefined
) {
throw new CastleExamGradingError(400, 'فیلد غیرمجاز در پاسخ آزمون')
}
const question = bank.find((item) => item.id === answer.questionId)
if (!question) throw new CastleExamGradingError(400, `سؤال ${answer.questionId} پیدا نشد`)
if (!question.options.some((option) => option.id === answer.answerId)) {
throw new CastleExamGradingError(400, `گزینهٔ نامعتبر برای سؤال ${answer.questionId}`)
}
chosen.set(answer.questionId, answer.answerId)
}
if (chosen.size !== bank.length) throw new CastleExamGradingError(400, 'پاسخ همهٔ سؤال‌ها لازم است')
const questions = bank.map((question) => ({
id: question.id,
sourceLevelId: question.sourceLevelId,
earned: chosen.get(question.id) === question.correctOptionId,
selectedAnswerId: chosen.get(question.id) ?? '',
explanation: question.explanation,
}))
const correctCount = questions.filter((item) => item.earned).length
const score = Math.round((correctCount * 100) / 12)
return {
score,
feedback: {
summary: score >= 60 ? CASTLE_COMPLETE_MESSAGE : 'این تلاش هنوز کامل نشده. پاسخ‌ها را ببین و دوباره امتحان کن.',
completed: score >= 60,
questions,
},
}
}
export function getCastleExamPublic(): CastleExamPublic {
return castleExamPublic
}
+194
View File
@@ -0,0 +1,194 @@
import type { CastleExamPublic, CastleQuestionPublic } from './castle.types'
export const CASTLE_ACTIVITY_KEY = 'castle-exam'
const sections = [
{ id: 'basics', title: 'پایه‌های پول' },
{ id: 'market', title: 'بازار و برنامه' },
{ id: 'choices', title: 'تصمیم‌های بزرگ‌تر' },
] as const
const bank = [
{
id: 'castle-q1',
sourceLevelId: 1,
sectionId: 'basics',
prompt: 'در یک خرید ساده، کاربرد پول چیست؟',
options: [
{ id: 'exchange', label: 'مبادله را آسان می‌کند و مقایسه قیمت را ممکن می‌سازد' },
{ id: 'decorate', label: 'فقط برای تزیین کیف است' },
{ id: 'always-more', label: 'هرچه بیشتر باشد، همیشه همه چیز ارزان‌تر می‌شود' },
],
correctOptionId: 'exchange',
explanation: 'پول کمک می‌کند به‌جای تعویض کالا با کالا، قیمت‌ها را با هم مقایسه کنیم.',
},
{
id: 'castle-q2',
sourceLevelId: 2,
sectionId: 'basics',
prompt: 'برای یک هدف در آینده، کدام رفتار مناسب‌تر است؟',
options: [
{ id: 'regular', label: 'مبلغی متناسب با شرایط را منظم کنار بگذاریم' },
{ id: 'all-now', label: 'همه پول این هفته را خرج کنیم و بعداً فکری کنیم' },
{ id: 'borrow', label: 'برای رسیدن سریع، بدهی بسازیم' },
],
correctOptionId: 'regular',
explanation: 'پس‌انداز مناسب، مبلغی است که با نیازها جور باشد و بتوانی ادامه بدهی.',
},
{
id: 'castle-q3',
sourceLevelId: 3,
sectionId: 'basics',
prompt: 'بودجه کم است. آب آشامیدنی لازم است و یک وسیله تزئینی اختیاری. اول کدام؟',
options: [
{ id: 'water', label: 'اول آب آشامیدنی را تأمین کنیم' },
{ id: 'decor', label: 'اول وسیله تزئینی، چون قشنگ‌تر است' },
{ id: 'neither', label: 'هیچ‌کدام فرقی ندارد' },
],
correctOptionId: 'water',
explanation: 'در این سناریو آب نیاز ضروری است و وسیله تزئینی خواسته اختیاری. اول نیاز را تأمین می‌کنیم.',
},
{
id: 'castle-q4',
sourceLevelId: 4,
sectionId: 'basics',
prompt: 'کدام جمله درباره طلا دقیق‌تر است؟',
options: [
{ id: 'moves', label: 'طلا یک دارایی است و قیمتش می‌تواند بالا یا پایین برود' },
{ id: 'fixed', label: 'قیمت طلا همیشه ثابت می‌ماند' },
{ id: 'only-up', label: 'قیمت طلا فقط بالا می‌رود' },
],
correctOptionId: 'moves',
explanation: 'طلا دارایی است، نه پول نقد هر روز. قیمتش می‌تواند تغییر کند.',
},
{
id: 'castle-q5',
sourceLevelId: 5,
sectionId: 'market',
prompt: 'آیا بالارفتن قیمت امروز تضمین می‌کند فردا هم بالا برود؟',
options: [
{ id: 'no', label: 'خیر' },
{ id: 'yes', label: 'بله، همیشه ادامه پیدا می‌کند' },
],
correctOptionId: 'no',
explanation: 'قیمت دیروز یا امروز، قیمت فردا را تضمین نمی‌کند.',
},
{
id: 'castle-q6',
sourceLevelId: 6,
sectionId: 'market',
prompt: 'قیمت همان سبد کالا از ۱۰۰ به ۱۲۰ رسیده و پول تو هنوز ۱۰۰ است. چه می‌شود؟',
options: [
{ id: 'less', label: 'با همان پول، مقدار کمتری از آن کالاها می‌توان خرید' },
{ id: 'more', label: 'با همان پول، مقدار بیشتری می‌توان خرید' },
{ id: 'same', label: 'قدرت خرید عوض نمی‌شود' },
],
correctOptionId: 'less',
explanation: 'وقتی قیمت سبد بالا برود و پولت ثابت بماند، قدرت خرید کمتر می‌شود.',
},
{
id: 'castle-q7',
sourceLevelId: 7,
sectionId: 'market',
prompt: '۱۰۰ سکه داری. رفت‌وآمد ۳۰ و آب‌وغذا ۲۵ لازم است. کدام برنامه معتبر است؟',
options: [
{ id: 'valid', label: 'رفت‌وآمد ۳۰، غذا ۲۵، پس‌انداز ۲۰، تفریح ۱۵، ذخیره ۱۰' },
{ id: 'over', label: 'رفت‌وآمد ۳۰، غذا ۲۵، پس‌انداز ۳۰، تفریح ۲۰، ذخیره ۱۰' },
{ id: 'short', label: 'رفت‌وآمد ۲۰، غذا ۲۵، پس‌انداز ۳۰، تفریح ۱۵، ذخیره ۱۰' },
],
correctOptionId: 'valid',
explanation: 'برنامه معتبر نیازها را تأمین می‌کند و مجموعش از ۱۰۰ بیشتر نیست. گزینه دوم ۱۱۵ است و گزینه سوم رفت‌وآمد را کم دارد.',
},
{
id: 'castle-q8',
sourceLevelId: 8,
sectionId: 'market',
prompt: 'برای نیازی ضروری در دو روز آینده، کدام رفتار مناسب‌تر است؟',
options: [
{ id: 'keep', label: 'پول لازم را وارد پیشنهاد نامطمئن با احتمال زیان نکنیم' },
{ id: 'risk', label: 'همان پول ضروری را برای سود بیشتر وارد پیشنهاد نامطمئن کنیم' },
],
correctOptionId: 'keep',
explanation: 'پولی که به‌زودی برای نیاز ضروری لازم است، نباید وارد گزینه‌ای شود که ممکن است کم شود.',
},
{
id: 'castle-q9',
sourceLevelId: 9,
sectionId: 'choices',
prompt: 'آیا تقسیم پول بین چند گزینه، زیان را ناممکن می‌کند؟',
options: [
{ id: 'no', label: 'خیر؛ چند گزینه ممکن است هم‌زمان افت کنند' },
{ id: 'yes', label: 'بله، با چند گزینه دیگر زیان ممکن نیست' },
],
correctOptionId: 'no',
explanation: 'تقسیم می‌تواند وابستگی به یک گزینه را کم کند، اما افت همگانی هنوز ممکن است.',
},
{
id: 'castle-q10',
sourceLevelId: 10,
sectionId: 'choices',
prompt: 'در مثال فرضی، ۱۰۰ سکه با رشد مرکب ۱۰٪ در هر دوره و بدون هزینه، پس از دو دوره چقدر است؟ این وعده واقعی نیست.',
options: [
{ id: '121', label: '۱۲۱' },
{ id: '120', label: '۱۲۰' },
{ id: '110', label: '۱۱۰' },
],
correctOptionId: '121',
explanation: 'با فرض ثابت‌ماندن نرخ و نبودن هزینه: ۱۰۰ + ۱۰ = ۱۱۰ و ۱۱۰ + ۱۱ = ۱۲۱.',
},
{
id: 'castle-q11',
sourceLevelId: 11,
sectionId: 'choices',
prompt: 'دو کالای یکسان: الف قیمت ۳۸ و تحویل ۱۲؛ ب قیمت ۴۰ و تحویل ۵. کدام مبلغ نهایی کمتری دارد؟',
options: [
{ id: 'b', label: 'ب، چون ۴۵ در برابر ۵۰ است' },
{ id: 'a', label: 'الف، چون عدد روی کارت کوچک‌تر است' },
],
correctOptionId: 'b',
explanation: 'هزینه نهایی قیمت به‌اضافه تحویل است: ۳۸ + ۱۲ = ۵۰ و ۴۰ + ۵ = ۴۵.',
},
{
id: 'castle-q12',
sourceLevelId: 12,
sectionId: 'choices',
prompt: 'هدف ۱۲۰ سکه، موجودی ۳۰ و پس‌انداز هفتگی ۱۵ است. اگر شرایط ثابت بماند، چند هفته لازم است؟',
options: [
{ id: '6', label: '۶ هفته' },
{ id: '8', label: '۸ هفته' },
{ id: '4', label: '۴ هفته' },
],
correctOptionId: '6',
explanation: '۹۰ سکه باقی مانده و ۱۵ سکه در هفته، با شرایط ثابت، ۶ هفته می‌شود. این برآورد است.',
},
] as const
function sectionTitle(sectionId: string): string {
return sections.find((section) => section.id === sectionId)?.title ?? ''
}
export const castleExamPublic: CastleExamPublic = {
id: 'castle-exam',
version: 1,
title: 'آزمون قلعه',
guideTitle: 'راهنما',
guideImageKey: 'professor',
intro:
'به قلعه رسیدی! حالا با تصمیم‌های کوچک نشان بده چه چیزهایی یاد گرفته‌ای. عجله‌ای نیست و قبل از ثبت می‌توانی پاسخ‌ها را مرور کنی.',
sections: sections.map((section) => ({ id: section.id, title: section.title })),
questions: bank.map((question) => ({
id: question.id,
sourceLevelId: question.sourceLevelId,
sectionId: question.sectionId,
sectionTitle: sectionTitle(question.sectionId),
prompt: question.prompt,
options: question.options.map((option) => ({ id: option.id, label: option.label })),
})) satisfies CastleQuestionPublic[],
}
export function getCastleQuestionBank() {
return bank
}
export const CASTLE_COMPLETE_MESSAGE =
'آفرین! مسیر آموزشی قلعه را کامل کردی. می‌توانی برای مرور و بهترشدن دوباره بازی کنی.'
+31
View File
@@ -0,0 +1,31 @@
export interface CastleQuestionPublic {
id: string
sourceLevelId: number
sectionId: string
sectionTitle: string
prompt: string
options: Array<{ id: string; label: string }>
}
export interface CastleExamPublic {
id: string
version: number
title: string
guideTitle: string
guideImageKey: string
intro: string
sections: Array<{ id: string; title: string }>
questions: CastleQuestionPublic[]
}
export interface CastleExamFeedback {
summary: string
completed: boolean
questions: Array<{
id: string
sourceLevelId: number
earned: boolean
selectedAnswerId: string
explanation: string
}>
}
+62
View File
@@ -1,6 +1,7 @@
import { Type } from 'class-transformer'
import {
IsArray,
IsInt,
IsNumber,
IsOptional,
IsString,
@@ -8,6 +9,52 @@ import {
ValidateNested,
} from 'class-validator'
export class BudgetAllocationDto {
@IsInt()
@Min(0)
transport!: number
@IsInt()
@Min(0)
food!: number
@IsInt()
@Min(0)
savings!: number
@IsInt()
@Min(0)
fun!: number
}
export class PortfolioHoldingDto {
@IsInt()
@Min(0)
sun!: number
@IsInt()
@Min(0)
rain!: number
@IsInt()
@Min(0)
wind!: number
}
export class GoalPlanDto {
@IsInt()
@Min(0)
initialWeekly!: number
@IsInt()
@Min(0)
exceptionWeekly!: number
@IsInt()
@Min(0)
continueWeekly!: number
}
export class LessonAnswerDto {
@IsString()
questionId!: string
@@ -20,6 +67,21 @@ export class LessonAnswerDto {
@IsArray()
@IsString({ each: true })
orderedItemIds?: string[]
@IsOptional()
@ValidateNested()
@Type(() => BudgetAllocationDto)
allocations?: BudgetAllocationDto
@IsOptional()
@ValidateNested()
@Type(() => PortfolioHoldingDto)
holdings?: PortfolioHoldingDto
@IsOptional()
@ValidateNested()
@Type(() => GoalPlanDto)
plan?: GoalPlanDto
}
export class CompleteLevelDto {
+133
View File
@@ -0,0 +1,133 @@
import {
CONTINUE_RATES,
EXCEPTION_RATES,
INITIAL_RATES,
initialPlan,
revisedPlan,
} from './goal.math'
import { financialGoalMission, getGoalQuestionBank } from './goal.mission'
import type { FinancialGoalFeedback, FinancialGoalPublic } from './goal.types'
export class FinancialGoalGradingError extends Error {
statusCode: number
constructor(statusCode: number, message: string) {
super(message)
this.statusCode = statusCode
}
}
export interface GoalPlanInput {
initialWeekly: number
exceptionWeekly: number
continueWeekly: number
}
export interface GoalSubmissionAnswer {
questionId?: string
answerId?: string
plan?: GoalPlanInput
orderedItemIds?: unknown
allocations?: unknown
holdings?: unknown
weeks?: unknown
balance?: unknown
score?: unknown
reward?: unknown
}
function assertPlan(value: GoalPlanInput | undefined): GoalPlanInput {
if (!value || typeof value !== 'object') {
throw new FinancialGoalGradingError(400, 'برنامه هدف نامعتبر است')
}
const rates = [
['initialWeekly', INITIAL_RATES],
['exceptionWeekly', EXCEPTION_RATES],
['continueWeekly', CONTINUE_RATES],
] as const
const plan = {} as GoalPlanInput
for (const [key, allowed] of rates) {
const amount = value[key]
if (typeof amount !== 'number' || !Number.isInteger(amount) || amount < 0) {
throw new FinancialGoalGradingError(400, 'مبلغ پس‌انداز نامعتبر است')
}
if (!allowed.includes(amount as never)) {
throw new FinancialGoalGradingError(400, 'نرخ پس‌انداز مجاز نیست')
}
plan[key] = amount
}
if (plan.continueWeekly === 0) {
throw new FinancialGoalGradingError(400, 'نرخ ادامه نمی‌تواند صفر باشد')
}
return plan
}
export function gradeFinancialGoalSubmission(answers: GoalSubmissionAnswer[]): {
score: number
feedback: FinancialGoalFeedback
} {
if (!Array.isArray(answers)) throw new FinancialGoalGradingError(400, 'فرمت پاسخ نامعتبر است')
const bank = getGoalQuestionBank()
const seen = new Set<string>()
let plan: GoalPlanInput | null = null
const chosen = new Map<string, string>()
for (const answer of answers) {
if (!answer || typeof answer.questionId !== 'string') {
throw new FinancialGoalGradingError(400, 'شناسهٔ پاسخ نامعتبر است')
}
if (seen.has(answer.questionId)) {
throw new FinancialGoalGradingError(400, `پاسخ ${answer.questionId} تکراری است`)
}
seen.add(answer.questionId)
if (
answer.orderedItemIds !== undefined ||
answer.allocations !== undefined ||
answer.holdings !== undefined ||
answer.weeks !== undefined ||
answer.balance !== undefined ||
answer.score !== undefined ||
answer.reward !== undefined
) {
throw new FinancialGoalGradingError(400, 'فیلد غیرمجاز در پاسخ هدف')
}
if (answer.questionId === 'plan') {
plan = assertPlan(answer.plan)
continue
}
if (typeof answer.answerId !== 'string') {
throw new FinancialGoalGradingError(400, 'نوع پاسخ سؤال نامعتبر است')
}
const question = bank.find((item) => item.id === answer.questionId)
if (!question) throw new FinancialGoalGradingError(400, `سؤال ${answer.questionId} پیدا نشد`)
if (!question.options.some((option) => option.id === answer.answerId)) {
throw new FinancialGoalGradingError(400, `گزینهٔ نامعتبر برای سؤال ${answer.questionId}`)
}
chosen.set(answer.questionId, answer.answerId)
}
if (!plan) throw new FinancialGoalGradingError(400, 'برنامه هدف ارسال نشده')
if (chosen.size !== bank.length) throw new FinancialGoalGradingError(400, 'پاسخ همهٔ سؤال‌ها لازم است')
const initial = initialPlan(plan.initialWeekly)
const revised = revisedPlan(plan.initialWeekly, plan.exceptionWeekly, plan.continueWeekly)
const questions = bank.map((question) => ({
id: question.id,
earned: chosen.get(question.id) === question.correctOptionId,
explanation: question.explanation,
}))
return {
score: questions.filter((item) => item.earned).length * 20,
feedback: {
summary: financialGoalMission.summary,
timeline: {
initialWeeks: initial.weeks,
balanceAfterTwo: revised.balanceAfterTwo,
remainingWeeks: revised.remainingWeeks,
totalWeeks: revised.totalWeeks,
finalBalance: revised.finalBalance,
},
questions,
},
}
}
export function getFinancialGoalPublic(): FinancialGoalPublic {
return financialGoalMission
}
+55
View File
@@ -0,0 +1,55 @@
export const GOAL_PRICE = 120
export const CURRENT_SAVINGS = 30
export const WEEKLY_INCOME = 30
export const NORMAL_ESSENTIAL = 10
export const EXCEPTION_ESSENTIAL = 20
export const INITIAL_RATES = [5, 10, 15, 20] as const
export const EXCEPTION_RATES = [0, 5, 10] as const
export const CONTINUE_RATES = [5, 10, 15, 20] as const
export interface InitialPlanResult {
remaining: number
weeks: number
totalSaved: number
extra: number
optional: number
}
export interface RevisedPlanResult {
balanceAfterTwo: number
remainingWeeks: number
totalWeeks: number
finalBalance: number
extra: number
}
export function optionalCoins(essential: number, saving: number): number {
return WEEKLY_INCOME - essential - saving
}
export function initialPlan(weekly: number): InitialPlanResult {
const remaining = GOAL_PRICE - CURRENT_SAVINGS
const weeks = Math.ceil(remaining / weekly)
const totalSaved = CURRENT_SAVINGS + weeks * weekly
return {
remaining,
weeks,
totalSaved,
extra: totalSaved - GOAL_PRICE,
optional: optionalCoins(NORMAL_ESSENTIAL, weekly),
}
}
export function revisedPlan(initialWeekly: number, exceptionWeekly: number, continueWeekly: number): RevisedPlanResult {
const balanceAfterTwo = CURRENT_SAVINGS + initialWeekly + exceptionWeekly
const gap = Math.max(0, GOAL_PRICE - balanceAfterTwo)
const remainingWeeks = gap === 0 ? 0 : Math.ceil(gap / continueWeekly)
const finalBalance = balanceAfterTwo + remainingWeeks * continueWeekly
return {
balanceAfterTwo,
remainingWeeks,
totalWeeks: 2 + remainingWeeks,
finalBalance,
extra: finalBalance - GOAL_PRICE,
}
}
@@ -0,0 +1,105 @@
import {
CONTINUE_RATES,
CURRENT_SAVINGS,
EXCEPTION_ESSENTIAL,
EXCEPTION_RATES,
GOAL_PRICE,
INITIAL_RATES,
NORMAL_ESSENTIAL,
WEEKLY_INCOME,
} from './goal.math'
import type { FinancialGoalPublic } from './goal.types'
export const FINANCIAL_GOAL_ACTIVITY_KEY = 'financial-goal'
const questions = [
{
id: 'q-goal',
prompt: 'هدف مشخص کدام است؟',
options: [
{ id: 'vague', label: 'فقط پول بیشتری می‌خواهم' },
{ id: 'specific', label: 'خرید بسته مشخص با قیمت ۱۲۰ و برنامه پس‌انداز' },
],
correctOptionId: 'specific',
explanation: 'هدف مشخص می‌گوید چه چیزی، با چه قیمتی و با چه برنامه‌ای خریده می‌شود.',
},
{
id: 'q-left',
prompt: 'با ۳۰ سکه موجود، چقدر تا هدف باقی مانده؟',
options: [
{ id: '90', label: '۹۰ سکه' },
{ id: '120', label: '۱۲۰ سکه' },
{ id: '30', label: '۳۰ سکه' },
],
correctOptionId: '90',
explanation: '۱۲۰ منهای ۳۰ می‌شود ۹۰ سکه.',
},
{
id: 'q-weeks',
prompt: 'با پس‌انداز ۱۵ در هفته و شرایط اولیه، چند هفته لازم است؟',
options: [
{ id: '5', label: '۵ هفته' },
{ id: '6', label: '۶ هفته' },
{ id: '9', label: '۹ هفته' },
],
correctOptionId: '6',
explanation: '۹۰ تقسیم بر ۱۵ می‌شود ۶ هفته. این برآورد است، اگر شرایط ثابت بماند.',
},
{
id: 'q-need',
prompt: 'در هفته‌ای که نیاز ضروری بیشتر شده، کدام کار مناسب‌تر است؟',
options: [
{ id: 'drop', label: 'نیاز ضروری را حذف کن تا زودتر به هدف برسی' },
{ id: 'adjust', label: 'نیاز را تأمین کن و مبلغ پس‌انداز یا زمان هدف را اصلاح کن' },
],
correctOptionId: 'adjust',
explanation: 'نیاز ضروری نباید فدای هدف شود. برنامه را می‌شود آهسته‌تر کرد.',
},
{
id: 'q-change',
prompt: 'اگر شرایط یا قیمت هدف تغییر کند، چه باید کرد؟',
options: [
{ id: 'keep', label: 'برآورد قبلی را تضمین بدان و تغییر نده' },
{ id: 'update', label: 'برنامه و زمان را دوباره بررسی و به‌روز کن' },
],
correctOptionId: 'update',
explanation: 'برآورد قبلی تضمین نیست. با تغییر شرایط، زمان را دوباره حساب می‌کنیم.',
},
] as const
export const financialGoalMission: FinancialGoalPublic = {
id: 'financial-goal',
version: 1,
title: 'هدف مالی',
guideTitle: 'راهنما',
guideImageKey: 'professor',
intro:
'برای خرید بسته ساخت کاردستی قلعه برنامه می‌ریزی. سکه‌ها خیالی‌اند و از کیف پول بازی کم نمی‌شوند. مقدار پس‌انداز باید با نیازها سازگار و قابل ادامه باشد.',
rules: [
'سریع‌ترین برنامه به‌خودی‌خود بهتر نیست.',
'صفر بودن خرج اختیاری به‌تنهایی اشتباه نیست.',
'در هفته استثنایی می‌توانی پس‌انداز نکنی.',
'زمان‌ها برآوردند، نه وعده واقعی.',
],
eventMessage: 'در هفته دوم، از ۳۰ سکه پول هفتگی، ۲۰ سکه برای یک نیاز ضروری لازم شد.',
plan: {
goalPrice: GOAL_PRICE,
currentSavings: CURRENT_SAVINGS,
weeklyIncome: WEEKLY_INCOME,
normalEssential: NORMAL_ESSENTIAL,
exceptionEssential: EXCEPTION_ESSENTIAL,
initialRates: [...INITIAL_RATES],
exceptionRates: [...EXCEPTION_RATES],
continueRates: [...CONTINUE_RATES],
},
questions: questions.map(({ id, prompt, options }) => ({
id,
prompt,
options: options.map((option) => ({ id: option.id, label: option.label })),
})),
summary: 'هدف مشخص، مبلغ باقی‌مانده و زمان برآوردی را با تغییر شرایط دوباره بررسی می‌کنیم.',
}
export function getGoalQuestionBank() {
return questions
}
@@ -0,0 +1,42 @@
export interface GoalPlanPublic {
goalPrice: number
currentSavings: number
weeklyIncome: number
normalEssential: number
exceptionEssential: number
initialRates: number[]
exceptionRates: number[]
continueRates: number[]
}
export interface GoalQuestionPublic {
id: string
prompt: string
options: Array<{ id: string; label: string }>
}
export interface FinancialGoalPublic {
id: string
version: number
title: string
guideTitle: string
guideImageKey: string
intro: string
rules: string[]
eventMessage: string
plan: GoalPlanPublic
questions: GoalQuestionPublic[]
summary: string
}
export interface FinancialGoalFeedback {
summary: string
timeline: {
initialWeeks: number
balanceAfterTwo: number
remainingWeeks: number
totalWeeks: number
finalBalance: number
}
questions: Array<{ id: string; earned: boolean; explanation: string }>
}
+2 -2
View File
@@ -13,7 +13,7 @@ import { CurrentUser } from '../common/decorators/current-user.decorator'
import { strictBodyPipe } from '../common/pipes/strict-body.pipe'
import { GOLD_LAND_LEVELS } from './levels/levels'
import { CompleteLevelDto } from './dto/complete-level.dto'
import type { LessonSubmissionAnswer } from './lessons/lesson.types'
import type { LevelSubmissionAnswer } from './levels/level.types'
import { GoldLandProgressionService } from './services/progression.service'
@Controller('api/gold-land')
@@ -80,7 +80,7 @@ export class GoldLandController {
@Body(strictBodyPipe(CompleteLevelDto)) dto: CompleteLevelDto,
) {
return this.progression.completeLevel(userId, levelId, {
answers: dto.answers as LessonSubmissionAnswer[],
answers: dto.answers as LevelSubmissionAnswer[],
durationMs: dto.durationMs,
})
}
@@ -0,0 +1,102 @@
import type { LessonDefinition } from './lesson.types.js'
import { buildGrowthExample } from './growth-example.js'
const example = buildGrowthExample(100, 10, 3)
export const compoundInterestLesson: LessonDefinition = {
id: 'compound-interest',
version: 1,
title: 'سود مرکب',
steps: [
{
id: 'compound-story',
type: 'story',
title: 'راهنما',
body: 'وقتی سود در حساب بماند، در دوره بعد ممکن است خودش هم در محاسبه رشد شرکت کند. این یک مثال ریاضی با نرخ فرضی ثابت است، نه وعده سود واقعی.',
imageKey: 'professor',
},
{
id: 'compound-table',
type: 'growth_table',
title: 'دو روش رشد',
body: '۱۰۰ سکه اولیه و نرخ فرضی ۱۰٪ در هر دوره. رشد ساده هر بار فقط ۱۰٪ مبلغ اولیه را اضافه می‌کند. رشد مرکب هر بار ۱۰٪ موجودی همان دوره را اضافه می‌کند.',
disclaimer:
'این مثال نرخ ثابت و بدون هزینه فرض می‌کند. در دنیای واقعی سود ممکن است ثابت نباشد، زیان رخ دهد و هزینه و تورم بر نتیجه اثر بگذارند.',
unitLabel: 'دوره فرضی',
principalTenths: example.principalTenths,
ratePercent: example.ratePercent,
methods: [
{ id: 'simple', label: 'رشد ساده', note: 'رشد هر دوره فقط از مبلغ اولیه حساب می‌شود.' },
{ id: 'compound', label: 'رشد مرکب', note: 'رشد هر دوره از موجودی جدید، شامل رشد قبلی، حساب می‌شود.' },
],
periods: example.periods,
},
{
id: 'compound-q1',
type: 'single_choice',
title: 'سؤال ۱',
question: 'تفاوت اصلی رشد مرکب چیست؟',
options: [
{ id: 'initial-only', label: 'همیشه فقط از مبلغ اولیه حساب می‌شود' },
{ id: 'on-balance', label: 'رشد دوره بعد بر موجودی جدید، شامل رشد قبلی، محاسبه می‌شود' },
{ id: 'same', label: 'با رشد ساده هیچ فرقی ندارد' },
],
correctAnswerId: 'on-balance',
explanation: 'در رشد مرکب، رشد قبلی داخل موجودی می‌ماند و دوره بعد روی همان موجودی جدید حساب می‌شود.',
},
{
id: 'compound-q2',
type: 'single_choice',
title: 'سؤال ۲',
question: 'پس از دو دوره، مبلغ روش مرکب چقدر است؟',
options: [
{ id: '120', label: '۱۲۰ سکه' },
{ id: '121', label: '۱۲۱ سکه' },
{ id: '130', label: '۱۳۰ سکه' },
],
correctAnswerId: '121',
explanation: 'دوره اول ۱۰۰ + ۱۰ = ۱۱۰ و دوره دوم ۱۱۰ + ۱۱ = ۱۲۱.',
},
{
id: 'compound-q3',
type: 'single_choice',
title: 'سؤال ۳',
question: 'چرا رشد دوره دوم در روش مرکب ۱۱ سکه است؟',
options: [
{ id: 'from-110', label: 'چون ۱۰٪ از ۱۱۰ محاسبه شده است' },
{ id: 'from-100', label: 'چون همیشه ۱۰٪ از ۱۰۰ است' },
{ id: 'random', label: 'چون عدد ۱۱ تصادفی انتخاب شده' },
],
correctAnswerId: 'from-110',
explanation: 'موجودی شروع دوره دوم ۱۱۰ است و ۱۰٪ آن ۱۱ سکه می‌شود.',
},
{
id: 'compound-q4',
type: 'single_choice',
title: 'سؤال ۴',
question: 'پس از سه دوره، کدام روش در همین مثال مبلغ بیشتری دارد؟',
options: [
{ id: 'simple', label: 'ساده؛ ۱۳۰ در برابر ۱۳۳٫۱' },
{ id: 'compound', label: 'مرکب؛ ۱۳۳٫۱ در برابر ۱۳۰' },
{ id: 'equal', label: 'هر دو برابرند' },
],
correctAnswerId: 'compound',
explanation: 'فقط در همین مثال با نرخ ثابت، مرکب ۱۳۳٫۱ و ساده ۱۳۰ است. این نتیجه برای هر سرمایه‌گذاری واقعی تضمین نیست.',
},
{
id: 'compound-q5',
type: 'true_false',
title: 'سؤال ۵',
question: 'این مثال تضمین می‌کند هر سرمایه‌گذاری واقعی همیشه همین رشد را دارد.',
correctAnswerId: 'false',
explanation: 'خیر. مثال نرخ ثابت و بدون هزینه است. سود واقعی ممکن است تغییر کند، زیان پیش بیاید و هزینه یا تورم نتیجه را عوض کند.',
},
{
id: 'compound-summary',
type: 'summary',
title: 'جمع‌بندی',
body: 'رشد ساده فقط به مبلغ اولیه نگاه می‌کند. رشد مرکب به موجودی جدید نگاه می‌کند. این تفاوت در همین مثال بعد از سه دوره ۱۳۳٫۱ را در برابر ۱۳۰ نشان داد، ولی تضمین دنیای واقعی نیست.',
highlight: 'رشد روی موجودی جدید، تضمین سود واقعی نیست.',
},
],
}
+54
View File
@@ -0,0 +1,54 @@
export interface GrowthRow {
methodId: 'simple' | 'compound'
label: string
startTenths: number
growthTenths: number
endTenths: number
}
export interface GrowthPeriod {
index: number
rows: GrowthRow[]
}
export interface GrowthExample {
principalTenths: number
ratePercent: number
periods: GrowthPeriod[]
}
export function buildGrowthExample(principalCoins = 100, ratePercent = 10, periodCount = 3): GrowthExample {
if (!Number.isInteger(principalCoins) || !Number.isInteger(ratePercent) || !Number.isInteger(periodCount)) {
throw new Error('پارامتر رشد باید عدد صحیح باشد')
}
const principalTenths = principalCoins * 10
const periods: GrowthPeriod[] = [
{
index: 0,
rows: [
{ methodId: 'simple', label: 'رشد ساده', startTenths: principalTenths, growthTenths: 0, endTenths: principalTenths },
{ methodId: 'compound', label: 'رشد مرکب', startTenths: principalTenths, growthTenths: 0, endTenths: principalTenths },
],
},
]
let compound = principalTenths
for (let index = 1; index <= periodCount; index += 1) {
const simpleStart = principalTenths + ((principalTenths * ratePercent) / 100) * (index - 1)
const simpleGrowth = (principalTenths * ratePercent) / 100
const compoundGrowth = (compound * ratePercent) / 100
if (![simpleStart, simpleGrowth, compoundGrowth].every(Number.isInteger)) {
throw new Error('این نرخ با واحد یک‌دهم سکه دقیق نیست')
}
const simpleEnd = simpleStart + simpleGrowth
const compoundEnd = compound + compoundGrowth
periods.push({
index,
rows: [
{ methodId: 'simple', label: 'رشد ساده', startTenths: simpleStart, growthTenths: simpleGrowth, endTenths: simpleEnd },
{ methodId: 'compound', label: 'رشد مرکب', startTenths: compound, growthTenths: compoundGrowth, endTenths: compoundEnd },
],
})
compound = compoundEnd
}
return { principalTenths, ratePercent, periods }
}
+19 -2
View File
@@ -121,7 +121,7 @@ export function gradeLessonSubmission(
}
validateSortAnswer(step, submitted.orderedItemIds)
} else {
if (!isChoiceLessonAnswer(submitted)) {
if (!isChoiceLessonAnswer(submitted) || ('orderedItemIds' in submitted && submitted.orderedItemIds != null)) {
throw new LessonGradingError(400, `فرمت پاسخ برای سؤال ${questionId} نامعتبر است`)
}
validateChoiceAnswer(step, submitted.answerId)
@@ -132,6 +132,16 @@ export function gradeLessonSubmission(
}
}
const feedbackQuestions = gradable.flatMap(({ questionId, step }) => {
if (!('explanation' in step) || !step.explanation) return []
const submitted = byQuestion.get(questionId)
return [{
id: questionId,
earned: submitted ? isAnswerCorrect(step, submitted) : false,
explanation: step.explanation,
}]
})
for (const answer of answers) {
const known = gradable.some((g) => g.questionId === answer.questionId)
if (!known) {
@@ -142,5 +152,12 @@ export function gradeLessonSubmission(
const totalQuestions = gradable.length
const score = Math.round((correctCount / totalQuestions) * 100)
return { score, correctCount, totalQuestions }
return {
score,
correctCount,
totalQuestions,
...(feedbackQuestions.length === gradable.length
? { feedback: { questions: feedbackQuestions } }
: {}),
}
}
+2
View File
@@ -1,4 +1,5 @@
import type { LessonDefinition } from './lesson.types.js'
import { compoundInterestLesson } from './compound-interest.lesson.js'
import { inflationLesson } from './inflation.lesson.js'
import { whatIsMoneyLesson } from './what-is-money.lesson.js'
import { savingLesson } from './saving.lesson.js'
@@ -14,6 +15,7 @@ const PLAYABLE_LESSONS: Record<string, LessonDefinition> = {
'what-is-gold': whatIsGoldLesson,
'gold-market': goldMarketLesson,
inflation: inflationLesson,
'compound-interest': compoundInterestLesson,
}
export function getPlayableLesson(activityKey: string): LessonDefinition | null {
@@ -11,6 +11,7 @@ const STRIP_KEYS = new Set([
'correctOption',
'answerKey',
'correctOrder',
'explanation',
])
function sanitizeStep(step: LessonStep): SanitizedLessonStep {
+36 -1
View File
@@ -1,4 +1,4 @@
export type LessonStepType = 'story' | 'single_choice' | 'true_false' | 'sort' | 'summary'
export type LessonStepType = 'story' | 'single_choice' | 'true_false' | 'sort' | 'summary' | 'growth_table'
export interface LessonOption {
id: string
@@ -22,12 +22,38 @@ export interface SingleChoiceStep extends LessonStepBase {
question: string
options: LessonOption[]
correctAnswerId: string
explanation?: string
}
export interface TrueFalseStep extends LessonStepBase {
type: 'true_false'
question: string
correctAnswerId: 'true' | 'false'
explanation?: string
}
export interface GrowthTableRow {
methodId: string
label: string
startTenths: number
growthTenths: number
endTenths: number
}
export interface GrowthTablePeriod {
index: number
rows: GrowthTableRow[]
}
export interface GrowthTableStep extends LessonStepBase {
type: 'growth_table'
body: string
disclaimer: string
unitLabel: string
principalTenths: number
ratePercent: number
methods: Array<{ id: string; label: string; note: string }>
periods: GrowthTablePeriod[]
}
export interface SortStep extends LessonStepBase {
@@ -49,6 +75,7 @@ export type LessonStep =
| TrueFalseStep
| SortStep
| SummaryStep
| GrowthTableStep
export interface LessonDefinition {
id: string
@@ -76,10 +103,17 @@ export interface SortLessonAnswer {
export type LessonSubmissionAnswer = ChoiceLessonAnswer | SortLessonAnswer
export interface LessonAnswerFeedback {
id: string
earned: boolean
explanation: string
}
export interface GradeLessonResult {
score: number
correctCount: number
totalQuestions: number
feedback?: { questions: LessonAnswerFeedback[] }
}
export type SanitizedLessonStep =
@@ -88,6 +122,7 @@ export type SanitizedLessonStep =
| Omit<TrueFalseStep, 'correctAnswerId'>
| Omit<SortStep, 'correctOrder'>
| Omit<SummaryStep, never>
| GrowthTableStep
export interface SanitizedLessonDefinition {
id: string
+29 -2
View File
@@ -1,6 +1,19 @@
import type { MeResponse } from '../../shared/types/user.types'
import type { sanitizeLesson } from '../lessons/lesson.sanitizer'
import type { LessonSubmissionAnswer } from '../lessons/lesson.types'
import type { BudgetAllocation, BudgetMissionFeedback, BudgetMissionPublic } from '../budgeting/budgeting.types'
import type { RiskReturnFeedback, RiskReturnPublic } from '../risk-return/risk-return.types'
import type { PortfolioFeedback, PortfolioMissionPublic } from '../portfolio/portfolio.types'
import type { SmartShopFeedback, SmartShopPublic } from '../smart-shop/shop.types'
import type { FinancialGoalFeedback, FinancialGoalPublic } from '../financial-goal/goal.types'
import type { CastleExamFeedback, CastleExamPublic } from '../castle-exam/castle.types'
import type { LessonAnswerFeedback, LessonSubmissionAnswer } from '../lessons/lesson.types'
export interface BudgetLevelAnswer {
questionId: string
allocations: BudgetAllocation
}
export type LevelSubmissionAnswer = LessonSubmissionAnswer | BudgetLevelAnswer
export type LevelStatus =
| 'locked'
@@ -21,7 +34,7 @@ export interface LevelProgressRow {
}
export interface CompleteLevelSubmission {
answers: LessonSubmissionAnswer[]
answers: LevelSubmissionAnswer[]
durationMs?: number
}
@@ -59,6 +72,12 @@ export interface LevelDetailResponse {
activity: {
type: string
lesson?: ReturnType<typeof sanitizeLesson>
budgeting?: BudgetMissionPublic
riskReturn?: RiskReturnPublic
portfolio?: PortfolioMissionPublic
smartShop?: SmartShopPublic
financialGoal?: FinancialGoalPublic
castleExam?: CastleExamPublic
} | null
}
@@ -72,4 +91,12 @@ export interface CompleteLevelResponse {
reward: { xp: number; coins: number }
unlockedLevelId: number | null
me: MeResponse
feedback?:
| BudgetMissionFeedback
| RiskReturnFeedback
| PortfolioFeedback
| SmartShopFeedback
| FinancialGoalFeedback
| CastleExamFeedback
| { questions: LessonAnswerFeedback[] }
}
+7 -7
View File
@@ -99,7 +99,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
activityKey: 'budgeting',
rewardXp: 45,
rewardCoins: 18,
isActive: false,
isActive: true,
},
{
id: 8,
@@ -111,7 +111,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
activityKey: 'risk-return',
rewardXp: 50,
rewardCoins: 20,
isActive: false,
isActive: true,
},
{
id: 9,
@@ -123,7 +123,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
activityKey: 'portfolio',
rewardXp: 50,
rewardCoins: 20,
isActive: false,
isActive: true,
},
{
id: 10,
@@ -135,7 +135,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
activityKey: 'compound-interest',
rewardXp: 45,
rewardCoins: 18,
isActive: false,
isActive: true,
},
{
id: 11,
@@ -147,7 +147,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
activityKey: 'smart-shop',
rewardXp: 45,
rewardCoins: 18,
isActive: false,
isActive: true,
},
{
id: 12,
@@ -159,7 +159,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
activityKey: 'financial-goal',
rewardXp: 50,
rewardCoins: 20,
isActive: false,
isActive: true,
},
{
id: 13,
@@ -171,7 +171,7 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
activityKey: 'castle-exam',
rewardXp: 100,
rewardCoins: 50,
isActive: false,
isActive: true,
},
]
+118
View File
@@ -0,0 +1,118 @@
import { PORTFOLIO_STEP, PORTFOLIO_TOTAL, simulatePortfolio } from './portfolio.math'
import { getPortfolioQuestionBank, portfolioMission } from './portfolio.mission'
import type { PortfolioFeedback, PortfolioMissionPublic } from './portfolio.types'
import type { PortfolioHolding } from './portfolio.math'
export class PortfolioGradingError extends Error {
statusCode: number
constructor(statusCode: number, message: string) {
super(message)
this.statusCode = statusCode
}
}
const FUND_IDS = ['sun', 'rain', 'wind'] as const
export interface PortfolioSubmissionAnswer {
questionId?: string
answerId?: string
holdings?: PortfolioHolding
orderedItemIds?: string[]
allocations?: unknown
}
function assertHolding(value: unknown): PortfolioHolding {
if (typeof value !== 'object' || value === null) {
throw new PortfolioGradingError(400, 'ترکیب سبد نامعتبر است')
}
const record = value as Record<string, unknown>
const unknown = Object.keys(record).filter((key) => !FUND_IDS.includes(key as (typeof FUND_IDS)[number]))
if (unknown.length > 0) {
throw new PortfolioGradingError(400, 'صندوق ناشناخته در سبد')
}
const holding = {} as PortfolioHolding
for (const id of FUND_IDS) {
const amount = record[id]
if (typeof amount !== 'number' || !Number.isInteger(amount) || amount < 0) {
throw new PortfolioGradingError(400, 'مبلغ صندوق نامعتبر است')
}
if (amount % PORTFOLIO_STEP !== 0) {
throw new PortfolioGradingError(400, 'مبلغ‌ها باید مضرب ۱۰ باشند')
}
holding[id] = amount
}
const sum = holding.sun + holding.rain + holding.wind
if (sum !== PORTFOLIO_TOTAL) {
throw new PortfolioGradingError(400, 'مجموع سبد باید دقیقاً ۱۰۰ سکه باشد')
}
return holding
}
export function gradePortfolioSubmission(answers: PortfolioSubmissionAnswer[]): {
score: number
feedback: PortfolioFeedback
} {
if (!Array.isArray(answers)) {
throw new PortfolioGradingError(400, 'فرمت پاسخ نامعتبر است')
}
const bank = getPortfolioQuestionBank()
const seen = new Set<string>()
let holding: PortfolioHolding | null = null
const chosen = new Map<string, string>()
for (const answer of answers) {
if (!answer || typeof answer.questionId !== 'string') {
throw new PortfolioGradingError(400, 'شناسهٔ پاسخ نامعتبر است')
}
if (seen.has(answer.questionId)) {
throw new PortfolioGradingError(400, `پاسخ ${answer.questionId} تکراری است`)
}
seen.add(answer.questionId)
if (answer.orderedItemIds !== undefined || answer.allocations !== undefined) {
throw new PortfolioGradingError(400, 'فیلد غیرمجاز در پاسخ سبد')
}
if (answer.questionId === 'portfolio') {
holding = assertHolding(answer.holdings)
continue
}
if (typeof answer.answerId !== 'string') {
throw new PortfolioGradingError(400, 'نوع پاسخ سؤال نامعتبر است')
}
const question = bank.find((item) => item.id === answer.questionId)
if (!question) {
throw new PortfolioGradingError(400, `سؤال ${answer.questionId} پیدا نشد`)
}
if (!question.options.some((option) => option.id === answer.answerId)) {
throw new PortfolioGradingError(400, `گزینهٔ نامعتبر برای سؤال ${answer.questionId}`)
}
chosen.set(answer.questionId, answer.answerId)
}
if (!holding) {
throw new PortfolioGradingError(400, 'ترکیب سبد ارسال نشده')
}
if (chosen.size !== bank.length) {
throw new PortfolioGradingError(400, 'پاسخ همهٔ سؤال‌ها لازم است')
}
const questions = bank.map((question) => ({
id: question.id,
earned: chosen.get(question.id) === question.correctOptionId,
explanation: question.explanation,
}))
return {
score: questions.filter((question) => question.earned).length * 20,
feedback: {
summary: portfolioMission.summary,
scenarios: simulatePortfolio(holding),
questions,
},
}
}
export function getPortfolioPublic(): PortfolioMissionPublic {
return portfolioMission
}
+83
View File
@@ -0,0 +1,83 @@
export const PORTFOLIO_TOTAL = 100
export const PORTFOLIO_STEP = 10
export interface PortfolioHolding {
sun: number
rain: number
wind: number
}
export interface PortfolioScenarioResult {
id: string
title: string
note: string
parts: Array<{ fundId: 'sun' | 'rain' | 'wind'; label: string; value: number }>
total: number
changeLabel: string
referenceSunTotal: number
referenceChangeLabel: string
}
export function applyPercent(amount: number, percent: number): number {
return (amount * (100 + percent)) / 100
}
export function changeLabel(total: number, baseline = PORTFOLIO_TOTAL): string {
const delta = total - baseline
if (delta > 0) return `سود ${delta} سکه`
if (delta < 0) return `زیان ${Math.abs(delta)} سکه`
return 'بدون تغییر'
}
const FUNDS = [
{ id: 'sun' as const, label: 'آفتاب' },
{ id: 'rain' as const, label: 'باران' },
{ id: 'wind' as const, label: 'باد' },
]
const SCENARIOS = [
{
id: 'sunny',
title: 'هوای آفتابی',
note: 'مثال ساختگی است، نه پیش‌بینی. آفتاب ۲۰٪ بیشتر، باران ۱۰٪ کمتر، باد بدون تغییر.',
percents: { sun: 20, rain: -10, wind: 0 },
},
{
id: 'rainy',
title: 'روز بارانی',
note: 'مثال ساختگی است. آفتاب ۲۰٪ کمتر، باران ۱۰٪ بیشتر، باد بدون تغییر.',
percents: { sun: -20, rain: 10, wind: 0 },
},
{
id: 'drop',
title: 'افت همگانی',
note: 'مثال ساختگی است. هر سه صندوق ۱۰٪ کمتر می‌شوند.',
percents: { sun: -10, rain: -10, wind: -10 },
},
]
export function simulatePortfolio(holding: PortfolioHolding): PortfolioScenarioResult[] {
const reference: PortfolioHolding = { sun: PORTFOLIO_TOTAL, rain: 0, wind: 0 }
return SCENARIOS.map((scenario) => {
const parts = FUNDS.map((fund) => ({
fundId: fund.id,
label: fund.label,
value: applyPercent(holding[fund.id], scenario.percents[fund.id]),
}))
const total = parts.reduce((sum, part) => sum + part.value, 0)
const referenceSunTotal = FUNDS.reduce(
(sum, fund) => sum + applyPercent(reference[fund.id], scenario.percents[fund.id]),
0,
)
return {
id: scenario.id,
title: scenario.title,
note: scenario.note,
parts,
total,
changeLabel: changeLabel(total),
referenceSunTotal,
referenceChangeLabel: changeLabel(referenceSunTotal),
}
})
}
@@ -0,0 +1,124 @@
import type { PortfolioMissionPublic } from './portfolio.types'
export const PORTFOLIO_ACTIVITY_KEY = 'portfolio'
const questions = [
{
id: 'q-one',
prompt: 'قرار دادن همه پول در یک گزینه چه اثری دارد؟',
options: [
{ id: 'depend', label: 'نتیجه بیشتر به همان گزینه وابسته می‌شود' },
{ id: 'safe', label: 'زیان کاملاً ناممکن می‌شود' },
{ id: 'best', label: 'حتماً بهترین نتیجه آینده را می‌سازد' },
],
correctOptionId: 'depend',
explanation: 'وقتی همه پول در یک گزینه باشد، بالا و پایین همان گزینه تقریباً کل سبد را جابه‌جا می‌کند.',
},
{
id: 'q-loss',
prompt: 'آیا تقسیم پول بین چند گزینه زیان را ناممکن می‌کند؟',
options: [
{ id: 'yes', label: 'بله، دیگر هیچ زیانی ممکن نیست' },
{ id: 'no', label: 'خیر؛ افت همگانی می‌تواند چند گزینه را هم‌زمان کم کند' },
],
correctOptionId: 'no',
explanation: 'در سناریوی افت همگانی هر سه صندوق ۱۰٪ کمتر شدند. تنوع، زیان را حذف نکرد.',
},
{
id: 'q-name',
prompt: 'اگر دو صندوق اسم متفاوت داشته باشند ولی همیشه یکسان تغییر کنند، آیا فقط اسم متفاوت تنوع مؤثر می‌سازد؟',
options: [
{ id: 'yes', label: 'بله، اسم متفاوت کافی است' },
{ id: 'no', label: 'خیر؛ رفتار و وابستگی آن‌ها مهم است' },
],
correctOptionId: 'no',
explanation: 'اسم جدا به‌تنهایی تنوع مؤثر نیست. اگر همیشه مثل هم حرکت کنند، وابستگی‌شان یکی است.',
},
{
id: 'q-best',
prompt: 'آیا سبدی که در یک سناریو بیشترین سود را داشته، حتماً برای آینده بهترین است؟',
options: [
{ id: 'yes', label: 'بله، همان سبد برای آینده بهترین است' },
{ id: 'no', label: 'خیر؛ یک سناریو آینده را تضمین نمی‌کند' },
],
correctOptionId: 'no',
explanation: 'یک مثال ساختگی، آینده را تضمین نمی‌کند. هیچ سبدی را فقط به‌خاطر یک سناریو بهترین نمی‌دانیم.',
},
{
id: 'q-approach',
prompt: 'برای ساخت سبد، کدام رویکرد مناسب‌تر است؟',
options: [
{ id: 'count', label: 'هرچه تعداد گزینه‌ها بیشتر باشد کافی است' },
{ id: 'balanced', label: 'توجه به هدف، زمان نیاز به پول و ریسک؛ در مسائل واقعی کمک از بزرگسال قابل اعتماد' },
{ id: 'chase', label: 'فقط گزینه‌ای را بردار که در یک مثال بیشترین سود را داشت' },
],
correctOptionId: 'balanced',
explanation: 'تعداد زیاد به‌تنهایی تنوع مؤثر نیست. هدف، زمان نیاز به پول و ریسک مهم‌اند و موضوع واقعی به کمک بزرگسال نیاز دارد.',
},
] as const
export const portfolioMission: PortfolioMissionPublic = {
id: 'portfolio',
version: 1,
title: 'سبد سرمایه‌گذاری',
guideTitle: 'راهنما',
guideImageKey: 'professor',
totalCoins: 100,
step: 10,
intro:
'در مسیر قلعه ۱۰۰ سکهٔ آموزشی داری. این سکه‌ها از کیف پول بازی کم نمی‌شوند و نام صندوق‌ها دارایی واقعی نیستند. تقسیم پول می‌تواند وابستگی به یک گزینه را کم کند، ولی زیان را حذف یا سود را تضمین نمی‌کند.',
rules: [
'هر بار ۱۰ سکه جابه‌جا می‌شود.',
'مجموع باید دقیقاً ۱۰۰ سکه باشد.',
'گذاشتن همهٔ پول در یک صندوق مجاز است تا اثرش را ببینی.',
'تعداد صندوق‌ها یا سود یک سناریو نمره نمی‌دهد.',
],
funds: [
{ id: 'sun', label: 'آفتاب', hint: 'در مثال آفتابی بیشتر و در مثال بارانی کمتر می‌شود.' },
{ id: 'rain', label: 'باران', hint: 'در مثال بارانی بیشتر و در مثال آفتابی کمتر می‌شود.' },
{ id: 'wind', label: 'باد', hint: 'در دو مثال اول بدون تغییر می‌ماند.' },
],
scenarios: [
{
id: 'sunny',
title: 'هوای آفتابی',
note: 'مثال است، نه پیش‌بینی و نه همهٔ حالت‌های واقعی.',
changes: [
{ fundId: 'sun', percent: 20 },
{ fundId: 'rain', percent: -10 },
{ fundId: 'wind', percent: 0 },
],
},
{
id: 'rainy',
title: 'روز بارانی',
note: 'مثال است. برای این حالت‌ها احتمال تعریف نشده.',
changes: [
{ fundId: 'sun', percent: -20 },
{ fundId: 'rain', percent: 10 },
{ fundId: 'wind', percent: 0 },
],
},
{
id: 'drop',
title: 'افت همگانی',
note: 'مثال است. هر سه صندوق با هم کمتر می‌شوند.',
changes: [
{ fundId: 'sun', percent: -10 },
{ fundId: 'rain', percent: -10 },
{ fundId: 'wind', percent: -10 },
],
},
],
questions: questions.map(({ id, prompt, options }) => ({
id,
prompt,
options: options.map((option) => ({ id: option.id, label: option.label })),
})),
summary:
'تقسیم منابع می‌تواند وابستگی به یک گزینه را کم کند، اما زیان را حذف یا سود را تضمین نمی‌کند. اسم زیاد به‌تنهایی تنوع مؤثر نیست.',
}
export function getPortfolioQuestionBank() {
return questions
}
@@ -0,0 +1,46 @@
export interface PortfolioFundPublic {
id: 'sun' | 'rain' | 'wind'
label: string
hint: string
}
export interface PortfolioScenarioPublic {
id: string
title: string
note: string
changes: Array<{ fundId: 'sun' | 'rain' | 'wind'; percent: number }>
}
export interface PortfolioQuestionPublic {
id: string
prompt: string
options: Array<{ id: string; label: string }>
}
export interface PortfolioMissionPublic {
id: 'portfolio'
version: number
title: string
guideTitle: string
guideImageKey: string
totalCoins: number
step: number
intro: string
rules: string[]
funds: PortfolioFundPublic[]
scenarios: PortfolioScenarioPublic[]
questions: PortfolioQuestionPublic[]
summary: string
}
export interface PortfolioQuestionFeedback {
id: string
earned: boolean
explanation: string
}
export interface PortfolioFeedback {
summary: string
scenarios: import('./portfolio.math').PortfolioScenarioResult[]
questions: PortfolioQuestionFeedback[]
}
@@ -0,0 +1,75 @@
import { getRiskQuestionBank, riskReturnMission } from './risk-return.mission'
import type { RiskReturnFeedback, RiskReturnPublic } from './risk-return.types'
export class RiskReturnGradingError extends Error {
statusCode: number
constructor(statusCode: number, message: string) {
super(message)
this.statusCode = statusCode
}
}
export interface RiskSubmissionAnswer {
questionId?: string
answerId?: string
orderedItemIds?: string[]
allocations?: unknown
}
export function gradeRiskReturnSubmission(answers: RiskSubmissionAnswer[]): {
score: number
feedback: RiskReturnFeedback
} {
if (!Array.isArray(answers)) {
throw new RiskReturnGradingError(400, 'فرمت پاسخ نامعتبر است')
}
const bank = getRiskQuestionBank()
const seen = new Set<string>()
const byId = new Map<string, string>()
for (const answer of answers) {
if (!answer || typeof answer.questionId !== 'string' || typeof answer.answerId !== 'string') {
throw new RiskReturnGradingError(400, 'نوع پاسخ نامعتبر است')
}
if (answer.orderedItemIds !== undefined || answer.allocations !== undefined) {
throw new RiskReturnGradingError(400, 'فیلد غیرمجاز در پاسخ')
}
if (seen.has(answer.questionId)) {
throw new RiskReturnGradingError(400, `سؤال ${answer.questionId} تکراری است`)
}
const question = bank.find((item) => item.id === answer.questionId)
if (!question) {
throw new RiskReturnGradingError(400, `سؤال ${answer.questionId} پیدا نشد`)
}
if (!question.options.some((option) => option.id === answer.answerId)) {
throw new RiskReturnGradingError(400, `گزینهٔ نامعتبر برای سؤال ${answer.questionId}`)
}
seen.add(answer.questionId)
byId.set(answer.questionId, answer.answerId)
}
if (byId.size !== bank.length) {
throw new RiskReturnGradingError(400, 'پاسخ همهٔ فعالیت‌ها لازم است')
}
const questions = bank.map((question) => ({
id: question.id,
earned: byId.get(question.id) === question.correctOptionId,
explanation: question.explanation,
}))
const correctCount = questions.filter((question) => question.earned).length
return {
score: correctCount * 20,
feedback: {
summary: riskReturnMission.summary,
questions,
},
}
}
export function getRiskReturnPublic(): RiskReturnPublic {
return riskReturnMission
}
@@ -0,0 +1,89 @@
import type { RiskReturnPublic } from './risk-return.types'
export const RISK_ACTIVITY_KEY = 'risk-return'
const questions = [
{
id: 'q-loss',
prompt: 'کدام پیشنهاد در این مثال امکان برگشت کمتر از ۲۰ سکه دارد؟',
options: [
{ id: 'a', label: 'پیشنهاد الف' },
{ id: 'b', label: 'پیشنهاد ب' },
{ id: 'neither', label: 'هیچ‌کدام' },
],
correctOptionId: 'b',
explanation: 'در پیشنهاد ب یک کارت ۸ سکه است، یعنی کمتر از ۲۰. در پیشنهاد الف هر چهار کارت ۲۰ یا ۲۲ است.',
},
{
id: 'q-certain',
prompt: 'آیا در پیشنهاد ب حتماً ۳۶ سکه دریافت می‌کنی؟',
options: [
{ id: 'yes', label: 'بله، حتماً ۳۶ سکه می‌گیری' },
{ id: 'no', label: 'خیر، ۳۶ فقط یکی از نتیجه‌های ممکن است' },
],
correctOptionId: 'no',
explanation: '۳۶ فقط یکی از چهار کارت است. آمدن آن قطعی نیست.',
},
{
id: 'q-need',
prompt: 'دو روز دیگر برای یک نیاز ضروری به تمام ۲۰ سکه نیاز داری. تصمیم مناسب‌تر چیست؟',
options: [
{ id: 'a', label: 'همین ۲۰ سکه را به پیشنهاد الف بده' },
{ id: 'b', label: 'همین ۲۰ سکه را به پیشنهاد ب بده' },
{ id: 'keep', label: 'این پول ضروری را وارد پیشنهادهای نامطمئن نکن' },
],
correctOptionId: 'keep',
explanation: 'پولی که به‌زودی برای نیاز ضروری لازم است نباید وارد انتخاب نامطمئن شود. هیچ‌کدام از دو پیشنهاد برای این موقعیت مناسب نیست.',
},
{
id: 'q-compare',
prompt: 'درباره مقایسه دو پیشنهاد کدام جمله درست است؟',
options: [
{ id: 'certain-gain', label: 'پیشنهاد ب حتماً سود بیشتری می‌دهد' },
{ id: 'range', label: 'پیشنهاد ب امکان نتیجه بالاتر و زیان بیشتری دارد؛ سود بیشتر قطعی نیست' },
{ id: 'same', label: 'هر دو پیشنهاد دقیقاً یک نتیجه‌اند' },
],
correctOptionId: 'range',
explanation: 'پیشنهاد ب هم کارت ۸ دارد و هم کارت ۳۶. نتیجهٔ بالاتر ممکن است، ولی تضمین نشده.',
},
{
id: 'q-promise',
prompt: 'اگر کسی بگوید «سود خیلی زیاد، بدون هیچ ریسک و حتماً»، واکنش مناسب چیست؟',
options: [
{ id: 'trust', label: 'وعده را تضمین معتبر حساب کن و سریع بپذیر' },
{ id: 'check', label: 'وعده را تضمین معتبر فرض نکن، بررسی کن و از بزرگسال قابل اعتماد کمک بگیر' },
{ id: 'big-number', label: 'فقط به عدد بزرگ نگاه کن' },
],
correctOptionId: 'check',
explanation: 'وعدهٔ سود زیاد و بدون ریسک، تضمین معتبر نیست. باید بررسی شود و از بزرگسال قابل اعتماد کمک بگیری.',
},
] as const
export const riskReturnMission: RiskReturnPublic = {
id: 'risk-return',
version: 1,
title: 'ریسک و بازده',
guideTitle: 'راهنما',
guideImageKey: 'professor',
stakeCoins: 20,
intro:
'در بودجه‌بندی دیدی پول نیاز ضروری با پول هدف فرق دارد. حالا در مسیر قلعه، ۲۰ سکهٔ آموزشی و کاملاً خیالی را فقط برای فکر کردن نگاه می‌کنی. این سکه‌ها از کیف پول بازی‌ات کم نمی‌شوند و هیچ دارایی واقعی پیشنهاد نمی‌شود.',
disclaimer:
'در این مثال ساختگی، هر کارت شانس یکسان دارد. در دنیای واقعی همیشه احتمال‌ها را دقیق نمی‌دانیم.',
exampleLimit:
'پیشنهاد الف فقط در همین مثال زیان اسمی ندارد. این ویژگی تضمین عمومی برای سرمایه‌گذاری‌ها نیست.',
summary: 'امکان سود بیشتر، تضمین سود نیست؛ انتخاب به هدف و زمان نیاز به پول بستگی دارد.',
offers: [
{ id: 'a', label: 'پیشنهاد الف', outcomes: [20, 20, 22, 22] },
{ id: 'b', label: 'پیشنهاد ب', outcomes: [8, 16, 28, 36] },
],
questions: questions.map(({ id, prompt, options }) => ({
id,
prompt,
options: options.map((option) => ({ id: option.id, label: option.label })),
})),
}
export function getRiskQuestionBank() {
return questions
}
@@ -0,0 +1,42 @@
export interface RiskOffer {
id: 'a' | 'b'
label: string
outcomes: number[]
}
export interface RiskOption {
id: string
label: string
}
export interface RiskQuestionPublic {
id: string
prompt: string
options: RiskOption[]
}
export interface RiskReturnPublic {
id: 'risk-return'
version: number
title: string
guideTitle: string
guideImageKey: string
stakeCoins: number
intro: string
disclaimer: string
exampleLimit: string
summary: string
offers: RiskOffer[]
questions: RiskQuestionPublic[]
}
export interface RiskQuestionFeedback {
id: string
earned: boolean
explanation: string
}
export interface RiskReturnFeedback {
summary: string
questions: RiskQuestionFeedback[]
}
+193 -5
View File
@@ -16,6 +16,24 @@ import type {
LevelProgressRow,
LevelStatus,
} from '../levels/level.types'
import { BudgetingGradingError, getBudgetMissionPublic, gradeBudgetSubmission } from '../budgeting/budgeting.grader'
import { BUDGET_ACTIVITY_KEY } from '../budgeting/budgeting.mission'
import { getRiskReturnPublic, gradeRiskReturnSubmission, RiskReturnGradingError } from '../risk-return/risk-return.grader'
import { RISK_ACTIVITY_KEY } from '../risk-return/risk-return.mission'
import { getPortfolioPublic, gradePortfolioSubmission, PortfolioGradingError } from '../portfolio/portfolio.grader'
import { PORTFOLIO_ACTIVITY_KEY } from '../portfolio/portfolio.mission'
import { getSmartShopPublic, gradeSmartShopSubmission, SmartShopGradingError } from '../smart-shop/shop.grader'
import { SMART_SHOP_ACTIVITY_KEY } from '../smart-shop/shop.mission'
import { getFinancialGoalPublic, gradeFinancialGoalSubmission, FinancialGoalGradingError } from '../financial-goal/goal.grader'
import { FINANCIAL_GOAL_ACTIVITY_KEY } from '../financial-goal/goal.mission'
import { getCastleExamPublic, gradeCastleExam, CastleExamGradingError } from '../castle-exam/castle.grader'
import { CASTLE_ACTIVITY_KEY } from '../castle-exam/castle.mission'
import type { BudgetMissionFeedback } from '../budgeting/budgeting.types'
import type { RiskReturnFeedback } from '../risk-return/risk-return.types'
import type { PortfolioFeedback } from '../portfolio/portfolio.types'
import type { SmartShopFeedback } from '../smart-shop/shop.types'
import type { FinancialGoalFeedback } from '../financial-goal/goal.types'
import type { CastleExamFeedback } from '../castle-exam/castle.types'
import { loadLessonForLevel } from '../lessons/lesson.loader'
import {
gradeLessonSubmission,
@@ -219,6 +237,60 @@ export class GoldLandProgressionService {
lesson: stripSensitiveFields(sanitizeLesson(lesson)),
}
}
} else if (
canAccessContent &&
definition?.activityType === 'game' &&
definition.activityKey === BUDGET_ACTIVITY_KEY
) {
activity = {
type: 'budgeting',
budgeting: getBudgetMissionPublic(),
}
} else if (
canAccessContent &&
definition?.activityType === 'challenge' &&
definition.activityKey === RISK_ACTIVITY_KEY
) {
activity = {
type: 'risk-return',
riskReturn: getRiskReturnPublic(),
}
} else if (
canAccessContent &&
definition?.activityType === 'game' &&
definition.activityKey === PORTFOLIO_ACTIVITY_KEY
) {
activity = {
type: 'portfolio',
portfolio: getPortfolioPublic(),
}
} else if (
canAccessContent &&
definition?.activityType === 'game' &&
definition.activityKey === SMART_SHOP_ACTIVITY_KEY
) {
activity = {
type: 'smart-shop',
smartShop: getSmartShopPublic(),
}
} else if (
canAccessContent &&
definition?.activityType === 'challenge' &&
definition.activityKey === FINANCIAL_GOAL_ACTIVITY_KEY
) {
activity = {
type: 'financial-goal',
financialGoal: getFinancialGoalPublic(),
}
} else if (
canAccessContent &&
definition?.activityType === 'final_exam' &&
definition.activityKey === CASTLE_ACTIVITY_KEY
) {
activity = {
type: 'castle-exam',
castleExam: getCastleExamPublic(),
}
}
return stripSensitiveFields({
@@ -288,10 +360,27 @@ export class GoldLandProgressionService {
throw new ProgressionError(403, 'این مرحله هنوز آماده نیست')
}
const score = this.gradeSubmission(levelId, submission)
const graded = this.gradeSubmission(levelId, submission)
const score = graded.score
const newStars = starsFromScore(score)
if (newStars === 0) {
throw new ProgressionError(400, 'امتیاز کافی برای تکمیل مرحله نبود. دوباره تلاش کن!')
if (definition.activityKey !== CASTLE_ACTIVITY_KEY || !graded.feedback) {
throw new ProgressionError(400, 'امتیاز کافی برای تکمیل مرحله نبود. دوباره تلاش کن!')
}
const existing = this.getProgressRow(userId, levelId)
const state = this.users.getUserState(userId)
return {
levelId,
score,
stars: (existing?.stars ?? 0) as 0 | 1 | 2 | 3,
previousStars: (existing?.stars ?? 0) as 0 | 1 | 2 | 3,
isFirstCompletion: false,
bestScoreImproved: false,
reward: { xp: 0, coins: 0 },
unlockedLevelId: null,
me: this.users.toMeResponse(state),
feedback: graded.feedback,
}
}
return this.database.transaction(() => {
@@ -379,6 +468,7 @@ export class GoldLandProgressionService {
reward: { xp: totalXp, coins: totalCoins },
unlockedLevelId,
me: this.users.toMeResponse(state),
...(graded.feedback ? { feedback: graded.feedback } : {}),
}
})
}
@@ -394,7 +484,10 @@ export class GoldLandProgressionService {
return item
}
private gradeSubmission(levelId: number, submission: CompleteLevelSubmission): number {
private gradeSubmission(
levelId: number,
submission: CompleteLevelSubmission,
): { score: number; feedback?: BudgetMissionFeedback | RiskReturnFeedback | PortfolioFeedback | SmartShopFeedback | FinancialGoalFeedback | CastleExamFeedback | { questions: import('../lessons/lesson.types').LessonAnswerFeedback[] } } {
const definition = getLevelDefinition(levelId)
if (!definition) throw new ProgressionError(404, 'مرحله پیدا نشد')
@@ -405,10 +498,31 @@ export class GoldLandProgressionService {
if (!lesson) {
throw new ProgressionError(403, 'محتوای این درس هنوز آماده نیست')
}
if (
submission.answers?.some(
(answer) =>
answer != null &&
typeof answer === 'object' &&
'allocations' in answer &&
answer.allocations != null,
) ||
submission.answers?.some(
(answer) =>
answer != null &&
typeof answer === 'object' &&
'holdings' in answer &&
answer.holdings != null,
)
) {
throw new ProgressionError(400, 'قالب پاسخ درس نامعتبر است')
}
try {
const result = gradeLessonSubmission(lesson, submission.answers ?? [])
return result.score
const result = gradeLessonSubmission(
lesson,
(submission.answers ?? []) as import('../lessons/lesson.types').LessonSubmissionAnswer[],
)
return { score: result.score, ...(result.feedback ? { feedback: result.feedback } : {}) }
} catch (error) {
if (error instanceof LessonGradingError) {
throw new ProgressionError(error.statusCode, error.message)
@@ -417,6 +531,80 @@ export class GoldLandProgressionService {
}
}
if (definition.activityType === 'game' && definition.activityKey === BUDGET_ACTIVITY_KEY) {
try {
return gradeBudgetSubmission(
(submission.answers ?? []) as import('../budgeting/budgeting.grader').BudgetSubmissionAnswer[],
)
} catch (error) {
if (error instanceof BudgetingGradingError) {
throw new ProgressionError(error.statusCode, error.message)
}
throw error
}
}
if (definition.activityType === 'challenge' && definition.activityKey === RISK_ACTIVITY_KEY) {
try {
return gradeRiskReturnSubmission(
(submission.answers ?? []) as import('../risk-return/risk-return.grader').RiskSubmissionAnswer[],
)
} catch (error) {
if (error instanceof RiskReturnGradingError) {
throw new ProgressionError(error.statusCode, error.message)
}
throw error
}
}
if (definition.activityType === 'game' && definition.activityKey === PORTFOLIO_ACTIVITY_KEY) {
try {
return gradePortfolioSubmission(
(submission.answers ?? []) as import('../portfolio/portfolio.grader').PortfolioSubmissionAnswer[],
)
} catch (error) {
if (error instanceof PortfolioGradingError) {
throw new ProgressionError(error.statusCode, error.message)
}
throw error
}
}
if (definition.activityType === 'game' && definition.activityKey === SMART_SHOP_ACTIVITY_KEY) {
try {
return gradeSmartShopSubmission(
(submission.answers ?? []) as import('../smart-shop/shop.grader').SmartShopSubmissionAnswer[],
)
} catch (error) {
if (error instanceof SmartShopGradingError) {
throw new ProgressionError(error.statusCode, error.message)
}
throw error
}
}
if (definition.activityType === 'challenge' && definition.activityKey === FINANCIAL_GOAL_ACTIVITY_KEY) {
try {
return gradeFinancialGoalSubmission(
(submission.answers ?? []) as import('../financial-goal/goal.grader').GoalSubmissionAnswer[],
)
} catch (error) {
if (error instanceof FinancialGoalGradingError) {
throw new ProgressionError(error.statusCode, error.message)
}
throw error
}
}
if (definition.activityType === 'final_exam' && definition.activityKey === CASTLE_ACTIVITY_KEY) {
try {
return gradeCastleExam((submission.answers ?? []) as import('../castle-exam/castle.grader').CastleSubmissionAnswer[])
} catch (error) {
if (error instanceof CastleExamGradingError) throw new ProgressionError(error.statusCode, error.message)
throw error
}
}
throw new ProgressionError(403, 'ارزیابی این مرحله هنوز پیاده‌سازی نشده')
}
}
+84
View File
@@ -0,0 +1,84 @@
import { getSmartShopQuestionBank, smartShopMission } from './shop.mission'
import type { SmartShopFeedback, SmartShopPublic } from './shop.types'
export class SmartShopGradingError extends Error {
statusCode: number
constructor(statusCode: number, message: string) {
super(message)
this.statusCode = statusCode
}
}
export interface SmartShopSubmissionAnswer {
questionId?: string
answerId?: string
orderedItemIds?: string[]
allocations?: unknown
holdings?: unknown
price?: unknown
score?: unknown
reward?: unknown
}
export function gradeSmartShopSubmission(answers: SmartShopSubmissionAnswer[]): {
score: number
feedback: SmartShopFeedback
} {
if (!Array.isArray(answers)) {
throw new SmartShopGradingError(400, 'فرمت پاسخ نامعتبر است')
}
const bank = getSmartShopQuestionBank()
const seen = new Set<string>()
const chosen = new Map<string, string>()
for (const answer of answers) {
if (!answer || typeof answer.questionId !== 'string') {
throw new SmartShopGradingError(400, 'شناسهٔ پاسخ نامعتبر است')
}
if (seen.has(answer.questionId)) {
throw new SmartShopGradingError(400, `پاسخ ${answer.questionId} تکراری است`)
}
seen.add(answer.questionId)
if (
answer.orderedItemIds !== undefined ||
answer.allocations !== undefined ||
answer.holdings !== undefined ||
answer.price !== undefined ||
answer.score !== undefined ||
answer.reward !== undefined
) {
throw new SmartShopGradingError(400, 'فیلد غیرمجاز در پاسخ خرید')
}
if (typeof answer.answerId !== 'string') {
throw new SmartShopGradingError(400, 'نوع پاسخ نامعتبر است')
}
const question = bank.find((item) => item.id === answer.questionId)
if (!question) {
throw new SmartShopGradingError(400, `سؤال ${answer.questionId} پیدا نشد`)
}
if (!question.options.some((option) => option.id === answer.answerId)) {
throw new SmartShopGradingError(400, `گزینهٔ نامعتبر برای سؤال ${answer.questionId}`)
}
chosen.set(answer.questionId, answer.answerId)
}
if (chosen.size !== bank.length) {
throw new SmartShopGradingError(400, 'پاسخ همهٔ سؤال‌ها لازم است')
}
const purchases = bank.map((question) => {
const picked = question.options.find((option) => option.id === chosen.get(question.id))
const correct = question.options.find((option) => option.correct)
return {
id: question.id,
earned: picked?.id === correct?.id,
explanation: correct?.explanation ?? question.options.find((option) => option.explanation)?.explanation ?? '',
}
})
const correctCount = purchases.filter((item) => item.earned).length
return {
score: Math.round((correctCount * 100) / 6),
feedback: { summary: smartShopMission.summary, purchases },
}
}
export function getSmartShopPublic(): SmartShopPublic {
return smartShopMission
}
+12
View File
@@ -0,0 +1,12 @@
export interface ShopCost {
price: number | null
delivery: number | null
}
export function finalCost(cost: ShopCost): number | null {
if (cost.price === null || cost.delivery === null) return null
if (!Number.isInteger(cost.price) || !Number.isInteger(cost.delivery) || cost.price < 0 || cost.delivery < 0) {
return null
}
return cost.price + cost.delivery
}
+156
View File
@@ -0,0 +1,156 @@
import { finalCost } from './shop.math'
import type { ShopCardPublic, ShopMissionPublic, SmartShopPublic } from './shop.types'
export const SMART_SHOP_ACTIVITY_KEY = 'smart-shop'
interface PrivateOption {
id: string
label: string
correct?: boolean
explanation?: string
}
function card(
id: string,
label: string,
facts: string[],
price: number | null,
delivery: number | null,
discountText: string | null = null,
previousPrice: number | null = null,
): ShopCardPublic {
return {
id,
label,
facts,
price,
delivery,
discountText,
previousPrice,
finalCost: finalCost({ price, delivery }),
}
}
const notebookChoice: PrivateOption[] = [
{ id: 'a', label: 'دفتر ۴۰برگ، ۲۵ سکه، تحویل رایگان' },
{ id: 'b', label: 'دفتر ۶۰برگ، ۴۰ سکه، تحویل ۵ سکه', correct: true, explanation: 'شش‌ده برگ و سالم است و هزینه نهایی‌اش ۴۵ سکه است؛ بین گزینه‌های مناسب کمتر است.' },
{ id: 'c', label: 'دفتر ۶۰برگ، ۳۸ سکه، تحویل ۱۲ سکه' },
]
const notebookWhy: PrivateOption[] = [
{ id: 'cheapest-tag', label: 'چون عدد روی کارت کوچک‌تر است' },
{
id: 'fits-and-cheaper',
label: 'نیاز را برآورده می‌کند و هزینه نهایی‌اش بین گزینه‌های مناسب کمتر است',
correct: true,
explanation: 'دفتر ۴۰برگ نیاز را تأمین نمی‌کند. دفتر ۳۸ به‌اضافه ۱۲، هزینه نهایی ۵۰ دارد و از ۴۵ بیشتر است.',
},
]
const discountChoice: PrivateOption[] = [
{ id: 'a', label: 'مداد با تخفیف ۲۵٪ و قیمت نهایی ۳۰' },
{ id: 'b', label: 'مداد با قیمت نهایی ۲۸، بدون تخفیف', correct: true, explanation: 'هر دو یک مداد با تحویل رایگان‌اند و ۲۸ از ۳۰ کمتر است.' },
]
const discountWhy: PrivateOption[] = [
{ id: 'bigger-discount', label: 'چون درصد تخفیف بیشتر همیشه بهتر است' },
{
id: 'pay-amount',
label: 'برای کالای یکسان، مبلغ نهایی پرداختی مهم است؛ درصد تخفیف به‌تنهایی کافی نیست',
correct: true,
explanation: '۲۵٪ تخفیف قیمت را به ۳۰ می‌رساند. گزینه بدون تخفیف ۲۸ تمام می‌شود.',
},
]
const infoChoice: PrivateOption[] = [
{ id: 'buy-now', label: 'همین حالا بخر' },
{ id: 'assume', label: 'فرض کن اندازه مناسب است و تحویل رایگان است' },
{
id: 'ask',
label: 'فعلاً نخر و اندازه و هزینه نهایی را بپرس، با کمک بزرگسال قابل اعتماد',
correct: true,
explanation: 'اندازه و هزینه تحویل مشخص نیست. خرید فوری یا حدس زدن جای بررسی را نمی‌گیرد.',
},
]
const infoWhy: PrivateOption[] = [
{ id: 'hurry', label: 'چون پیام می‌گوید همین حالا بخر' },
{
id: 'incomplete',
label: 'اطلاعات لازم برای تصمیم و مقایسه هنوز کامل نیست',
correct: true,
explanation: 'وقتی اندازه یا هزینه نهایی نامشخص است، هنوز نمی‌توان مقایسه کرد.',
},
]
function options(items: PrivateOption[]) {
return items.map(({ id, label }) => ({ id, label }))
}
const missions: ShopMissionPublic[] = [
{
id: 'notebook',
title: 'دفتر مناسب',
need: 'یک دفتر حداقل ۶۰ برگ و سالم.',
budget: 50,
cards: [
card('a', 'پیشنهاد الف', ['۴۰ برگ', 'سالم'], 25, 0),
card('b', 'پیشنهاد ب', ['۶۰ برگ', 'سالم'], 40, 5),
card('c', 'پیشنهاد ج', ['۶۰ برگ', 'سالم'], 38, 12),
],
choice: { id: 'notebook-choice', prompt: 'کدام پیشنهاد را برای این نیاز انتخاب می‌کنی؟', options: options(notebookChoice) },
reason: { id: 'notebook-why', prompt: 'دلیل انتخاب تو چیست؟', options: options(notebookWhy) },
},
{
id: 'discount',
title: 'برچسب تخفیف',
need: 'یک مداد مشخص، با نوع و شرایط یکسان.',
budget: 30,
cards: [
card('a', 'پیشنهاد الف', ['همان مداد', 'تحویل رایگان'], 30, 0, 'تخفیف ۲۵٪', 40),
card('b', 'پیشنهاد ب', ['همان مداد', 'تحویل رایگان', 'بدون تخفیف'], 28, 0),
],
choice: { id: 'discount-choice', prompt: 'کدام مداد را انتخاب می‌کنی؟', options: options(discountChoice) },
reason: { id: 'discount-why', prompt: 'دلیل انتخاب تو چیست؟', options: options(discountWhy) },
},
{
id: 'info',
title: 'اطلاعات ناقص',
need: 'وسیله‌ای با اندازه مشخص، مناسب هدف معرفی‌شده.',
budget: 60,
cards: [
card('offer', 'پیشنهاد فروشنده', ['اندازه نامشخص', 'پیام: همین حالا بخر'], 45, null),
],
choice: { id: 'info-choice', prompt: 'با این اطلاعات چه می‌کنی؟', options: options(infoChoice) },
reason: { id: 'info-why', prompt: 'دلیل این تصمیم چیست؟', options: options(infoWhy) },
},
]
export const smartShopMission: SmartShopPublic = {
id: 'smart-shop',
version: 1,
title: 'خرید هوشمند',
guideTitle: 'راهنما',
guideImageKey: 'professor',
intro:
'سه خرید خیالی داری. ارزان‌ترین عدد روی کارت یا بزرگ‌ترین تخفیف همیشه بهترین انتخاب نیست. این سکه‌ها از کیف پول بازی کم نمی‌شوند.',
rules: [
'نیاز و بودجه را کنار پیشنهادها ببین.',
'هزینه نهایی قیمت به‌اضافه تحویل است.',
'اگر چیزی نامشخص است، آن را صفر فرض نکن.',
'قبل از ثبت می‌توانی انتخاب‌ها را عوض کنی.',
],
missions,
summary: 'خرید خوب به نیاز، هزینه نهایی، اطلاعات کافی و بودجه نگاه می‌کند.',
}
export function getSmartShopQuestionBank() {
return [
{ id: 'notebook-choice', options: notebookChoice },
{ id: 'notebook-why', options: notebookWhy },
{ id: 'discount-choice', options: discountChoice },
{ id: 'discount-why', options: discountWhy },
{ id: 'info-choice', options: infoChoice },
{ id: 'info-why', options: infoWhy },
]
}
+42
View File
@@ -0,0 +1,42 @@
export interface ShopCardPublic {
id: string
label: string
facts: string[]
price: number | null
delivery: number | null
discountText: string | null
previousPrice: number | null
finalCost: number | null
}
export interface ShopOptionPublic {
id: string
label: string
}
export interface ShopMissionPublic {
id: string
title: string
need: string
budget: number
cards: ShopCardPublic[]
choice: { id: string; prompt: string; options: ShopOptionPublic[] }
reason: { id: string; prompt: string; options: ShopOptionPublic[] }
}
export interface SmartShopPublic {
id: string
version: number
title: string
guideTitle: string
guideImageKey: string
intro: string
rules: string[]
missions: ShopMissionPublic[]
summary: string
}
export interface SmartShopFeedback {
summary: string
purchases: Array<{ id: string; earned: boolean; explanation: string }>
}
+7 -7
View File
@@ -21,13 +21,13 @@ export const GOLD_LAND_LEVELS: GoldLandLevelDefinition[] = [
{ id: 4, order: 4, slug: 'what-is-gold', title: 'طلا چیه؟', description: 'با طلا به‌عنوان دارایی آشنا شو.', activityType: 'lesson', activityKey: 'what-is-gold', rewardXp: 40, rewardCoins: 15, isActive: true },
{ id: 5, order: 5, slug: 'gold-market', title: 'بازار طلا', description: 'بازار طلا را بشناس.', activityType: 'lesson', activityKey: 'gold-market', rewardXp: 50, rewardCoins: 20, isActive: true },
{ id: 6, order: 6, slug: 'inflation', title: 'تورم', description: 'تورم چیست و چطور بر پول تو اثر می‌گذارد؟', activityType: 'lesson', activityKey: 'inflation', rewardXp: 40, rewardCoins: 15, isActive: true, activityRoute: '/lessons/inflation' },
{ id: 7, order: 7, slug: 'budgeting', title: 'بودجه‌بندی', description: 'پولت را هوشمندانه تقسیم کن.', activityType: 'game', activityKey: 'budgeting', rewardXp: 45, rewardCoins: 18, isActive: false },
{ id: 8, order: 8, slug: 'risk-and-return', title: 'ریسک و بازده', description: 'ریسک و پاداش در سرمایه‌گذاری.', activityType: 'challenge', activityKey: 'risk-return', rewardXp: 50, rewardCoins: 20, isActive: false },
{ id: 9, order: 9, slug: 'portfolio', title: 'سبد سرمایه‌گذاری', description: 'یک سبد متنوع بساز.', activityType: 'game', activityKey: 'portfolio', rewardXp: 50, rewardCoins: 20, isActive: false },
{ id: 10, order: 10, slug: 'compound-interest', title: 'سود مرکب', description: 'قدرت سود مرکب را ببین.', activityType: 'lesson', activityKey: 'compound-interest', rewardXp: 45, rewardCoins: 18, isActive: false },
{ id: 11, order: 11, slug: 'smart-shopping', title: 'خرید هوشمند', description: 'هوشمندانه خرید کن.', activityType: 'game', activityKey: 'smart-shop', rewardXp: 45, rewardCoins: 18, isActive: false },
{ id: 12, order: 12, slug: 'financial-goal', title: 'هدف مالی', description: 'برنامه مالی خودت را بساز.', activityType: 'challenge', activityKey: 'financial-goal', rewardXp: 50, rewardCoins: 20, isActive: false },
{ id: 13, order: 13, slug: 'castle-exam', title: 'آزمون قلعه', description: 'آزمون نهایی سرزمین طلا.', activityType: 'final_exam', activityKey: 'castle-exam', rewardXp: 100, rewardCoins: 50, isActive: false },
{ id: 7, order: 7, slug: 'budgeting', title: 'بودجه‌بندی', description: 'پولت را هوشمندانه تقسیم کن.', activityType: 'game', activityKey: 'budgeting', rewardXp: 45, rewardCoins: 18, isActive: true },
{ id: 8, order: 8, slug: 'risk-and-return', title: 'ریسک و بازده', description: 'ریسک و پاداش در سرمایه‌گذاری.', activityType: 'challenge', activityKey: 'risk-return', rewardXp: 50, rewardCoins: 20, isActive: true },
{ id: 9, order: 9, slug: 'portfolio', title: 'سبد سرمایه‌گذاری', description: 'یک سبد متنوع بساز.', activityType: 'game', activityKey: 'portfolio', rewardXp: 50, rewardCoins: 20, isActive: true },
{ id: 10, order: 10, slug: 'compound-interest', title: 'سود مرکب', description: 'قدرت سود مرکب را ببین.', activityType: 'lesson', activityKey: 'compound-interest', rewardXp: 45, rewardCoins: 18, isActive: true },
{ id: 11, order: 11, slug: 'smart-shopping', title: 'خرید هوشمند', description: 'هوشمندانه خرید کن.', activityType: 'game', activityKey: 'smart-shop', rewardXp: 45, rewardCoins: 18, isActive: true },
{ id: 12, order: 12, slug: 'financial-goal', title: 'هدف مالی', description: 'برنامه مالی خودت را بساز.', activityType: 'challenge', activityKey: 'financial-goal', rewardXp: 50, rewardCoins: 20, isActive: true },
{ id: 13, order: 13, slug: 'castle-exam', title: 'آزمون قلعه', description: 'آزمون نهایی سرزمین طلا.', activityType: 'final_exam', activityKey: 'castle-exam', rewardXp: 100, rewardCoins: 50, isActive: true },
]
export function getLevelDefinition(levelId: number) {
+233
View File
@@ -0,0 +1,233 @@
/**
* Budgeting mission — temporary SQLite only.
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter'
import { BudgetingGradingError, gradeBudgetSubmission } from '../src/gold-land/budgeting/budgeting.grader'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import { whatIsMoneyLesson } from '../src/gold-land/lessons/what-is-money.lesson'
import { getGradableQuestions } from '../src/gold-land/lessons/lesson.grader'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'budget-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-budget-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3996'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
function plan(transport: number, food: number, savings: number, fun: number) {
return { transport, food, savings, fun }
}
function answers(initial: ReturnType<typeof plan>, finalPlan: ReturnType<typeof plan>) {
return [
{ questionId: 'initial', allocations: initial },
{ questionId: 'final', allocations: finalPlan },
]
}
async function createApp(): Promise<NestFastifyApplication> {
const { AppModule } = await import('../src/app.module')
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile()
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter())
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
app.useGlobalFilters(new ApiExceptionFilter())
await app.init()
await app.getHttpAdapter().getInstance().ready()
return app
}
async function run() {
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const perfectA = gradeBudgetSubmission(answers(plan(20, 25, 20, 35), plan(30, 25, 20, 25)))
const perfectB = gradeBudgetSubmission(answers(plan(25, 30, 25, 0), plan(35, 30, 20, 0)))
assert.equal(perfectA.score, 100)
assert.equal(perfectB.score, 100)
const shortSavings = gradeBudgetSubmission(answers(plan(20, 25, 10, 0), plan(30, 25, 10, 0)))
assert.equal(shortSavings.score, 80)
assert.equal(shortSavings.feedback.plans[0]?.criteria.find((c) => c.id === 'savings-goal')?.earned, false)
assert.equal(shortSavings.feedback.plans[1]?.criteria.find((c) => c.id === 'transport')?.earned, true)
assert.throws(
() => gradeBudgetSubmission(answers(plan(20, 25, 20, 0), plan(20, 25, 20, 0))),
(error: unknown) => error instanceof BudgetingGradingError && error.kind === 'unmet-needs' && error.statusCode === 422,
)
assert.throws(
() => gradeBudgetSubmission(answers(plan(0, 0, 20, 0), plan(30, 25, 20, 0))),
(error: unknown) => error instanceof BudgetingGradingError && error.kind === 'unmet-needs',
)
for (const bad of [
answers(plan(-5, 25, 20, 0), plan(30, 25, 20, 0)),
answers(plan(20.5 as unknown as number, 25, 20, 0), plan(30, 25, 20, 0)),
answers(plan(12, 25, 20, 0), plan(30, 25, 20, 0)),
answers(plan(50, 50, 25, 25), plan(30, 25, 20, 0)),
]) {
assert.throws(() => gradeBudgetSubmission(bad))
}
assert.throws(() =>
gradeBudgetSubmission([
{ questionId: 'initial', allocations: { ...plan(20, 25, 20, 0), extra: 5 } as never },
{ questionId: 'final', allocations: plan(30, 25, 20, 0) },
]),
)
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
USER,
JSON.stringify(createDefaultUserState(USER)),
new Date().toISOString(),
)
const now = new Date().toISOString()
const insert = db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, 70, 1, 1, ?, ?, ?)
`)
for (let id = 1; id <= 5; id += 1) insert.run(USER, id, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 7)?.status, 'locked')
assert.throws(() => progression.startLevel(USER, 7))
insert.run(USER, 6, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 7)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 8)?.status, 'locked')
const lessonAnswers = getGradableQuestions(whatIsMoneyLesson).map(({ questionId, step }) =>
step.type === 'sort'
? { questionId, orderedItemIds: [...step.correctOrder] }
: { questionId, answerId: step.correctAnswerId },
)
const lesson = progression.completeLevel(USER, 1, { answers: lessonAnswers })
assert.equal(lesson.score, 100)
assert.equal(lesson.feedback, undefined)
const first = progression.completeLevel(USER, 7, {
answers: answers(plan(20, 25, 10, 0), plan(30, 25, 10, 0)),
})
assert.equal(first.score, 80)
assert.equal(first.stars, 2)
assert.equal(first.isFirstCompletion, true)
assert.equal(first.reward.xp, 45 + 10)
assert.equal(first.reward.coins, 18 + 6)
const same = progression.completeLevel(USER, 7, {
answers: answers(plan(20, 25, 10, 0), plan(30, 25, 10, 0)),
})
assert.equal(same.isFirstCompletion, false)
assert.equal(same.reward.xp, 0)
assert.equal(same.reward.coins, 0)
const improved = progression.completeLevel(USER, 7, {
answers: answers(plan(20, 25, 20, 0), plan(30, 25, 20, 0)),
})
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 5)
assert.equal(improved.reward.coins, 3)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 8)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 9)?.status, 'locked')
const reg = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: { username: `budget_http_${Date.now()}`, password: 'pass1234', name: 'Budget' },
})
assert.equal(reg.statusCode, 200)
const token = JSON.parse(reg.payload) as { token: string }
const bad = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/7/complete',
headers: { authorization: `Bearer ${token.token}` },
payload: {
answers: [
{ questionId: 'initial', allocations: { transport: 20, food: 25, savings: 20, fun: 0, bonus: 1 } },
{ questionId: 'final', allocations: plan(30, 25, 20, 0) },
],
},
})
assert.equal(bad.statusCode, 400)
const badBody = JSON.parse(bad.payload) as { error: string }
assert.match(badBody.error, /property bonus should not exist|نباید/)
const parallelId = 'budget-parallel-user'
db.prepare('DELETE FROM reward_ledger WHERE user_id = ?').run(parallelId)
db.prepare('DELETE FROM user_level_progress WHERE user_id = ?').run(parallelId)
db.prepare('DELETE FROM users WHERE id = ?').run(parallelId)
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
parallelId,
JSON.stringify(createDefaultUserState(parallelId)),
now,
)
for (let id = 1; id <= 6; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const parallelToken = app.get(JwtService).sign({ userId: parallelId })
const parallelPayload = {
answers: answers(plan(20, 25, 20, 0), plan(30, 25, 20, 0)),
}
const [firstRace, secondRace] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/gold-land/levels/7/complete',
headers: { authorization: `Bearer ${parallelToken}` },
payload: parallelPayload,
}),
app.inject({
method: 'POST',
url: '/api/gold-land/levels/7/complete',
headers: { authorization: `Bearer ${parallelToken}` },
payload: parallelPayload,
}),
])
assert.equal(firstRace.statusCode, 200)
assert.equal(secondRace.statusCode, 200)
const raceBodies = [firstRace, secondRace].map(
(response) => JSON.parse(response.payload) as { reward: { xp: number; coins: number }; stars: number },
)
assert.equal(raceBodies.reduce((sum, body) => sum + body.reward.coins, 0), 18 + 9)
assert.equal(raceBodies.reduce((sum, body) => sum + body.reward.xp, 0), 45 + 15)
const ledger = db.prepare(
'SELECT reward_key, xp_delta, coins_delta FROM reward_ledger WHERE user_id = ? ORDER BY reward_key',
).all(parallelId) as Array<{ reward_key: string; xp_delta: number; coins_delta: number }>
assert.deepEqual(ledger.map((row) => row.reward_key), [
'level:7:first-completion',
'level:7:star:1',
'level:7:star:2',
'level:7:star:3',
])
const me = await app.inject({
method: 'GET',
url: '/api/me',
headers: { authorization: `Bearer ${parallelToken}` },
})
const meBody = JSON.parse(me.payload) as { user: { coins: number } }
assert.equal(meBody.user.coins, 1350 + 18 + 9)
console.log('budgeting tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// Windows may keep the sqlite handle briefly.
}
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+141
View File
@@ -0,0 +1,141 @@
/**
* Castle exam — temporary SQLite only.
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter'
import { gradeCastleExam } from '../src/gold-land/castle-exam/castle.grader'
import { castleExamPublic, getCastleQuestionBank } from '../src/gold-land/castle-exam/castle.mission'
import { GOLD_LAND_LEVELS } from '../src/gold-land/levels/levels'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'castle-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-castle-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '4001'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
const correctIds = ['exchange', 'regular', 'water', 'moves', 'no', 'less', 'valid', 'keep', 'no', '121', 'b', '6']
function answersFor(count: number) {
return getCastleQuestionBank().map((question, index) => ({
questionId: question.id,
answerId: index < count ? question.correctOptionId : question.options.find((option) => option.id !== question.correctOptionId)!.id,
}))
}
async function createApp(): Promise<NestFastifyApplication> {
const { AppModule } = await import('../src/app.module')
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile()
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter())
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
app.useGlobalFilters(new ApiExceptionFilter())
await app.init()
await app.getHttpAdapter().getInstance().ready()
return app
}
async function run() {
const scores = Array.from({ length: 13 }, (_, count) => gradeCastleExam(answersFor(count)).score)
assert.deepEqual(scores, [0, 8, 17, 25, 33, 42, 50, 58, 67, 75, 83, 92, 100])
assert.equal(gradeCastleExam(answersFor(7)).feedback.completed, false)
assert.equal(gradeCastleExam(answersFor(8)).feedback.completed, true)
assert.equal(JSON.stringify(castleExamPublic).includes('correctOptionId'), false)
assert.equal(JSON.stringify(castleExamPublic).includes('explanation'), false)
assert.deepEqual(castleExamPublic.questions.map((question) => question.sourceLevelId), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
assert.throws(() => gradeCastleExam(answersFor(12).slice(0, 11)))
assert.throws(() => gradeCastleExam([...answersFor(12), answersFor(1)[0]!]))
assert.throws(() => gradeCastleExam(answersFor(12).map((answer, index) => (index === 0 ? { ...answer, answerId: 'nope' } : answer))))
assert.throws(() => gradeCastleExam(answersFor(12).map((answer) => ({ ...answer, score: 100 }))))
assert.deepEqual(correctIds, getCastleQuestionBank().map((question) => question.correctOptionId))
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(USER, JSON.stringify(createDefaultUserState(USER)), now)
const insert = db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, 100, 3, 1, ?, ?, ?)
`)
for (let id = 1; id <= 11; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 13))
insert.run(USER, 12, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 13)?.status, 'available')
const coinsOf = () => JSON.parse((db.prepare('SELECT state_json FROM users WHERE id = ?').get(USER) as { state_json: string }).state_json).coins as number
const beforeCoins = coinsOf()
const failed = progression.completeLevel(USER, 13, { answers: answersFor(7) })
assert.equal(failed.score, 58)
assert.equal(failed.stars, 0)
assert.equal(failed.reward.coins, 0)
assert.equal(failed.unlockedLevelId, null)
assert.equal(failed.feedback && 'completed' in failed.feedback && failed.feedback.completed, false)
const row = db.prepare('SELECT completed_at FROM user_level_progress WHERE user_id = ? AND level_id = 13').get(USER) as { completed_at: string | null } | undefined
assert.equal(row?.completed_at ?? null, null)
assert.equal(coinsOf(), beforeCoins)
const first = progression.completeLevel(USER, 13, { answers: answersFor(8) })
assert.equal(first.score, 67)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 100 + 5)
assert.equal(first.reward.coins, 50 + 3)
assert.equal(first.unlockedLevelId, null)
const saved = db.prepare('SELECT best_score, stars, completed_at FROM user_level_progress WHERE user_id = ? AND level_id = 13').get(USER) as { best_score: number; stars: number; completed_at: string }
const afterPass = progression.completeLevel(USER, 13, { answers: answersFor(7) })
assert.equal(afterPass.score, 58)
assert.equal(afterPass.reward.coins, 0)
assert.equal(afterPass.feedback && 'completed' in afterPass.feedback && afterPass.feedback.completed, false)
assert.equal(afterPass.previousStars, 1)
const still = db.prepare('SELECT best_score, stars, completed_at FROM user_level_progress WHERE user_id = ? AND level_id = 13').get(USER) as { best_score: number; stars: number; completed_at: string }
assert.equal(still.best_score, saved.best_score)
assert.equal(still.stars, saved.stars)
assert.equal(still.completed_at, saved.completed_at)
const replay = progression.completeLevel(USER, 13, { answers: answersFor(8) })
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 13, { answers: answersFor(12) })
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(improved.unlockedLevelId, null)
assert.equal(GOLD_LAND_LEVELS.length, 13)
assert.equal(progression.buildLevelItems(USER).some((level) => level.id === 14), false)
const parallelId = 'castle-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(parallelId, JSON.stringify(createDefaultUserState(parallelId)), now)
for (let id = 1; id <= 12; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const body = { answers: answersFor(11) }
const [left, right] = await Promise.all([
app.inject({ method: 'POST', url: '/api/gold-land/levels/13/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
app.inject({ method: 'POST', url: '/api/gold-land/levels/13/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map((response) => JSON.parse(response.payload) as { reward: { coins: number; xp: number }; unlockedLevelId: number | null })
assert.equal(bodies.reduce((sum, item) => sum + item.reward.coins, 0), 50 + 9)
assert.equal(bodies.reduce((sum, item) => sum + item.reward.xp, 0), 100 + 15)
assert.equal(bodies.every((item) => item.unlockedLevelId === null), true)
console.log('castle-exam tests passed')
} finally {
await app.close()
try { rmSync(testDir, { recursive: true, force: true }) } catch { /* ignore */ }
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+172
View File
@@ -0,0 +1,172 @@
/**
* Compound-interest lesson — temporary SQLite only.
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter'
import { buildGrowthExample } from '../src/gold-land/lessons/growth-example'
import { compoundInterestLesson } from '../src/gold-land/lessons/compound-interest.lesson'
import { gradeLessonSubmission } from '../src/gold-land/lessons/lesson.grader'
import { sanitizeLesson } from '../src/gold-land/lessons/lesson.sanitizer'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'compound-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-compound-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3997'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
const correct = [
{ questionId: 'compound-q1', answerId: 'on-balance' },
{ questionId: 'compound-q2', answerId: '121' },
{ questionId: 'compound-q3', answerId: 'from-110' },
{ questionId: 'compound-q4', answerId: 'compound' },
{ questionId: 'compound-q5', answerId: 'false' },
]
const wrong = ['initial-only', '120', 'from-100', 'simple', 'true']
function withCount(count: number) {
return correct.map((answer, index) =>
index < count ? answer : { questionId: answer.questionId, answerId: wrong[index] ?? 'true' },
)
}
function ends(methodId: 'simple' | 'compound') {
return buildGrowthExample().periods.map((period) => period.rows.find((row) => row.methodId === methodId)?.endTenths)
}
async function createApp(): Promise<NestFastifyApplication> {
const { AppModule } = await import('../src/app.module')
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile()
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter())
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
app.useGlobalFilters(new ApiExceptionFilter())
await app.init()
await app.getHttpAdapter().getInstance().ready()
return app
}
async function run() {
assert.deepEqual(ends('simple'), [1000, 1100, 1200, 1300])
assert.deepEqual(ends('compound'), [1000, 1100, 1210, 1331])
const period2 = buildGrowthExample().periods[2]
const compound2 = period2?.rows.find((row) => row.methodId === 'compound')
assert.equal(compound2?.startTenths, 1100)
assert.equal(compound2?.growthTenths, 110)
assert.equal(compound2?.endTenths, 1210)
const before = JSON.stringify(buildGrowthExample().periods)
const viewed = buildGrowthExample().periods.slice(0, 2)
assert.equal(viewed.length, 2)
assert.equal(JSON.stringify(buildGrowthExample().periods), before)
assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(0)).score, 0)
assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(2)).score, 40)
assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(3)).score, 60)
assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(4)).score, 80)
assert.equal(gradeLessonSubmission(compoundInterestLesson, withCount(5)).score, 100)
const perfect = gradeLessonSubmission(compoundInterestLesson, correct)
assert.equal(perfect.feedback?.questions.length, 5)
assert.equal(perfect.feedback?.questions.every((item) => item.earned), true)
assert.throws(() => gradeLessonSubmission(compoundInterestLesson, correct.slice(0, 4)))
assert.throws(() => gradeLessonSubmission(compoundInterestLesson, [...correct, correct[0]!]))
assert.throws(() => gradeLessonSubmission(compoundInterestLesson, [{ questionId: 'missing', answerId: '121' }]))
assert.throws(() =>
gradeLessonSubmission(compoundInterestLesson, correct.map((answer) => ({ ...answer, orderedItemIds: ['x'] }))),
)
const publicLesson = JSON.stringify(sanitizeLesson(compoundInterestLesson))
assert.equal(publicLesson.includes('correctAnswerId'), false)
assert.equal(publicLesson.includes('explanation'), false)
assert.equal(publicLesson.includes('1331'), true)
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
USER,
JSON.stringify(createDefaultUserState(USER)),
now,
)
const insert = db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, 80, 2, 1, ?, ?, ?)
`)
for (let id = 1; id <= 8; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 10))
insert.run(USER, 9, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 10)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 11)?.status, 'locked')
assert.throws(() => progression.completeLevel(USER, 10, { answers: withCount(2) }))
const first = progression.completeLevel(USER, 10, { answers: withCount(3) })
assert.equal(first.score, 60)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 45 + 5)
assert.equal(first.reward.coins, 18 + 3)
const replay = progression.completeLevel(USER, 10, { answers: withCount(3) })
assert.equal(replay.reward.xp, 0)
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 10, { answers: correct })
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 11)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 12)?.status, 'locked')
const parallelId = 'compound-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
parallelId,
JSON.stringify(createDefaultUserState(parallelId)),
now,
)
for (let id = 1; id <= 9; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const [left, right] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/gold-land/levels/10/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: correct },
}),
app.inject({
method: 'POST',
url: '/api/gold-land/levels/10/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: correct },
}),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map((response) => JSON.parse(response.payload) as { reward: { xp: number; coins: number } })
assert.equal(bodies.reduce((sum, body) => sum + body.reward.coins, 0), 18 + 9)
assert.equal(bodies.reduce((sum, body) => sum + body.reward.xp, 0), 45 + 15)
console.log('compound-interest tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+1 -1
View File
@@ -266,7 +266,7 @@ async function runTests() {
assert.equal(body.user.xp, xpBefore + 50)
}
// --- coming_soon level cannot start ---
// 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({
+149
View File
@@ -0,0 +1,149 @@
/**
* Financial goal — temporary SQLite only.
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter'
import { initialPlan, revisedPlan, INITIAL_RATES, EXCEPTION_RATES, CONTINUE_RATES } from '../src/gold-land/financial-goal/goal.math'
import { gradeFinancialGoalSubmission } from '../src/gold-land/financial-goal/goal.grader'
import { financialGoalMission } from '../src/gold-land/financial-goal/goal.mission'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'goal-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-goal-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3999'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
const correct = [
{ questionId: 'q-goal', answerId: 'specific' },
{ questionId: 'q-left', answerId: '90' },
{ questionId: 'q-weeks', answerId: '6' },
{ questionId: 'q-need', answerId: 'adjust' },
{ questionId: 'q-change', answerId: 'update' },
]
const wrong = ['vague', '120', '5', 'drop', 'keep']
function submission(plan: { initialWeekly: number; exceptionWeekly: number; continueWeekly: number }, count: number) {
return [
{ questionId: 'plan', plan },
...correct.map((answer, index) => (index < count ? answer : { questionId: answer.questionId, answerId: wrong[index]! })),
]
}
async function createApp(): Promise<NestFastifyApplication> {
const { AppModule } = await import('../src/app.module')
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile()
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter())
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
app.useGlobalFilters(new ApiExceptionFilter())
await app.init()
await app.getHttpAdapter().getInstance().ready()
return app
}
async function run() {
assert.deepEqual(INITIAL_RATES.map((rate) => initialPlan(rate).weeks), [18, 9, 6, 5])
const sample = revisedPlan(15, 5, 15)
assert.equal(sample.balanceAfterTwo, 50)
assert.equal(sample.remainingWeeks, 5)
assert.equal(sample.totalWeeks, 7)
assert.equal(sample.finalBalance, 125)
const paused = revisedPlan(15, 0, 15)
assert.equal(paused.balanceAfterTwo, 45)
assert.equal(paused.totalWeeks, 7)
const totals: number[] = []
for (const initial of INITIAL_RATES) {
for (const exception of EXCEPTION_RATES) {
for (const cont of CONTINUE_RATES) {
const result = revisedPlan(initial, exception, cont)
assert.equal(result.totalWeeks, 2 + result.remainingWeeks)
assert.equal(result.finalBalance, result.balanceAfterTwo + result.remainingWeeks * cont)
assert.ok(result.finalBalance >= 120)
totals.push(result.totalWeeks)
}
}
}
assert.equal(Math.min(...totals), 5)
const slow = { initialWeekly: 5, exceptionWeekly: 0, continueWeekly: 5 }
const fast = { initialWeekly: 20, exceptionWeekly: 10, continueWeekly: 20 }
assert.equal(gradeFinancialGoalSubmission(submission(slow, 0)).score, 0)
assert.equal(gradeFinancialGoalSubmission(submission(fast, 2)).score, 40)
assert.equal(gradeFinancialGoalSubmission(submission(slow, 3)).score, 60)
assert.equal(gradeFinancialGoalSubmission(submission(fast, 4)).score, 80)
assert.equal(gradeFinancialGoalSubmission(submission(slow, 5)).score, 100)
assert.equal(gradeFinancialGoalSubmission(submission(fast, 5)).score, 100)
assert.throws(() => gradeFinancialGoalSubmission(submission({ initialWeekly: 15, exceptionWeekly: 5, continueWeekly: 0 }, 5)))
assert.throws(() => gradeFinancialGoalSubmission([...submission(fast, 5), correct[0]!]))
assert.throws(() => gradeFinancialGoalSubmission(submission(fast, 5).map((answer) => ({ ...answer, weeks: 7 }))))
assert.equal(JSON.stringify(financialGoalMission).includes('correctOptionId'), false)
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(USER, JSON.stringify(createDefaultUserState(USER)), now)
const insert = db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, 80, 2, 1, ?, ?, ?)
`)
for (let id = 1; id <= 10; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 12))
insert.run(USER, 11, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 12)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 13)?.status, 'locked')
assert.throws(() => progression.completeLevel(USER, 12, { answers: submission(fast, 2) }))
const first = progression.completeLevel(USER, 12, { answers: submission(slow, 3) })
assert.equal(first.score, 60)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 50 + 5)
assert.equal(first.reward.coins, 20 + 3)
const replay = progression.completeLevel(USER, 12, { answers: submission(fast, 3) })
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 12, { answers: submission({ initialWeekly: 15, exceptionWeekly: 5, continueWeekly: 15 }, 5) })
assert.equal(improved.feedback?.timeline.totalWeeks, 7)
assert.equal(improved.feedback?.timeline.finalBalance, 125)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 13)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).length, 13)
const parallelId = 'goal-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(parallelId, JSON.stringify(createDefaultUserState(parallelId)), now)
for (let id = 1; id <= 11; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const body = { answers: submission(fast, 5) }
const [left, right] = await Promise.all([
app.inject({ method: 'POST', url: '/api/gold-land/levels/12/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
app.inject({ method: 'POST', url: '/api/gold-land/levels/12/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map((response) => JSON.parse(response.payload) as { reward: { coins: number; xp: number } })
assert.equal(bodies.reduce((sum, item) => sum + item.reward.coins, 0), 20 + 9)
assert.equal(bodies.reduce((sum, item) => sum + item.reward.xp, 0), 50 + 15)
console.log('financial-goal tests passed')
} finally {
await app.close()
try { rmSync(testDir, { recursive: true, force: true }) } catch { /* ignore */ }
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+1 -1
View File
@@ -428,7 +428,7 @@ async function runTests() {
assert.equal(levelsMeta.statusCode, 200)
const levelsBody = parseJson<{ levels: Array<{ id: number; isActive: boolean }> }>(levelsMeta.payload)
assert.equal(levelsBody.levels.length, GOLD_LAND_LEVELS.length)
assert.equal(levelsBody.levels.filter((l) => l.isActive).length, 6)
assert.equal(levelsBody.levels.filter((l) => l.isActive).length, 13)
const noAuthProgress = await app.inject({ method: 'GET', url: '/api/gold-land/progress' })
assert.equal(noAuthProgress.statusCode, 401)
+199
View File
@@ -0,0 +1,199 @@
/**
* Portfolio game — temporary SQLite only.
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter'
import { gradePortfolioSubmission } from '../src/gold-land/portfolio/portfolio.grader'
import { simulatePortfolio } from '../src/gold-land/portfolio/portfolio.math'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'portfolio-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-portfolio-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3996'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
const correctQuestions = [
{ questionId: 'q-one', answerId: 'depend' },
{ questionId: 'q-loss', answerId: 'no' },
{ questionId: 'q-name', answerId: 'no' },
{ questionId: 'q-best', answerId: 'no' },
{ questionId: 'q-approach', answerId: 'balanced' },
]
const wrongIds = ['safe', 'yes', 'yes', 'yes', 'count']
function mix(holding: { sun: number; rain: number; wind: number }, count: number) {
return [
{ questionId: 'portfolio', holdings: holding },
...correctQuestions.map((answer, index) =>
index < count ? answer : { questionId: answer.questionId, answerId: wrongIds[index] ?? 'safe' },
),
]
}
function scenario(holding: { sun: number; rain: number; wind: number }, id: string) {
const found = simulatePortfolio(holding).find((item) => item.id === id)
assert.ok(found)
return found
}
async function createApp(): Promise<NestFastifyApplication> {
const { AppModule } = await import('../src/app.module')
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile()
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter())
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
app.useGlobalFilters(new ApiExceptionFilter())
await app.init()
await app.getHttpAdapter().getInstance().ready()
return app
}
async function run() {
const concentrated = { sun: 100, rain: 0, wind: 0 }
const split = { sun: 40, rain: 30, wind: 30 }
const other = { sun: 20, rain: 50, wind: 30 }
assert.deepEqual(
[scenario(concentrated, 'sunny').total, scenario(concentrated, 'rainy').total, scenario(concentrated, 'drop').total],
[120, 80, 90],
)
assert.equal(scenario(concentrated, 'sunny').changeLabel, 'سود 20 سکه')
assert.equal(scenario(concentrated, 'rainy').changeLabel, 'زیان 20 سکه')
assert.deepEqual(
[scenario(split, 'sunny').total, scenario(split, 'rainy').total, scenario(split, 'drop').total],
[105, 95, 90],
)
assert.equal(scenario(split, 'sunny').changeLabel, 'سود 5 سکه')
assert.equal(scenario(split, 'rainy').changeLabel, 'زیان 5 سکه')
assert.deepEqual(
[scenario(other, 'sunny').total, scenario(other, 'rainy').total, scenario(other, 'drop').total],
[99, 101, 90],
)
for (const holding of [concentrated, split, other]) {
const drop = scenario(holding, 'drop')
assert.equal(drop.total, 90)
assert.equal(drop.changeLabel, 'زیان 10 سکه')
}
assert.equal(gradePortfolioSubmission(mix(concentrated, 0)).score, 0)
assert.equal(gradePortfolioSubmission(mix(split, 2)).score, 40)
assert.equal(gradePortfolioSubmission(mix(other, 3)).score, 60)
assert.equal(gradePortfolioSubmission(mix(concentrated, 4)).score, 80)
assert.equal(gradePortfolioSubmission(mix(split, 5)).score, 100)
assert.equal(gradePortfolioSubmission(mix(concentrated, 5)).score, 100)
assert.throws(() => gradePortfolioSubmission(mix({ sun: 90, rain: 0, wind: 0 }, 5)))
assert.throws(() => gradePortfolioSubmission(mix({ sun: 12, rain: 44, wind: 44 }, 5)))
assert.throws(() => gradePortfolioSubmission(mix({ sun: -10, rain: 60, wind: 50 }, 5)))
assert.throws(() =>
gradePortfolioSubmission([{ questionId: 'portfolio', holdings: { sun: 100, rain: 0, wind: 0, gold: 0 } as never }, ...correctQuestions]),
)
assert.throws(() => gradePortfolioSubmission([...mix(split, 5), correctQuestions[0]!]))
assert.throws(() =>
gradePortfolioSubmission(mix(split, 5).map((answer, index) => (index === 1 ? { ...answer, answerId: 'nope' } : answer))),
)
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
USER,
JSON.stringify(createDefaultUserState(USER)),
now,
)
const insert = db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, 80, 2, 1, ?, ?, ?)
`)
for (let id = 1; id <= 7; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 9))
insert.run(USER, 8, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 9)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 10)?.status, 'locked')
const detail = progression.getGoldLandLevel(USER, 9)
const payload = JSON.stringify(detail.activity)
assert.equal(payload.includes('correctOptionId'), false)
assert.equal(payload.includes('آفتاب'), true)
assert.throws(() => progression.completeLevel(USER, 9, { answers: mix(split, 2) }))
const first = progression.completeLevel(USER, 9, { answers: mix(concentrated, 3) })
assert.equal(first.score, 60)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 50 + 5)
assert.equal(first.reward.coins, 20 + 3)
const replay = progression.completeLevel(USER, 9, { answers: mix(split, 3) })
assert.equal(replay.score, 60)
assert.equal(replay.reward.xp, 0)
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 9, { answers: mix(other, 5) })
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 10)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 11)?.status, 'locked')
const parallelId = 'portfolio-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
parallelId,
JSON.stringify(createDefaultUserState(parallelId)),
now,
)
for (let id = 1; id <= 8; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const body = { answers: mix(split, 5) }
const [left, right] = await Promise.all([
app.inject({ method: 'POST', url: '/api/gold-land/levels/9/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
app.inject({ method: 'POST', url: '/api/gold-land/levels/9/complete', headers: { authorization: `Bearer ${token}` }, payload: body }),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map((response) => JSON.parse(response.payload) as { reward: { xp: number; coins: number } })
assert.equal(bodies.reduce((sum, item) => sum + item.reward.coins, 0), 20 + 9)
assert.equal(bodies.reduce((sum, item) => sum + item.reward.xp, 0), 50 + 15)
const keys = db.prepare('SELECT reward_key FROM reward_ledger WHERE user_id = ? ORDER BY reward_key').all(parallelId) as Array<{ reward_key: string }>
assert.deepEqual(keys.map((row) => row.reward_key), [
'level:9:first-completion',
'level:9:star:1',
'level:9:star:2',
'level:9:star:3',
])
const bad = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/9/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: [{ questionId: 'portfolio', holdings: { sun: 50, rain: 50 } }] },
})
assert.equal(bad.statusCode, 400)
console.log('portfolio tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+1 -1
View File
@@ -139,7 +139,7 @@ async function runTests() {
let items = progression.buildLevelItems(TEST_USER)
const playable = getAllPlayableLessons()
assert.equal(playable.length, 6)
assert.equal(playable.length, 7)
for (const lesson of playable) {
assert.equal(countGradableQuestions(lesson), 5, `${lesson.id} should have 5 gradable questions`)
}
+166
View File
@@ -0,0 +1,166 @@
/**
* Risk-return challenge — temporary SQLite only.
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter'
import { gradeRiskReturnSubmission } from '../src/gold-land/risk-return/risk-return.grader'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'risk-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-risk-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3995'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
const correct = [
{ questionId: 'q-loss', answerId: 'b' },
{ questionId: 'q-certain', answerId: 'no' },
{ questionId: 'q-need', answerId: 'keep' },
{ questionId: 'q-compare', answerId: 'range' },
{ questionId: 'q-promise', answerId: 'check' },
]
const wrongIds = ['a', 'yes', 'a', 'certain-gain', 'trust']
function withCorrectCount(count: number) {
return correct.map((answer, index) =>
index < count ? answer : { questionId: answer.questionId, answerId: wrongIds[index] ?? 'a' },
)
}
async function createApp(): Promise<NestFastifyApplication> {
const { AppModule } = await import('../src/app.module')
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile()
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter())
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
app.useGlobalFilters(new ApiExceptionFilter())
await app.init()
await app.getHttpAdapter().getInstance().ready()
return app
}
async function run() {
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
assert.equal(gradeRiskReturnSubmission(withCorrectCount(0)).score, 0)
assert.equal(gradeRiskReturnSubmission(withCorrectCount(2)).score, 40)
assert.equal(gradeRiskReturnSubmission(withCorrectCount(3)).score, 60)
assert.equal(gradeRiskReturnSubmission(withCorrectCount(4)).score, 80)
const perfect = gradeRiskReturnSubmission(correct)
const perfectAgain = gradeRiskReturnSubmission([...correct].reverse())
assert.equal(perfect.score, 100)
assert.equal(perfectAgain.score, 100)
assert.equal(perfect.feedback.summary.includes('تضمین سود نیست'), true)
assert.throws(() => gradeRiskReturnSubmission([...correct, correct[0]!]))
assert.throws(() =>
gradeRiskReturnSubmission(correct.map((answer) => ({ ...answer, orderedItemIds: ['x'] }))),
)
assert.throws(() =>
gradeRiskReturnSubmission(correct.map((answer, index) => (index === 0 ? { ...answer, questionId: 'nope' } : answer))),
)
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
USER,
JSON.stringify(createDefaultUserState(USER)),
now,
)
const insert = db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, 80, 2, 1, ?, ?, ?)
`)
for (let id = 1; id <= 6; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 8))
insert.run(USER, 7, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 8)?.status, 'available')
const detail = progression.getGoldLandLevel(USER, 8)
const payload = JSON.stringify(detail.activity)
assert.equal(payload.includes('correctOptionId'), false)
assert.equal(payload.includes('[20,20,22,22]') || payload.includes('20,20,22,22') || payload.includes('22'), true)
assert.equal(payload.includes('8,16,28,36') || payload.includes('[8,16,28,36]'), true)
assert.throws(() => progression.completeLevel(USER, 8, { answers: withCorrectCount(2) }))
const first = progression.completeLevel(USER, 8, { answers: withCorrectCount(3) })
assert.equal(first.score, 60)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 50 + 5)
assert.equal(first.reward.coins, 20 + 3)
const replay = progression.completeLevel(USER, 8, { answers: withCorrectCount(3) })
assert.equal(replay.reward.xp, 0)
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 8, { answers: correct })
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 9)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 10)?.status, 'locked')
const parallelId = 'risk-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(
parallelId,
JSON.stringify(createDefaultUserState(parallelId)),
now,
)
for (let id = 1; id <= 7; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const [left, right] = await Promise.all([
app.inject({
method: 'POST',
url: '/api/gold-land/levels/8/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: correct },
}),
app.inject({
method: 'POST',
url: '/api/gold-land/levels/8/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: correct },
}),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map(
(response) => JSON.parse(response.payload) as { reward: { xp: number; coins: number } },
)
assert.equal(bodies.reduce((sum, body) => sum + body.reward.coins, 0), 20 + 9)
assert.equal(bodies.reduce((sum, body) => sum + body.reward.xp, 0), 50 + 15)
const keys = db.prepare('SELECT reward_key FROM reward_ledger WHERE user_id = ? ORDER BY reward_key').all(parallelId) as Array<{ reward_key: string }>
assert.deepEqual(keys.map((row) => row.reward_key), [
'level:8:first-completion',
'level:8:star:1',
'level:8:star:2',
'level:8:star:3',
])
console.log('risk-return tests passed')
} finally {
await app.close()
try {
rmSync(testDir, { recursive: true, force: true })
} catch {
// ignore
}
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
+138
View File
@@ -0,0 +1,138 @@
/**
* Smart shop — temporary SQLite only.
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'
import { ApiExceptionFilter } from '../src/common/filters/api-exception.filter'
import { finalCost } from '../src/gold-land/smart-shop/shop.math'
import { gradeSmartShopSubmission } from '../src/gold-land/smart-shop/shop.grader'
import { smartShopMission } from '../src/gold-land/smart-shop/shop.mission'
import { GoldLandProgressionService } from '../src/gold-land/services/progression.service'
import type { DatabaseService } from '../src/database/database.service'
import { createDefaultUserState } from '../src/shared/catalog/default-user-state'
const USER = 'shop-test-user'
const testDir = mkdtempSync(join(tmpdir(), 'gold-land-shop-'))
process.env.DB_PATH = join(testDir, 'test.db')
process.env.NODE_ENV = 'test'
process.env.PORT = '3998'
process.env.JWT_SECRET = 'gold-land-dev-secret-change-in-production'
const correct = [
{ questionId: 'notebook-choice', answerId: 'b' },
{ questionId: 'notebook-why', answerId: 'fits-and-cheaper' },
{ questionId: 'discount-choice', answerId: 'b' },
{ questionId: 'discount-why', answerId: 'pay-amount' },
{ questionId: 'info-choice', answerId: 'ask' },
{ questionId: 'info-why', answerId: 'incomplete' },
]
const wrong = ['a', 'cheapest-tag', 'a', 'bigger-discount', 'buy-now', 'hurry']
function withCount(count: number) {
return correct.map((answer, index) => (index < count ? answer : { questionId: answer.questionId, answerId: wrong[index]! }))
}
async function createApp(): Promise<NestFastifyApplication> {
const { AppModule } = await import('../src/app.module')
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile()
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter())
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
app.useGlobalFilters(new ApiExceptionFilter())
await app.init()
await app.getHttpAdapter().getInstance().ready()
return app
}
async function run() {
assert.equal(finalCost({ price: 40, delivery: 5 }), 45)
assert.equal(finalCost({ price: 38, delivery: 12 }), 50)
assert.equal(finalCost({ price: 30, delivery: 0 }), 30)
assert.equal(finalCost({ price: 28, delivery: 0 }), 28)
assert.equal(finalCost({ price: 45, delivery: null }), null)
assert.notEqual(finalCost({ price: 45, delivery: null }), 0)
const scores = [0, 1, 2, 3, 4, 5, 6].map((count) => gradeSmartShopSubmission(withCount(count)).score)
assert.deepEqual(scores, [0, 17, 33, 50, 67, 83, 100])
assert.throws(() => gradeSmartShopSubmission(correct.slice(0, 5)))
assert.throws(() => gradeSmartShopSubmission([...correct, correct[0]!]))
assert.throws(() => gradeSmartShopSubmission(correct.map((answer, index) => (index === 0 ? { ...answer, answerId: 'ask' } : answer))))
assert.throws(() => gradeSmartShopSubmission(correct.map((answer) => ({ ...answer, price: 1 }))))
const payload = JSON.stringify(smartShopMission)
assert.equal(payload.includes('correctAnswerId'), false)
assert.equal(payload.includes('"explanation"'), false)
const info = smartShopMission.missions[2]?.cards[0]
assert.equal(info?.delivery, null)
assert.equal(info?.finalCost, null)
const app = await createApp()
const database = app.get((await import('../src/database/database.service')).DatabaseService) as DatabaseService
const progression = app.get(GoldLandProgressionService)
const db = database.getDb()
try {
const now = new Date().toISOString()
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(USER, JSON.stringify(createDefaultUserState(USER)), now)
const insert = db.prepare(`
INSERT INTO user_level_progress (
user_id, level_id, best_score, stars, attempts_count, started_at, completed_at, updated_at
) VALUES (?, ?, 80, 2, 1, ?, ?, ?)
`)
for (let id = 1; id <= 9; id += 1) insert.run(USER, id, now, now, now)
assert.throws(() => progression.startLevel(USER, 11))
insert.run(USER, 10, now, now, now)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 11)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 12)?.status, 'locked')
assert.throws(() => progression.completeLevel(USER, 11, { answers: withCount(3) }))
const first = progression.completeLevel(USER, 11, { answers: withCount(4) })
assert.equal(first.score, 67)
assert.equal(first.stars, 1)
assert.equal(first.reward.xp, 45 + 5)
assert.equal(first.reward.coins, 18 + 3)
const replay = progression.completeLevel(USER, 11, { answers: withCount(4) })
assert.equal(replay.reward.coins, 0)
const improved = progression.completeLevel(USER, 11, { answers: correct })
assert.equal(improved.score, 100)
assert.equal(improved.stars, 3)
assert.equal(improved.reward.xp, 10)
assert.equal(improved.reward.coins, 6)
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 12)?.status, 'available')
assert.equal(progression.buildLevelItems(USER).find((level) => level.id === 13)?.status, 'locked')
const parallelId = 'shop-parallel-user'
db.prepare('INSERT INTO users (id, state_json, updated_at) VALUES (?, ?, ?)').run(parallelId, JSON.stringify(createDefaultUserState(parallelId)), now)
for (let id = 1; id <= 10; id += 1) insert.run(parallelId, id, now, now, now)
const { JwtService } = await import('@nestjs/jwt')
const token = app.get(JwtService).sign({ userId: parallelId })
const [left, right] = await Promise.all([
app.inject({ method: 'POST', url: '/api/gold-land/levels/11/complete', headers: { authorization: `Bearer ${token}` }, payload: { answers: correct } }),
app.inject({ method: 'POST', url: '/api/gold-land/levels/11/complete', headers: { authorization: `Bearer ${token}` }, payload: { answers: correct } }),
])
assert.equal(left.statusCode, 200)
assert.equal(right.statusCode, 200)
const bodies = [left, right].map((response) => JSON.parse(response.payload) as { reward: { coins: number; xp: number } })
assert.equal(bodies.reduce((sum, body) => sum + body.reward.coins, 0), 18 + 9)
assert.equal(bodies.reduce((sum, body) => sum + body.reward.xp, 0), 45 + 15)
const bad = await app.inject({
method: 'POST',
url: '/api/gold-land/levels/11/complete',
headers: { authorization: `Bearer ${token}` },
payload: { answers: [{ questionId: 'notebook-choice', answerId: 'b', score: 100 }] },
})
assert.equal(bad.statusCode, 400)
console.log('smart-shop tests passed')
} finally {
await app.close()
try { rmSync(testDir, { recursive: true, force: true }) } catch { /* ignore */ }
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})