From 866be223956ef5d3961b0d835b36adedfeb35c82 Mon Sep 17 00:00:00 2001 From: safavimohadeseh78 Date: Thu, 22 Oct 2026 19:41:24 +0330 Subject: [PATCH] feat: complete the thirteen-stage financial learning path Server grades levels 7-13 and keeps castle attempt results separate from stored progress. Co-authored-by: Cursor --- docs/learning-path-status.md | 57 +++++ docs/level-10-compound-interest.md | 48 ++++ docs/level-11-smart-shop.md | 35 +++ docs/level-12-financial-goal.md | 36 +++ docs/level-13-castle-exam.md | 32 +++ docs/level-7-budgeting.md | 35 +++ docs/level-8-risk-return.md | 31 +++ docs/level-9-portfolio.md | 49 ++++ package.json | 9 +- src/gold-land/budgeting/budgeting.grader.ts | 175 +++++++++++++ src/gold-land/budgeting/budgeting.mission.ts | 61 +++++ src/gold-land/budgeting/budgeting.types.ts | 45 ++++ src/gold-land/castle-exam/castle.grader.ts | 82 ++++++ src/gold-land/castle-exam/castle.mission.ts | 194 +++++++++++++++ src/gold-land/castle-exam/castle.types.ts | 31 +++ src/gold-land/dto/complete-level.dto.ts | 62 +++++ src/gold-land/financial-goal/goal.grader.ts | 133 ++++++++++ src/gold-land/financial-goal/goal.math.ts | 55 +++++ src/gold-land/financial-goal/goal.mission.ts | 105 ++++++++ src/gold-land/financial-goal/goal.types.ts | 42 ++++ src/gold-land/gold-land.controller.ts | 4 +- .../lessons/compound-interest.lesson.ts | 102 ++++++++ src/gold-land/lessons/growth-example.ts | 54 ++++ src/gold-land/lessons/lesson.grader.ts | 21 +- src/gold-land/lessons/lesson.registry.ts | 2 + src/gold-land/lessons/lesson.sanitizer.ts | 1 + src/gold-land/lessons/lesson.types.ts | 37 ++- src/gold-land/levels/level.types.ts | 31 ++- src/gold-land/levels/levels.ts | 14 +- src/gold-land/portfolio/portfolio.grader.ts | 118 +++++++++ src/gold-land/portfolio/portfolio.math.ts | 83 +++++++ src/gold-land/portfolio/portfolio.mission.ts | 124 ++++++++++ src/gold-land/portfolio/portfolio.types.ts | 46 ++++ .../risk-return/risk-return.grader.ts | 75 ++++++ .../risk-return/risk-return.mission.ts | 89 +++++++ .../risk-return/risk-return.types.ts | 42 ++++ src/gold-land/services/progression.service.ts | 198 ++++++++++++++- src/gold-land/smart-shop/shop.grader.ts | 84 +++++++ src/gold-land/smart-shop/shop.math.ts | 12 + src/gold-land/smart-shop/shop.mission.ts | 156 ++++++++++++ src/gold-land/smart-shop/shop.types.ts | 42 ++++ src/progression/levels.ts | 14 +- test/budgeting.test.ts | 233 ++++++++++++++++++ test/castle-exam.test.ts | 141 +++++++++++ test/compound-interest.test.ts | 172 +++++++++++++ test/concurrency.test.ts | 2 +- test/financial-goal.test.ts | 149 +++++++++++ test/integration.test.ts | 2 +- test/portfolio.test.ts | 199 +++++++++++++++ test/progression.test.ts | 2 +- test/risk-return.test.ts | 166 +++++++++++++ test/smart-shop.test.ts | 138 +++++++++++ 52 files changed, 3840 insertions(+), 30 deletions(-) create mode 100644 docs/learning-path-status.md create mode 100644 docs/level-10-compound-interest.md create mode 100644 docs/level-11-smart-shop.md create mode 100644 docs/level-12-financial-goal.md create mode 100644 docs/level-13-castle-exam.md create mode 100644 docs/level-7-budgeting.md create mode 100644 docs/level-8-risk-return.md create mode 100644 docs/level-9-portfolio.md create mode 100644 src/gold-land/budgeting/budgeting.grader.ts create mode 100644 src/gold-land/budgeting/budgeting.mission.ts create mode 100644 src/gold-land/budgeting/budgeting.types.ts create mode 100644 src/gold-land/castle-exam/castle.grader.ts create mode 100644 src/gold-land/castle-exam/castle.mission.ts create mode 100644 src/gold-land/castle-exam/castle.types.ts create mode 100644 src/gold-land/financial-goal/goal.grader.ts create mode 100644 src/gold-land/financial-goal/goal.math.ts create mode 100644 src/gold-land/financial-goal/goal.mission.ts create mode 100644 src/gold-land/financial-goal/goal.types.ts create mode 100644 src/gold-land/lessons/compound-interest.lesson.ts create mode 100644 src/gold-land/lessons/growth-example.ts create mode 100644 src/gold-land/portfolio/portfolio.grader.ts create mode 100644 src/gold-land/portfolio/portfolio.math.ts create mode 100644 src/gold-land/portfolio/portfolio.mission.ts create mode 100644 src/gold-land/portfolio/portfolio.types.ts create mode 100644 src/gold-land/risk-return/risk-return.grader.ts create mode 100644 src/gold-land/risk-return/risk-return.mission.ts create mode 100644 src/gold-land/risk-return/risk-return.types.ts create mode 100644 src/gold-land/smart-shop/shop.grader.ts create mode 100644 src/gold-land/smart-shop/shop.math.ts create mode 100644 src/gold-land/smart-shop/shop.mission.ts create mode 100644 src/gold-land/smart-shop/shop.types.ts create mode 100644 test/budgeting.test.ts create mode 100644 test/castle-exam.test.ts create mode 100644 test/compound-interest.test.ts create mode 100644 test/financial-goal.test.ts create mode 100644 test/portfolio.test.ts create mode 100644 test/risk-return.test.ts create mode 100644 test/smart-shop.test.ts diff --git a/docs/learning-path-status.md b/docs/learning-path-status.md new file mode 100644 index 0000000..b5f4f21 --- /dev/null +++ b/docs/learning-path-status.md @@ -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 است. diff --git a/docs/level-10-compound-interest.md b/docs/level-10-compound-interest.md new file mode 100644 index 0000000..f7cea77 --- /dev/null +++ b/docs/level-10-compound-interest.md @@ -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 و ۱۸ سکه است. diff --git a/docs/level-11-smart-shop.md b/docs/level-11-smart-shop.md new file mode 100644 index 0000000..8dcca55 --- /dev/null +++ b/docs/level-11-smart-shop.md @@ -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 }` است. مبلغ، تخفیف، امتیاز و پاداش کلاینت پذیرفته نمی‌شود. diff --git a/docs/level-12-financial-goal.md b/docs/level-12-financial-goal.md new file mode 100644 index 0000000..843badf --- /dev/null +++ b/docs/level-12-financial-goal.md @@ -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 }`. زمان و موجودی کلاینت پذیرفته نمی‌شود. diff --git a/docs/level-13-castle-exam.md b/docs/level-13-castle-exam.md new file mode 100644 index 0000000..5e113e0 --- /dev/null +++ b/docs/level-13-castle-exam.md @@ -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 عمومی نیست. diff --git a/docs/level-7-budgeting.md b/docs/level-7-budgeting.md new file mode 100644 index 0000000..250b302 --- /dev/null +++ b/docs/level-7-budgeting.md @@ -0,0 +1,35 @@ +# مرحله ۷ — مأموریت بودجه‌بندی + +- گروه سنی: ۹ تا ۱۲ سال. +- مدت هدف: حدود ۵ تا ۸ دقیقه، بدون محدودیت زمانی. +- لحن: فارسی ساده و صمیمی. +- راهنما: تصویر موجود `professor` با عنوان «راهنما». شخصیت جدید ساخته نشده است. +- نوع فعالیت: `game` با کلید `budgeting`. این مرحله درس پنج‌سؤالی نیست. + +## هدف آموزشی + +کودک ۱۰۰ سکهٔ آموزشی را بین نیاز سفر (رفت‌وآمد و آب و غذا)، هدف پس‌انداز، و تفریح اختیاری تقسیم می‌کند و بعد از یک تغییر مشخص، برنامه را اصلاح می‌کند. این سکه‌ها از کیف پول کاربر کم نمی‌شوند. + +## سناریو + +- رفت‌وآمد: ابتدا حداقل ۲۰، بعد از اتفاق حداقل ۳۰. +- آب و غذا: حداقل ۲۵. +- پس‌انداز برای هدف: هدف ۲۰. حداقل ۱۰ برای بخشی از امتیاز. +- تفریح: اختیاری و بدون امتیاز اضافه. +- ذخیره: باقی‌ماندهٔ تخصیص‌نشده. صفر بودن آن خطا نیست و تنها راه درست معرفی نمی‌شود. +- گام تغییر: ۵ سکه. +- اتفاق ثابت: «هزینه رفت‌وآمد ۱۰ سکه بیشتر شد؛ حالا ۳۰ سکه لازم داری.» معرفی فقط می‌گوید ممکن است شرایط تغییر کند. + +## Rubric + +هر کدام از برنامهٔ اولیه و برنامهٔ نهایی پنج معیار ۱۰امتیازی دارد. مجموع ۱۰۰ است. + +1. مجموع تخصیص حداکثر ۱۰۰. +2. تأمین حداقل رفت‌وآمد همان مرحله. +3. تأمین آب و غذا. +4. حداقل ۱۰ سکه پس‌انداز. +5. رسیدن پس‌انداز به ۲۰. + +ورودی منفی، غیرصحیح، خارج از گام ۵، مجموع بیش از ۱۰۰، یا فیلد ناشناخته با خطای ورودی رد می‌شود. برنامهٔ اول بدون حداقل ۲۰ رفت‌وآمد و ۲۵ آب‌وغذا، و برنامهٔ نهایی بدون حداقل ۳۰ رفت‌وآمد و ۲۵ آب‌وغذا، با خطای جداگانهٔ نیاز ضروری رد می‌شوند و امتیاز نمی‌گیرند. پس‌انداز، تفریح و ذخیره شرط تأیید نیستند. + +پاداش پایهٔ ۴۵ XP و ۱۸ سکه و پاداش هر ستارهٔ تازه (۵ XP و ۳ سکه) فقط از سرور و دفتر پاداش است. امتیاز زیر ۶۰ تکمیل نمی‌شود. مرحلهٔ ۸ غیرفعال می‌ماند. diff --git a/docs/level-8-risk-return.md b/docs/level-8-risk-return.md new file mode 100644 index 0000000..0289e36 --- /dev/null +++ b/docs/level-8-risk-return.md @@ -0,0 +1,31 @@ +# مرحله ۸ — ریسک و بازده + +- گروه سنی: ۹ تا ۱۲ سال. +- مدت هدف: حدود ۵ تا ۸ دقیقه، بدون محدودیت زمانی. +- راهنما: تصویر موجود `professor`. +- نوع: `challenge` با کلید `risk-return`. + +## هدف آموزشی + +کودک تفاوت نتیجهٔ ممکن و نتیجهٔ تضمینی را می‌فهمد، احتمال زیان را تشخیص می‌دهد و انتخاب را با هدف و زمان نیاز به پول هماهنگ می‌کند. امتیاز برای فهم مفهوم و دلیل انتخاب است، نه برای انتخاب پرریسک یا سود اتفاقی. + +## مثال ساختگی + +برای ۲۰ سکهٔ آموزشی که از کیف پول بازی کم نمی‌شود: + +- پیشنهاد الف، چهار پیامد هم‌احتمال: ۲۰، ۲۰، ۲۲، ۲۲. +- پیشنهاد ب، چهار پیامد هم‌احتمال: ۸، ۱۶، ۲۸، ۳۶. + +در این مثال هر کارت شانس یکسان دارد. در دنیای واقعی همیشه احتمال‌ها را دقیق نمی‌دانیم. نبود زیان اسمی در پیشنهاد الف فقط مربوط به همین مثال است و تضمین عمومی سرمایه‌گذاری نیست. هیچ دارایی واقعی توصیه نمی‌شود. + +## Rubric + +پنج فعالیت، هر کدام ۲۰ امتیاز. جمع ۱۰۰. + +1. امکان برگشت کمتر از ۲۰: پیشنهاد ب. +2. دریافت قطعی ۳۶ در پیشنهاد ب: خیر. +3. نیاز ضروری تا دو روز دیگر: پول را وارد پیشنهادهای نامطمئن نکن. +4. مقایسه: پیشنهاد ب امکان نتیجهٔ بالاتر و زیان بیشتر دارد؛ سود بیشتر قطعی نیست. +5. وعدهٔ سود زیاد بدون ریسک: تضمین معتبر فرض نکن، بررسی کن و از بزرگسال قابل اعتماد کمک بگیر. + +امتیاز به قرعه وابسته نیست. پاسخ درست در محتوای عمومی سؤال نیست. پاداش پایه ۵۰ XP و ۲۰ سکه است. مرحلهٔ ۹ غیرفعال می‌ماند. diff --git a/docs/level-9-portfolio.md b/docs/level-9-portfolio.md new file mode 100644 index 0000000..d7cf5eb --- /dev/null +++ b/docs/level-9-portfolio.md @@ -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`. diff --git a/package.json b/package.json index 2391a39..0e10934 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/gold-land/budgeting/budgeting.grader.ts b/src/gold-land/budgeting/budgeting.grader.ts new file mode 100644 index 0000000..128c111 --- /dev/null +++ b/src/gold-land/budgeting/budgeting.grader.ts @@ -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 { + 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() + 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 +} diff --git a/src/gold-land/budgeting/budgeting.mission.ts b/src/gold-land/budgeting/budgeting.mission.ts new file mode 100644 index 0000000..60c37cd --- /dev/null +++ b/src/gold-land/budgeting/budgeting.mission.ts @@ -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, + }, + ], +} diff --git a/src/gold-land/budgeting/budgeting.types.ts b/src/gold-land/budgeting/budgeting.types.ts new file mode 100644 index 0000000..659e549 --- /dev/null +++ b/src/gold-land/budgeting/budgeting.types.ts @@ -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 + }> +} diff --git a/src/gold-land/castle-exam/castle.grader.ts b/src/gold-land/castle-exam/castle.grader.ts new file mode 100644 index 0000000..4bf4dc3 --- /dev/null +++ b/src/gold-land/castle-exam/castle.grader.ts @@ -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() + const chosen = new Map() + 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 +} diff --git a/src/gold-land/castle-exam/castle.mission.ts b/src/gold-land/castle-exam/castle.mission.ts new file mode 100644 index 0000000..63ac38d --- /dev/null +++ b/src/gold-land/castle-exam/castle.mission.ts @@ -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 = + 'آفرین! مسیر آموزشی قلعه را کامل کردی. می‌توانی برای مرور و بهترشدن دوباره بازی کنی.' diff --git a/src/gold-land/castle-exam/castle.types.ts b/src/gold-land/castle-exam/castle.types.ts new file mode 100644 index 0000000..95703e0 --- /dev/null +++ b/src/gold-land/castle-exam/castle.types.ts @@ -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 + }> +} diff --git a/src/gold-land/dto/complete-level.dto.ts b/src/gold-land/dto/complete-level.dto.ts index efc0c74..f6e19ec 100644 --- a/src/gold-land/dto/complete-level.dto.ts +++ b/src/gold-land/dto/complete-level.dto.ts @@ -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 { diff --git a/src/gold-land/financial-goal/goal.grader.ts b/src/gold-land/financial-goal/goal.grader.ts new file mode 100644 index 0000000..76e0cf5 --- /dev/null +++ b/src/gold-land/financial-goal/goal.grader.ts @@ -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() + let plan: GoalPlanInput | null = null + const chosen = new Map() + 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 +} diff --git a/src/gold-land/financial-goal/goal.math.ts b/src/gold-land/financial-goal/goal.math.ts new file mode 100644 index 0000000..3a4534f --- /dev/null +++ b/src/gold-land/financial-goal/goal.math.ts @@ -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, + } +} diff --git a/src/gold-land/financial-goal/goal.mission.ts b/src/gold-land/financial-goal/goal.mission.ts new file mode 100644 index 0000000..97288f5 --- /dev/null +++ b/src/gold-land/financial-goal/goal.mission.ts @@ -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 +} diff --git a/src/gold-land/financial-goal/goal.types.ts b/src/gold-land/financial-goal/goal.types.ts new file mode 100644 index 0000000..e363efa --- /dev/null +++ b/src/gold-land/financial-goal/goal.types.ts @@ -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 }> +} diff --git a/src/gold-land/gold-land.controller.ts b/src/gold-land/gold-land.controller.ts index 5bf8915..0f56b03 100644 --- a/src/gold-land/gold-land.controller.ts +++ b/src/gold-land/gold-land.controller.ts @@ -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, }) } diff --git a/src/gold-land/lessons/compound-interest.lesson.ts b/src/gold-land/lessons/compound-interest.lesson.ts new file mode 100644 index 0000000..94470a5 --- /dev/null +++ b/src/gold-land/lessons/compound-interest.lesson.ts @@ -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: 'رشد روی موجودی جدید، تضمین سود واقعی نیست.', + }, + ], +} diff --git a/src/gold-land/lessons/growth-example.ts b/src/gold-land/lessons/growth-example.ts new file mode 100644 index 0000000..0c3ec41 --- /dev/null +++ b/src/gold-land/lessons/growth-example.ts @@ -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 } +} diff --git a/src/gold-land/lessons/lesson.grader.ts b/src/gold-land/lessons/lesson.grader.ts index e3c1a2b..3d7213a 100644 --- a/src/gold-land/lessons/lesson.grader.ts +++ b/src/gold-land/lessons/lesson.grader.ts @@ -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 } } + : {}), + } } diff --git a/src/gold-land/lessons/lesson.registry.ts b/src/gold-land/lessons/lesson.registry.ts index 5806ae9..cac7493 100644 --- a/src/gold-land/lessons/lesson.registry.ts +++ b/src/gold-land/lessons/lesson.registry.ts @@ -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 = { 'what-is-gold': whatIsGoldLesson, 'gold-market': goldMarketLesson, inflation: inflationLesson, + 'compound-interest': compoundInterestLesson, } export function getPlayableLesson(activityKey: string): LessonDefinition | null { diff --git a/src/gold-land/lessons/lesson.sanitizer.ts b/src/gold-land/lessons/lesson.sanitizer.ts index 0f9d0cd..d8f6602 100644 --- a/src/gold-land/lessons/lesson.sanitizer.ts +++ b/src/gold-land/lessons/lesson.sanitizer.ts @@ -11,6 +11,7 @@ const STRIP_KEYS = new Set([ 'correctOption', 'answerKey', 'correctOrder', + 'explanation', ]) function sanitizeStep(step: LessonStep): SanitizedLessonStep { diff --git a/src/gold-land/lessons/lesson.types.ts b/src/gold-land/lessons/lesson.types.ts index c73990f..1c58622 100644 --- a/src/gold-land/lessons/lesson.types.ts +++ b/src/gold-land/lessons/lesson.types.ts @@ -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 | Omit | Omit + | GrowthTableStep export interface SanitizedLessonDefinition { id: string diff --git a/src/gold-land/levels/level.types.ts b/src/gold-land/levels/level.types.ts index 42892f2..46bd6b6 100644 --- a/src/gold-land/levels/level.types.ts +++ b/src/gold-land/levels/level.types.ts @@ -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 + 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[] } } diff --git a/src/gold-land/levels/levels.ts b/src/gold-land/levels/levels.ts index 083ad30..016786c 100644 --- a/src/gold-land/levels/levels.ts +++ b/src/gold-land/levels/levels.ts @@ -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, }, ] diff --git a/src/gold-land/portfolio/portfolio.grader.ts b/src/gold-land/portfolio/portfolio.grader.ts new file mode 100644 index 0000000..4ef80a4 --- /dev/null +++ b/src/gold-land/portfolio/portfolio.grader.ts @@ -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 + 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() + let holding: PortfolioHolding | null = null + const chosen = new Map() + + 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 +} diff --git a/src/gold-land/portfolio/portfolio.math.ts b/src/gold-land/portfolio/portfolio.math.ts new file mode 100644 index 0000000..c884446 --- /dev/null +++ b/src/gold-land/portfolio/portfolio.math.ts @@ -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), + } + }) +} diff --git a/src/gold-land/portfolio/portfolio.mission.ts b/src/gold-land/portfolio/portfolio.mission.ts new file mode 100644 index 0000000..d3fdd51 --- /dev/null +++ b/src/gold-land/portfolio/portfolio.mission.ts @@ -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 +} diff --git a/src/gold-land/portfolio/portfolio.types.ts b/src/gold-land/portfolio/portfolio.types.ts new file mode 100644 index 0000000..2d403ca --- /dev/null +++ b/src/gold-land/portfolio/portfolio.types.ts @@ -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[] +} diff --git a/src/gold-land/risk-return/risk-return.grader.ts b/src/gold-land/risk-return/risk-return.grader.ts new file mode 100644 index 0000000..6f3cfc5 --- /dev/null +++ b/src/gold-land/risk-return/risk-return.grader.ts @@ -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() + const byId = new Map() + + 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 +} diff --git a/src/gold-land/risk-return/risk-return.mission.ts b/src/gold-land/risk-return/risk-return.mission.ts new file mode 100644 index 0000000..efc2f83 --- /dev/null +++ b/src/gold-land/risk-return/risk-return.mission.ts @@ -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 +} diff --git a/src/gold-land/risk-return/risk-return.types.ts b/src/gold-land/risk-return/risk-return.types.ts new file mode 100644 index 0000000..3665a17 --- /dev/null +++ b/src/gold-land/risk-return/risk-return.types.ts @@ -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[] +} diff --git a/src/gold-land/services/progression.service.ts b/src/gold-land/services/progression.service.ts index bdb6e37..48c9f49 100644 --- a/src/gold-land/services/progression.service.ts +++ b/src/gold-land/services/progression.service.ts @@ -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, 'ارزیابی این مرحله هنوز پیاده‌سازی نشده') } } diff --git a/src/gold-land/smart-shop/shop.grader.ts b/src/gold-land/smart-shop/shop.grader.ts new file mode 100644 index 0000000..cf67b23 --- /dev/null +++ b/src/gold-land/smart-shop/shop.grader.ts @@ -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() + const chosen = new Map() + 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 +} diff --git a/src/gold-land/smart-shop/shop.math.ts b/src/gold-land/smart-shop/shop.math.ts new file mode 100644 index 0000000..a909ff1 --- /dev/null +++ b/src/gold-land/smart-shop/shop.math.ts @@ -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 +} diff --git a/src/gold-land/smart-shop/shop.mission.ts b/src/gold-land/smart-shop/shop.mission.ts new file mode 100644 index 0000000..2196eb2 --- /dev/null +++ b/src/gold-land/smart-shop/shop.mission.ts @@ -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 }, + ] +} diff --git a/src/gold-land/smart-shop/shop.types.ts b/src/gold-land/smart-shop/shop.types.ts new file mode 100644 index 0000000..0b82240 --- /dev/null +++ b/src/gold-land/smart-shop/shop.types.ts @@ -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 }> +} diff --git a/src/progression/levels.ts b/src/progression/levels.ts index 7f68291..e10cc11 100644 --- a/src/progression/levels.ts +++ b/src/progression/levels.ts @@ -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) { diff --git a/test/budgeting.test.ts b/test/budgeting.test.ts new file mode 100644 index 0000000..98885df --- /dev/null +++ b/test/budgeting.test.ts @@ -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, finalPlan: ReturnType) { + return [ + { questionId: 'initial', allocations: initial }, + { questionId: 'final', allocations: finalPlan }, + ] +} + +async function createApp(): Promise { + const { AppModule } = await import('../src/app.module') + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile() + const app = moduleRef.createNestApplication(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) +}) diff --git a/test/castle-exam.test.ts b/test/castle-exam.test.ts new file mode 100644 index 0000000..d1d1c85 --- /dev/null +++ b/test/castle-exam.test.ts @@ -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 { + const { AppModule } = await import('../src/app.module') + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile() + const app = moduleRef.createNestApplication(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) +}) diff --git a/test/compound-interest.test.ts b/test/compound-interest.test.ts new file mode 100644 index 0000000..703486a --- /dev/null +++ b/test/compound-interest.test.ts @@ -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 { + const { AppModule } = await import('../src/app.module') + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile() + const app = moduleRef.createNestApplication(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) +}) diff --git a/test/concurrency.test.ts b/test/concurrency.test.ts index c84e7b6..82d844e 100644 --- a/test/concurrency.test.ts +++ b/test/concurrency.test.ts @@ -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({ diff --git a/test/financial-goal.test.ts b/test/financial-goal.test.ts new file mode 100644 index 0000000..6d1a1b0 --- /dev/null +++ b/test/financial-goal.test.ts @@ -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 { + const { AppModule } = await import('../src/app.module') + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile() + const app = moduleRef.createNestApplication(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) +}) diff --git a/test/integration.test.ts b/test/integration.test.ts index 03763f6..e38517c 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -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) diff --git a/test/portfolio.test.ts b/test/portfolio.test.ts new file mode 100644 index 0000000..2fea4cd --- /dev/null +++ b/test/portfolio.test.ts @@ -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 { + const { AppModule } = await import('../src/app.module') + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile() + const app = moduleRef.createNestApplication(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) +}) diff --git a/test/progression.test.ts b/test/progression.test.ts index 4542418..4fd8cf9 100644 --- a/test/progression.test.ts +++ b/test/progression.test.ts @@ -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`) } diff --git a/test/risk-return.test.ts b/test/risk-return.test.ts new file mode 100644 index 0000000..1636ade --- /dev/null +++ b/test/risk-return.test.ts @@ -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 { + const { AppModule } = await import('../src/app.module') + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile() + const app = moduleRef.createNestApplication(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) +}) diff --git a/test/smart-shop.test.ts b/test/smart-shop.test.ts new file mode 100644 index 0000000..b1ee123 --- /dev/null +++ b/test/smart-shop.test.ts @@ -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 { + const { AppModule } = await import('../src/app.module') + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile() + const app = moduleRef.createNestApplication(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) +})