feat: complete the thirteen-stage learning experience and E2E journey
Frontend CI / unit (test, type-check, build) (push) Successful in 7m21s
Frontend CI / browser E2E (frontend + pinned backend) (push) Successful in 11m38s

Play levels 7-13 in the app and pin E2E to the local thirteen-stage backend commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-22 19:42:04 +03:30
co-authored by Cursor
parent bb29cc6bdb
commit c53a67831d
38 changed files with 3764 additions and 8 deletions
+1 -1
View File
@@ -2,6 +2,6 @@
"repositoryUrl": "https://get.feep.ir/gold_land/gold_land_server.git", "repositoryUrl": "https://get.feep.ir/gold_land/gold_land_server.git",
"checkoutPath": "backend-nest", "checkoutPath": "backend-nest",
"localRef": "", "localRef": "",
"pinnedRef": "dfb20ca92cb13f1d25c6aca64b6256c09176e603", "pinnedRef": "866be223956ef5d3961b0d835b36adedfeb35c82",
"notes": "CI uses E2E_BACKEND_REF when set, otherwise pinnedRef (full SHA only). Never falls back to main. Local sibling: E2E_BACKEND_ROOT=../backend-nest and skip ci:checkout-backend." "notes": "CI uses E2E_BACKEND_REF when set, otherwise pinnedRef (full SHA only). Never falls back to main. Local sibling: E2E_BACKEND_ROOT=../backend-nest and skip ci:checkout-backend."
} }
+120
View File
@@ -0,0 +1,120 @@
import { test, expect } from '@playwright/test'
import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth'
const perfectLessons: Record<number, Array<Record<string, unknown>>> = {
1: [
{ questionId: 'money-q1', answerId: 'option-a' },
{ questionId: 'money-q2', answerId: 'true' },
{ questionId: 'money-q3', answerId: 'option-a' },
{ questionId: 'money-q4', answerId: 'true' },
{ questionId: 'money-q5', answerId: 'option-b' },
],
2: [
{ questionId: 'saving-q1', answerId: 'option-b' },
{ questionId: 'saving-q2', answerId: 'true' },
{ questionId: 'saving-q3', answerId: 'option-a' },
{ questionId: 'saving-q4', answerId: 'true' },
{ questionId: 'saving-q5', answerId: 'option-a' },
],
3: [
{ questionId: 'needs-q1', answerId: 'option-a' },
{ questionId: 'needs-q2', answerId: 'true' },
{ questionId: 'needs-q3', orderedItemIds: ['water', 'food', 'medicine', 'shoes', 'toy', 'crown'] },
{ questionId: 'needs-q4', answerId: 'option-a' },
{ questionId: 'needs-q5', answerId: 'true' },
],
4: [
{ questionId: 'gold-q1', answerId: 'option-a' },
{ questionId: 'gold-q2', answerId: 'true' },
{ questionId: 'gold-q3', answerId: 'option-a' },
{ questionId: 'gold-q4', answerId: 'false' },
{ questionId: 'gold-q5', answerId: 'option-b' },
],
5: [
{ questionId: 'market-q1', answerId: 'option-a' },
{ questionId: 'market-q2', answerId: 'false' },
{ questionId: 'market-q3', answerId: 'option-a' },
{ questionId: 'market-q4', answerId: 'true' },
{ questionId: 'market-q5', answerId: 'option-b' },
],
6: [
{ questionId: 'inflation-q1', answerId: 'option-b' },
{ questionId: 'inflation-q2', answerId: 'true' },
{ questionId: 'inflation-q3', answerId: 'option-a' },
{ questionId: 'inflation-q4', answerId: 'false' },
{ questionId: 'inflation-q5', answerId: 'option-b' },
],
}
async function authHeader(page: import('@playwright/test').Page) {
const token = await page.evaluate(() => localStorage.getItem('gold-land-auth-token'))
if (!token) throw new Error('missing token')
return { authorization: `Bearer ${token}` }
}
test('budget mission from the map, then replay without extra reward', async ({ page }) => {
const user = uniqueUsername('budget')
await registerViaUi(page, user, 'pass1234', 'بودجه')
const headers = await authHeader(page)
for (let levelId = 1; levelId <= 6; levelId += 1) {
const start = await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })
expect(start.ok()).toBeTruthy()
const done = await page.request.post(`/api/gold-land/levels/${levelId}/complete`, {
headers,
data: { answers: perfectLessons[levelId] },
})
expect(done.ok()).toBeTruthy()
}
await page.goto('/map')
await page.getByRole('button', { name: 'مرحله 7: بودجه‌بندی' }).click()
await page.getByRole('button', { name: 'شروع بازی' }).click()
await page.getByRole('button', { name: 'ساخت بودجه' }).click()
await page.getByRole('button', { name: 'بررسی برنامه' }).click()
await expect(page.getByRole('button', { name: 'تأیید برنامه' })).toBeDisabled()
await expect(page.getByText('رفت‌وآمد حداقل ۲۰ سکه')).toBeVisible()
await page.getByRole('button', { name: 'اصلاح برنامه' }).click()
for (let i = 0; i < 4; i += 1) await page.getByRole('button', { name: 'افزایش رفت‌وآمد' }).click()
for (let i = 0; i < 5; i += 1) await page.getByRole('button', { name: 'افزایش آب و غذا' }).click()
for (let i = 0; i < 4; i += 1) await page.getByRole('button', { name: 'افزایش پس‌انداز برای هدف' }).click()
await page.getByRole('button', { name: 'بررسی برنامه' }).click()
await page.getByRole('button', { name: 'تأیید برنامه' }).click()
await expect(page.getByText('هزینه رفت‌وآمد ۱۰ سکه بیشتر شد')).toBeVisible()
await page.getByRole('button', { name: 'بازتنظیم بودجه' }).click()
await expect(page.getByRole('button', { name: 'ثبت نتیجه' })).toBeDisabled()
await expect(page.getByText('حداقل ۳۰ سکه')).toBeVisible()
for (let i = 0; i < 2; i += 1) await page.getByRole('button', { name: 'افزایش رفت‌وآمد' }).click()
await expect(page.getByRole('button', { name: 'ثبت نتیجه' })).toBeEnabled()
const before = await fetchMe(page)
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await expect(page.getByText('پاداش:')).toBeVisible()
await page.getByRole('button', { name: 'بازگشت به نقشه' }).click()
await page.waitForURL(/\/map/)
await page.reload()
const after = await fetchMe(page)
expect(after.progress.mapLevels.find((level) => level.id === 7)?.stars).toBe(3)
expect(after.user.coins).toBe(before.user.coins + 18 + 9)
await page.getByRole('button', { name: 'مرحله 7: بودجه‌بندی' }).click()
await page.getByRole('button', { name: 'مرور مجدد' }).click()
await page.getByRole('button', { name: 'ساخت بودجه' }).click()
for (let i = 0; i < 4; i += 1) await page.getByRole('button', { name: 'افزایش رفت‌وآمد' }).click()
for (let i = 0; i < 5; i += 1) await page.getByRole('button', { name: 'افزایش آب و غذا' }).click()
for (let i = 0; i < 4; i += 1) await page.getByRole('button', { name: 'افزایش پس‌انداز برای هدف' }).click()
await page.getByRole('button', { name: 'بررسی برنامه' }).click()
await page.getByRole('button', { name: 'تأیید برنامه' }).click()
await page.getByRole('button', { name: 'بازتنظیم بودجه' }).click()
for (let i = 0; i < 2; i += 1) await page.getByRole('button', { name: 'افزایش رفت‌وآمد' }).click()
const replayBefore = await fetchMe(page)
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await expect(page.getByText('پاداش تازه‌ای اضافه نشد')).toBeVisible()
const replayAfter = await fetchMe(page)
expect(replayAfter.user.coins).toBe(replayBefore.user.coins)
expect(replayAfter.user.xp).toBe(replayBefore.user.xp)
await page.goto('/map')
await expect(page.getByRole('button', { name: 'مرحله 9: سبد سرمایه‌گذاری' })).toBeDisabled()
})
+112
View File
@@ -0,0 +1,112 @@
import { test, expect, type Page } from '@playwright/test'
import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth'
const correct = [
'مبادله را آسان می‌کند و مقایسه قیمت را ممکن می‌سازد',
'مبلغی متناسب با شرایط را منظم کنار بگذاریم',
'اول آب آشامیدنی را تأمین کنیم',
'طلا یک دارایی است و قیمتش می‌تواند بالا یا پایین برود',
'خیر',
'با همان پول، مقدار کمتری از آن کالاها می‌توان خرید',
'رفت‌وآمد ۳۰، غذا ۲۵، پس‌انداز ۲۰، تفریح ۱۵، ذخیره ۱۰',
'پول لازم را وارد پیشنهاد نامطمئن با احتمال زیان نکنیم',
'خیر؛ چند گزینه ممکن است هم‌زمان افت کنند',
'۱۲۱',
'ب، چون ۴۵ در برابر ۵۰ است',
'۶ هفته',
]
const wrongTail = [
'همان پول ضروری را برای سود بیشتر وارد پیشنهاد نامطمئن کنیم',
'بله، با چند گزینه دیگر زیان ممکن نیست',
'۱۲۰',
'الف، چون عدد روی کارت کوچک‌تر است',
'۸ هفته',
]
async function finishThroughGoal(page: Page, headers: { authorization: string }) {
const lessons: Record<number, unknown> = {
1: [{ questionId: 'money-q1', answerId: 'option-a' }, { questionId: 'money-q2', answerId: 'true' }, { questionId: 'money-q3', answerId: 'option-a' }, { questionId: 'money-q4', answerId: 'true' }, { questionId: 'money-q5', answerId: 'option-b' }],
2: [{ questionId: 'saving-q1', answerId: 'option-b' }, { questionId: 'saving-q2', answerId: 'true' }, { questionId: 'saving-q3', answerId: 'option-a' }, { questionId: 'saving-q4', answerId: 'true' }, { questionId: 'saving-q5', answerId: 'option-a' }],
3: [{ questionId: 'needs-q1', answerId: 'option-a' }, { questionId: 'needs-q2', answerId: 'true' }, { questionId: 'needs-q3', orderedItemIds: ['water', 'food', 'medicine', 'shoes', 'toy', 'crown'] }, { questionId: 'needs-q4', answerId: 'option-a' }, { questionId: 'needs-q5', answerId: 'true' }],
4: [{ questionId: 'gold-q1', answerId: 'option-a' }, { questionId: 'gold-q2', answerId: 'true' }, { questionId: 'gold-q3', answerId: 'option-a' }, { questionId: 'gold-q4', answerId: 'false' }, { questionId: 'gold-q5', answerId: 'option-b' }],
5: [{ questionId: 'market-q1', answerId: 'option-a' }, { questionId: 'market-q2', answerId: 'false' }, { questionId: 'market-q3', answerId: 'option-a' }, { questionId: 'market-q4', answerId: 'true' }, { questionId: 'market-q5', answerId: 'option-b' }],
6: [{ questionId: 'inflation-q1', answerId: 'option-b' }, { questionId: 'inflation-q2', answerId: 'true' }, { questionId: 'inflation-q3', answerId: 'option-a' }, { questionId: 'inflation-q4', answerId: 'false' }, { questionId: 'inflation-q5', answerId: 'option-b' }],
}
for (let levelId = 1; levelId <= 6; levelId += 1) {
expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy()
expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, { headers, data: { answers: lessons[levelId] } })).ok()).toBeTruthy()
}
const later = [
{ answers: [{ questionId: 'initial', allocations: { transport: 20, food: 25, savings: 20, fun: 0 } }, { questionId: 'final', allocations: { transport: 30, food: 25, savings: 20, fun: 0 } }] },
{ answers: [{ 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' }] },
{ answers: [{ questionId: 'portfolio', holdings: { sun: 40, rain: 30, wind: 30 } }, { 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' }] },
{ answers: [{ 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' }] },
{ answers: [{ 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' }] },
{ answers: [{ questionId: 'plan', plan: { initialWeekly: 15, exceptionWeekly: 5, continueWeekly: 15 } }, { 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' }] },
]
for (let index = 0; index < later.length; index += 1) {
const levelId = index + 7
expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy()
expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, { headers, data: later[index] })).ok()).toBeTruthy()
}
}
async function answerExam(page: Page, labels: string[]) {
for (let index = 0; index < labels.length; index += 1) {
await page.getByText(labels[index]!, { exact: true }).click()
const name = index === labels.length - 1 ? 'مرور پاسخ‌ها' : 'بعدی'
await page.getByRole('button', { name }).click()
}
}
test('castle exam from the map', async ({ page }) => {
const user = uniqueUsername('castle')
await registerViaUi(page, user, 'pass1234', 'قلعه')
const token = await page.evaluate(() => localStorage.getItem('gold-land-auth-token'))
await finishThroughGoal(page, { authorization: `Bearer ${token}` })
await page.goto('/map')
await page.getByRole('button', { name: 'مرحله 13: آزمون قلعه' }).click()
await page.getByRole('button', { name: 'شروع آزمون' }).click()
await page.getByRole('button', { name: 'شروع سؤال‌ها' }).click()
await expect(page.getByText('پایه‌های پول')).toBeVisible()
const beforeFail = await fetchMe(page)
await answerExam(page, [...correct.slice(0, 7), ...wrongTail])
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await expect(page.getByText('این تلاش کامل نشد')).toBeVisible()
await expect(page.getByRole('button', { name: 'مرحله بعد' })).toHaveCount(0)
const afterFail = await fetchMe(page)
expect(afterFail.user.coins).toBe(beforeFail.user.coins)
await page.getByRole('button', { name: 'تلاش مجدد' }).click()
await answerExam(page, correct)
const before = await fetchMe(page)
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await expect(page.getByText('آفرین! مسیر آموزشی قلعه را کامل کردی')).toBeVisible()
await expect(page.getByText('پاداش:')).toBeVisible()
await expect(page.getByRole('button', { name: 'مرحله بعد' })).toHaveCount(0)
await page.getByRole('button', { name: 'بازگشت به نقشه' }).click()
await page.reload()
const after = await fetchMe(page)
expect(after.progress.mapLevels.find((level) => level.id === 13)?.stars).toBe(3)
expect(after.user.coins).toBe(before.user.coins + 50 + 9)
await page.getByRole('button', { name: 'مرحله 13: آزمون قلعه' }).click()
await page.getByRole('button', { name: 'مرور مجدد' }).click()
await page.getByRole('button', { name: 'شروع سؤال‌ها' }).click()
await answerExam(page, correct)
const replayBefore = await fetchMe(page)
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await expect(page.getByText('پاداش تازه‌ای اضافه نشد')).toBeVisible()
const replayAfter = await fetchMe(page)
expect(replayAfter.user.coins).toBe(replayBefore.user.coins)
await page.getByRole('button', { name: 'تلاش مجدد' }).click()
await answerExam(page, [...correct.slice(0, 7), ...wrongTail])
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await expect(page.getByText('این تلاش کامل نشد')).toBeVisible()
await expect(page.getByText('ستاره ذخیره‌شده همچنان 3 از ۳ است')).toBeVisible()
await expect(page.getByText('آفرین! مسیر آموزشی قلعه را کامل کردی')).toHaveCount(0)
const kept = await fetchMe(page)
expect(kept.progress.mapLevels.find((level) => level.id === 13)?.stars).toBe(3)
expect(kept.user.coins).toBe(replayAfter.user.coins)
})
+149
View File
@@ -0,0 +1,149 @@
import { test, expect } from '@playwright/test'
import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth'
const lessons: Record<number, Array<Record<string, unknown>>> = {
1: [
{ questionId: 'money-q1', answerId: 'option-a' },
{ questionId: 'money-q2', answerId: 'true' },
{ questionId: 'money-q3', answerId: 'option-a' },
{ questionId: 'money-q4', answerId: 'true' },
{ questionId: 'money-q5', answerId: 'option-b' },
],
2: [
{ questionId: 'saving-q1', answerId: 'option-b' },
{ questionId: 'saving-q2', answerId: 'true' },
{ questionId: 'saving-q3', answerId: 'option-a' },
{ questionId: 'saving-q4', answerId: 'true' },
{ questionId: 'saving-q5', answerId: 'option-a' },
],
3: [
{ questionId: 'needs-q1', answerId: 'option-a' },
{ questionId: 'needs-q2', answerId: 'true' },
{ questionId: 'needs-q3', orderedItemIds: ['water', 'food', 'medicine', 'shoes', 'toy', 'crown'] },
{ questionId: 'needs-q4', answerId: 'option-a' },
{ questionId: 'needs-q5', answerId: 'true' },
],
4: [
{ questionId: 'gold-q1', answerId: 'option-a' },
{ questionId: 'gold-q2', answerId: 'true' },
{ questionId: 'gold-q3', answerId: 'option-a' },
{ questionId: 'gold-q4', answerId: 'false' },
{ questionId: 'gold-q5', answerId: 'option-b' },
],
5: [
{ questionId: 'market-q1', answerId: 'option-a' },
{ questionId: 'market-q2', answerId: 'false' },
{ questionId: 'market-q3', answerId: 'option-a' },
{ questionId: 'market-q4', answerId: 'true' },
{ questionId: 'market-q5', answerId: 'option-b' },
],
6: [
{ questionId: 'inflation-q1', answerId: 'option-b' },
{ questionId: 'inflation-q2', answerId: 'true' },
{ questionId: 'inflation-q3', answerId: 'option-a' },
{ questionId: 'inflation-q4', answerId: 'false' },
{ questionId: 'inflation-q5', answerId: 'option-b' },
],
}
async function answerCompound(page: import('@playwright/test').Page) {
const choices = [
'رشد دوره بعد بر موجودی جدید، شامل رشد قبلی، محاسبه می‌شود',
'۱۲۱ سکه',
'چون ۱۰٪ از ۱۱۰ محاسبه شده است',
'مرکب؛ ۱۳۳٫۱ در برابر ۱۳۰',
]
for (const label of choices) {
await page.getByRole('button', { name: label, exact: true }).click()
await page.getByRole('button', { name: 'بعدی' }).click()
}
await page.getByRole('button', { name: 'نادرست', exact: true }).click()
await page.getByRole('button', { name: 'بعدی' }).click()
}
test('compound interest lesson from the map', async ({ page }) => {
const user = uniqueUsername('cmp')
await registerViaUi(page, user, 'pass1234', 'مرکب')
const token = await page.evaluate(() => localStorage.getItem('gold-land-auth-token'))
const headers = { authorization: `Bearer ${token}` }
for (let levelId = 1; levelId <= 6; levelId += 1) {
expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy()
expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, {
headers,
data: { answers: lessons[levelId] },
})).ok()).toBeTruthy()
}
expect((await page.request.post('/api/gold-land/levels/7/start', { headers })).ok()).toBeTruthy()
expect((await page.request.post('/api/gold-land/levels/7/complete', {
headers,
data: {
answers: [
{ questionId: 'initial', allocations: { transport: 20, food: 25, savings: 20, fun: 0 } },
{ questionId: 'final', allocations: { transport: 30, food: 25, savings: 20, fun: 0 } },
],
},
})).ok()).toBeTruthy()
expect((await page.request.post('/api/gold-land/levels/8/start', { headers })).ok()).toBeTruthy()
expect((await page.request.post('/api/gold-land/levels/8/complete', {
headers,
data: {
answers: [
{ 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' },
],
},
})).ok()).toBeTruthy()
expect((await page.request.post('/api/gold-land/levels/9/start', { headers })).ok()).toBeTruthy()
expect((await page.request.post('/api/gold-land/levels/9/complete', {
headers,
data: {
answers: [
{ questionId: 'portfolio', holdings: { sun: 40, rain: 30, wind: 30 } },
{ 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' },
],
},
})).ok()).toBeTruthy()
await page.goto('/map')
await page.getByRole('button', { name: 'مرحله 10: سود مرکب' }).click()
await page.getByRole('button', { name: 'شروع درس' }).click()
await page.getByRole('button', { name: 'بعدی' }).click()
await page.getByRole('button', { name: 'دوره بعد' }).click()
await page.getByRole('button', { name: 'دوره بعد' }).click()
await page.getByRole('button', { name: 'دوره بعد' }).click()
await expect(page.getByText('۱۳۳٫۱ سکه').first()).toBeVisible()
await expect(page.getByText('۱۲۱ + ۱۲٫۱ = ۱۳۳٫۱')).toBeVisible()
await page.getByRole('button', { name: 'دوره قبل' }).click()
await page.getByRole('button', { name: 'بعدی' }).click()
await answerCompound(page)
const before = await fetchMe(page)
await page.getByRole('button', { name: 'اتمام درس' }).click()
await expect(page.getByText('۱۰٪ آن ۱۱ سکه')).toBeVisible()
await page.getByRole('button', { name: 'بازگشت به نقشه' }).click()
await page.reload()
const after = await fetchMe(page)
expect(after.progress.mapLevels.find((level) => level.id === 10)?.stars).toBe(3)
expect(after.user.coins).toBe(before.user.coins + 18 + 9)
await page.getByRole('button', { name: 'مرحله 10: سود مرکب' }).click()
await page.getByRole('button', { name: 'مرور مجدد' }).click()
await page.getByRole('button', { name: 'بعدی' }).click()
await page.getByRole('button', { name: 'بعدی' }).click()
await answerCompound(page)
const replayBefore = await fetchMe(page)
await page.getByRole('button', { name: 'اتمام درس' }).click()
await expect(page.getByText('امتیاز')).toBeVisible()
const replayAfter = await fetchMe(page)
expect(replayAfter.user.coins).toBe(replayBefore.user.coins)
await page.goto('/map')
await expect(page.getByRole('button', { name: 'مرحله 12: هدف مالی' })).toBeDisabled()
})
+89
View File
@@ -0,0 +1,89 @@
import { test, expect, type Page } from '@playwright/test'
import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth'
async function finishThroughShop(page: Page, headers: { authorization: string }) {
const lessons: Record<number, unknown> = {
1: [{ questionId: 'money-q1', answerId: 'option-a' }, { questionId: 'money-q2', answerId: 'true' }, { questionId: 'money-q3', answerId: 'option-a' }, { questionId: 'money-q4', answerId: 'true' }, { questionId: 'money-q5', answerId: 'option-b' }],
2: [{ questionId: 'saving-q1', answerId: 'option-b' }, { questionId: 'saving-q2', answerId: 'true' }, { questionId: 'saving-q3', answerId: 'option-a' }, { questionId: 'saving-q4', answerId: 'true' }, { questionId: 'saving-q5', answerId: 'option-a' }],
3: [{ questionId: 'needs-q1', answerId: 'option-a' }, { questionId: 'needs-q2', answerId: 'true' }, { questionId: 'needs-q3', orderedItemIds: ['water', 'food', 'medicine', 'shoes', 'toy', 'crown'] }, { questionId: 'needs-q4', answerId: 'option-a' }, { questionId: 'needs-q5', answerId: 'true' }],
4: [{ questionId: 'gold-q1', answerId: 'option-a' }, { questionId: 'gold-q2', answerId: 'true' }, { questionId: 'gold-q3', answerId: 'option-a' }, { questionId: 'gold-q4', answerId: 'false' }, { questionId: 'gold-q5', answerId: 'option-b' }],
5: [{ questionId: 'market-q1', answerId: 'option-a' }, { questionId: 'market-q2', answerId: 'false' }, { questionId: 'market-q3', answerId: 'option-a' }, { questionId: 'market-q4', answerId: 'true' }, { questionId: 'market-q5', answerId: 'option-b' }],
6: [{ questionId: 'inflation-q1', answerId: 'option-b' }, { questionId: 'inflation-q2', answerId: 'true' }, { questionId: 'inflation-q3', answerId: 'option-a' }, { questionId: 'inflation-q4', answerId: 'false' }, { questionId: 'inflation-q5', answerId: 'option-b' }],
}
for (let levelId = 1; levelId <= 6; levelId += 1) {
expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy()
expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, { headers, data: { answers: lessons[levelId] } })).ok()).toBeTruthy()
}
const later = [
{ answers: [{ questionId: 'initial', allocations: { transport: 20, food: 25, savings: 20, fun: 0 } }, { questionId: 'final', allocations: { transport: 30, food: 25, savings: 20, fun: 0 } }] },
{ answers: [{ 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' }] },
{ answers: [{ questionId: 'portfolio', holdings: { sun: 40, rain: 30, wind: 30 } }, { 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' }] },
{ answers: [{ 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' }] },
{ answers: [{ 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' }] },
]
for (let index = 0; index < later.length; index += 1) {
const levelId = index + 7
expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy()
expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, { headers, data: later[index] })).ok()).toBeTruthy()
}
}
async function answerGoal(page: Page) {
const labels = [
'خرید بسته مشخص با قیمت ۱۲۰ و برنامه پس‌انداز',
'۹۰ سکه',
'۶ هفته',
'نیاز را تأمین کن و مبلغ پس‌انداز یا زمان هدف را اصلاح کن',
'برنامه و زمان را دوباره بررسی و به‌روز کن',
]
for (let index = 0; index < labels.length; index += 1) {
await page.getByText(labels[index]!, { exact: true }).click()
if (index < labels.length - 1) await page.getByRole('button', { name: 'بعدی' }).click()
else await page.getByRole('button', { name: 'مرور پاسخ‌ها' }).click()
}
}
async function buildPlan(page: Page) {
await page.getByRole('button', { name: 'ساخت برنامه' }).click()
await page.getByRole('button', { name: '15 سکه در هفته' }).click()
await expect(page.getByText('برآورد زمان: 6 هفته')).toBeVisible()
await page.getByRole('button', { name: 'تأیید برنامه اولیه' }).click()
await page.getByRole('button', { name: 'هفته دوم: 5 سکه' }).click()
await page.getByRole('button', { name: 'ادامه: 15 سکه' }).click()
await expect(page.getByText('کل زمان: 7 هفته')).toBeVisible()
await expect(page.getByText('موجودی نهایی برآوردی: 125 سکه')).toBeVisible()
await page.getByRole('button', { name: 'رفتن به سؤال‌ها' }).click()
await answerGoal(page)
}
test('financial goal from the map', async ({ page }) => {
const user = uniqueUsername('goal')
await registerViaUi(page, user, 'pass1234', 'هدف')
const token = await page.evaluate(() => localStorage.getItem('gold-land-auth-token'))
await finishThroughShop(page, { authorization: `Bearer ${token}` })
await page.goto('/map')
await page.getByRole('button', { name: 'مرحله 12: هدف مالی' }).click()
await page.getByRole('button', { name: 'شروع چالش' }).click()
await buildPlan(page)
const before = await fetchMe(page)
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await expect(page.getByText('پاداش:')).toBeVisible()
await page.getByRole('button', { name: 'بازگشت به نقشه' }).click()
await page.reload()
const after = await fetchMe(page)
expect(after.progress.mapLevels.find((level) => level.id === 12)?.stars).toBe(3)
expect(after.user.coins).toBe(before.user.coins + 20 + 9)
await page.getByRole('button', { name: 'مرحله 12: هدف مالی' }).click()
await page.getByRole('button', { name: 'مرور مجدد' }).click()
await buildPlan(page)
const replayBefore = await fetchMe(page)
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await expect(page.getByText('پاداش تازه‌ای اضافه نشد')).toBeVisible()
const replayAfter = await fetchMe(page)
expect(replayAfter.user.coins).toBe(replayBefore.user.coins)
await page.goto('/map')
await page.getByRole('button', { name: 'مرحله 13: آزمون قلعه' }).click()
await expect(page.getByRole('button', { name: 'شروع آزمون' })).toBeEnabled()
})
+242
View File
@@ -0,0 +1,242 @@
import { test, expect, type Page } from '@playwright/test'
import { fetchMe, loginViaUi, logoutViaProfile, registerViaUi, uniqueUsername } from './helpers/auth'
test.describe.configure({ mode: 'serial' })
async function openLevel(page: Page, name: string, start: string) {
await page.goto('/map')
await page.getByRole('button', { name }).click()
await page.getByRole('button', { name: start }).click()
await page.getByText('در حال بارگذاری').waitFor({ state: 'detached', timeout: 30_000 }).catch(() => undefined)
}
async function clickAnswer(page: Page, label: string) {
if (label === 'درست' || label === 'نادرست') {
await page.getByRole('button', { name: label, exact: true }).click()
return
}
const button = page.getByRole('button', { name: label, exact: true })
if (await button.count()) {
await button.first().click()
return
}
await page.getByText(label, { exact: true }).click()
}
async function finishLesson(page: Page, labels: string[]) {
let cursor = 0
for (let step = 0; step < 30; step += 1) {
if (await page.getByText('امتیاز').first().isVisible().catch(() => false)) return
if (await page.getByText('خوراکی ضروری').isVisible().catch(() => false)) {
for (let move = 0; move < 4; move += 1) {
await page.locator('.sort-item').filter({ hasText: 'خوراکی ضروری' }).getByRole('button', { name: '↑' }).click()
}
}
if (cursor < labels.length) {
const label = labels[cursor]!
const visible = await page.getByText(label, { exact: true }).isVisible().catch(() => false)
if (visible) {
await clickAnswer(page, label)
cursor += 1
}
}
const finish = page.getByRole('button', { name: 'اتمام درس' })
if (await finish.isVisible().catch(() => false) && await finish.isEnabled().catch(() => false)) {
await finish.click()
await page.getByText('امتیاز').first().waitFor()
return
}
const next = page.getByRole('button', { name: 'بعدی' })
if (await next.isEnabled().catch(() => false)) await next.click()
}
throw new Error('lesson did not finish')
}
async function starsOf(page: Page, levelId: number) {
const me = await fetchMe(page)
return me.progress.mapLevels.find((level) => level.id === levelId)?.stars ?? 0
}
test('full thirteen-level journey from the interface', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop', 'full journey runs once on desktop')
test.setTimeout(600_000)
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text())
})
const user = uniqueUsername('path')
await registerViaUi(page, user, 'pass1234', 'مسیر')
const lessons: Array<{ name: string; labels: string[] }> = [
{ name: 'مرحله 1: پول چیه؟', labels: ['مبادله آسان‌تر کالاها', 'درست', 'چون همه همیشه همان چیز را می‌خواهند', 'درست', 'پول وسیلهٔ مبادله و نگه‌داری ارزش است'] },
{ name: 'مرحله 2: پس‌انداز', labels: ['بخشی از پول را برای بعد کنار گذاشتن', 'درست', 'برای هدفش برنامه نداشت و همه را خرج کرد', 'درست', 'هدف آینده چیزی است که برایش برنامه می‌ریزیم'] },
{ name: 'مرحله 3: نیاز و خواسته', labels: ['چیزی که برای زندگی سالم لازم است', 'درست', 'اول نیازها را برطرف کنیم', 'درست'] },
{ name: 'مرحله 4: طلا چیه؟', labels: ['چون واحد استاندارد وزن طلا است', 'درست', 'زیورآلات و صنعت', 'نادرست', 'طلا دارایی با ارزش است که قیمتش می‌تواند تغییر کند'] },
{ name: 'مرحله 5: بازار طلا', labels: ['قیمت بالا و پایین می‌شود', 'نادرست', 'چون ممکن است وقت کافی برای فکر کردن نداشته باشیم', 'درست', 'فکر کردن و پراکنده نکردن تمام پول در یک دارایی'] },
{ name: 'مرحله 6: تورم', labels: ['افزایش عمومی قیمت‌ها در طول زمان', 'درست', 'قدرت خرید پول کم شده', 'نادرست', 'ممکن است ارزش واقعی‌اش کم شود'] },
]
for (let index = 0; index < lessons.length; index += 1) {
const lesson = lessons[index]!
await openLevel(page, lesson.name, 'شروع درس')
await finishLesson(page, lesson.labels)
await page.getByRole('button', { name: 'بازگشت به نقشه' }).click()
expect(await starsOf(page, index + 1)).toBeGreaterThan(0)
if (index < 5) {
await expect(page.getByRole('button', { name: lessons[index + 1]!.name })).toBeEnabled()
}
}
await page.reload()
expect(await starsOf(page, 6)).toBeGreaterThan(0)
await openLevel(page, 'مرحله 7: بودجه‌بندی', 'شروع بازی')
await page.getByRole('button', { name: 'ساخت بودجه' }).click()
for (let index = 0; index < 4; index += 1) await page.getByRole('button', { name: 'افزایش رفت‌وآمد' }).click()
for (let index = 0; index < 5; index += 1) await page.getByRole('button', { name: 'افزایش آب و غذا' }).click()
for (let index = 0; index < 4; index += 1) await page.getByRole('button', { name: 'افزایش پس‌انداز برای هدف' }).click()
await page.getByRole('button', { name: 'بررسی برنامه' }).click()
await page.getByRole('button', { name: 'تأیید برنامه' }).click()
await page.getByRole('button', { name: 'بازتنظیم بودجه' }).click()
for (let index = 0; index < 2; index += 1) await page.getByRole('button', { name: 'افزایش رفت‌وآمد' }).click()
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await page.getByRole('button', { name: 'بازگشت به نقشه' }).click()
expect(await starsOf(page, 7)).toBeGreaterThan(0)
await openLevel(page, 'مرحله 8: ریسک و بازده', 'شروع چالش')
await page.getByRole('button', { name: 'دیدن دو پیشنهاد' }).click()
await page.getByRole('button', { name: 'رفتن به فعالیت‌ها' }).click()
const risk = [
'پیشنهاد ب',
'خیر، ۳۶ فقط یکی از نتیجه‌های ممکن است',
'این پول ضروری را وارد پیشنهادهای نامطمئن نکن',
'پیشنهاد ب امکان نتیجه بالاتر و زیان بیشتری دارد؛ سود بیشتر قطعی نیست',
'وعده را تضمین معتبر فرض نکن، بررسی کن و از بزرگسال قابل اعتماد کمک بگیر',
]
for (let index = 0; index < risk.length; index += 1) {
await page.getByText(risk[index]!, { exact: true }).click()
await page.getByRole('button', { name: index === risk.length - 1 ? 'مرور پاسخ‌ها' : 'بعدی' }).click()
}
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await page.getByRole('button', { name: 'بازگشت به نقشه' }).click()
expect(await starsOf(page, 8)).toBeGreaterThan(0)
await openLevel(page, 'مرحله 9: سبد سرمایه‌گذاری', 'شروع بازی')
await page.getByRole('button', { name: 'ساخت سبد' }).click()
for (let index = 0; index < 4; index += 1) await page.getByRole('button', { name: 'افزایش آفتاب' }).click()
for (let index = 0; index < 3; index += 1) await page.getByRole('button', { name: 'افزایش باران' }).click()
for (let index = 0; index < 3; index += 1) await page.getByRole('button', { name: 'افزایش باد' }).click()
await page.getByRole('button', { name: 'دیدن سه سناریو' }).click()
await page.getByRole('button', { name: 'رفتن به سؤال‌ها' }).click()
const portfolio = [
'نتیجه بیشتر به همان گزینه وابسته می‌شود',
'خیر؛ افت همگانی می‌تواند چند گزینه را هم‌زمان کم کند',
'خیر؛ رفتار و وابستگی آن‌ها مهم است',
'خیر؛ یک سناریو آینده را تضمین نمی‌کند',
'توجه به هدف، زمان نیاز به پول و ریسک؛ در مسائل واقعی کمک از بزرگسال قابل اعتماد',
]
for (let index = 0; index < portfolio.length; index += 1) {
await page.getByText(portfolio[index]!, { exact: true }).click()
await page.getByRole('button', { name: index === portfolio.length - 1 ? 'مرور پاسخ‌ها' : 'بعدی' }).click()
}
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await page.getByRole('button', { name: 'بازگشت به نقشه' }).click()
expect(await starsOf(page, 9)).toBeGreaterThan(0)
await openLevel(page, 'مرحله 10: سود مرکب', 'شروع درس')
await page.getByRole('button', { name: 'بعدی' }).click()
await page.getByRole('button', { name: 'دوره بعد' }).click()
await page.getByRole('button', { name: 'دوره بعد' }).click()
await page.getByRole('button', { name: 'دوره بعد' }).click()
await page.getByRole('button', { name: 'بعدی' }).click()
const compound = [
'رشد دوره بعد بر موجودی جدید، شامل رشد قبلی، محاسبه می‌شود',
'۱۲۱ سکه',
'چون ۱۰٪ از ۱۱۰ محاسبه شده است',
'مرکب؛ ۱۳۳٫۱ در برابر ۱۳۰',
]
for (const label of compound) {
await page.getByRole('button', { name: label, exact: true }).click()
await page.getByRole('button', { name: 'بعدی' }).click()
}
await page.getByRole('button', { name: 'نادرست', exact: true }).click()
await page.getByRole('button', { name: 'بعدی' }).click()
await page.getByRole('button', { name: 'اتمام درس' }).click()
await page.getByRole('button', { name: 'بازگشت به نقشه' }).click()
expect(await starsOf(page, 10)).toBeGreaterThan(0)
await openLevel(page, 'مرحله 11: خرید هوشمند', 'شروع بازی')
await page.getByRole('button', { name: 'شروع خریدها' }).click()
const shop = [
['دفتر ۶۰برگ، ۴۰ سکه، تحویل ۵ سکه', 'نیاز را برآورده می‌کند و هزینه نهایی‌اش بین گزینه‌های مناسب کمتر است'],
['مداد با قیمت نهایی ۲۸، بدون تخفیف', 'برای کالای یکسان، مبلغ نهایی پرداختی مهم است؛ درصد تخفیف به‌تنهایی کافی نیست'],
['فعلاً نخر و اندازه و هزینه نهایی را بپرس، با کمک بزرگسال قابل اعتماد', 'اطلاعات لازم برای تصمیم و مقایسه هنوز کامل نیست'],
]
for (let index = 0; index < shop.length; index += 1) {
await page.getByText(shop[index]![0]!, { exact: true }).click()
await page.getByText(shop[index]![1]!, { exact: true }).click()
await page.getByRole('button', { name: index === shop.length - 1 ? 'مرور انتخاب‌ها' : 'مأموریت بعد' }).click()
}
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await page.getByRole('button', { name: 'بازگشت به نقشه' }).click()
expect(await starsOf(page, 11)).toBeGreaterThan(0)
await openLevel(page, 'مرحله 12: هدف مالی', 'شروع چالش')
await page.getByRole('button', { name: 'ساخت برنامه' }).click()
await page.getByRole('button', { name: '15 سکه در هفته' }).click()
await page.getByRole('button', { name: 'تأیید برنامه اولیه' }).click()
await page.getByRole('button', { name: 'هفته دوم: 5 سکه' }).click()
await page.getByRole('button', { name: 'ادامه: 15 سکه' }).click()
await page.getByRole('button', { name: 'رفتن به سؤال‌ها' }).click()
const goal = [
'خرید بسته مشخص با قیمت ۱۲۰ و برنامه پس‌انداز',
'۹۰ سکه',
'۶ هفته',
'نیاز را تأمین کن و مبلغ پس‌انداز یا زمان هدف را اصلاح کن',
'برنامه و زمان را دوباره بررسی و به‌روز کن',
]
for (let index = 0; index < goal.length; index += 1) {
await page.getByText(goal[index]!, { exact: true }).click()
await page.getByRole('button', { name: index === goal.length - 1 ? 'مرور پاسخ‌ها' : 'بعدی' }).click()
}
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await page.getByRole('button', { name: 'بازگشت به نقشه' }).click()
expect(await starsOf(page, 12)).toBeGreaterThan(0)
await page.reload()
expect(await starsOf(page, 12)).toBeGreaterThan(0)
await expect(page.getByRole('button', { name: 'مرحله 14', exact: false })).toHaveCount(0)
await openLevel(page, 'مرحله 13: آزمون قلعه', 'شروع آزمون')
await page.getByRole('button', { name: 'شروع سؤال‌ها' }).click()
const castle = [
'مبادله را آسان می‌کند و مقایسه قیمت را ممکن می‌سازد',
'مبلغی متناسب با شرایط را منظم کنار بگذاریم',
'اول آب آشامیدنی را تأمین کنیم',
'طلا یک دارایی است و قیمتش می‌تواند بالا یا پایین برود',
'خیر',
'با همان پول، مقدار کمتری از آن کالاها می‌توان خرید',
'رفت‌وآمد ۳۰، غذا ۲۵، پس‌انداز ۲۰، تفریح ۱۵، ذخیره ۱۰',
'پول لازم را وارد پیشنهاد نامطمئن با احتمال زیان نکنیم',
'خیر؛ چند گزینه ممکن است هم‌زمان افت کنند',
'۱۲۱',
'ب، چون ۴۵ در برابر ۵۰ است',
'۶ هفته',
]
for (let index = 0; index < castle.length; index += 1) {
await page.getByText(castle[index]!, { exact: true }).click()
await page.getByRole('button', { name: index === castle.length - 1 ? 'مرور پاسخ‌ها' : 'بعدی' }).click()
}
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await expect(page.getByText('آفرین! مسیر آموزشی قلعه را کامل کردی')).toBeVisible()
await expect(page.getByRole('button', { name: 'مرحله بعد' })).toHaveCount(0)
await page.getByRole('button', { name: 'بازگشت به نقشه' }).click()
expect(await starsOf(page, 13)).toBeGreaterThan(0)
await logoutViaProfile(page)
await loginViaUi(page, user, 'pass1234')
await page.goto('/map')
for (let levelId = 1; levelId <= 13; levelId += 1) {
expect(await starsOf(page, levelId)).toBeGreaterThan(0)
}
expect(errors.filter((error) => !/favicon|Download the React DevTools/i.test(error))).toEqual([])
})
+152
View File
@@ -0,0 +1,152 @@
import { test, expect } from '@playwright/test'
import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth'
const lessons: Record<number, Array<Record<string, unknown>>> = {
1: [
{ questionId: 'money-q1', answerId: 'option-a' },
{ questionId: 'money-q2', answerId: 'true' },
{ questionId: 'money-q3', answerId: 'option-a' },
{ questionId: 'money-q4', answerId: 'true' },
{ questionId: 'money-q5', answerId: 'option-b' },
],
2: [
{ questionId: 'saving-q1', answerId: 'option-b' },
{ questionId: 'saving-q2', answerId: 'true' },
{ questionId: 'saving-q3', answerId: 'option-a' },
{ questionId: 'saving-q4', answerId: 'true' },
{ questionId: 'saving-q5', answerId: 'option-a' },
],
3: [
{ questionId: 'needs-q1', answerId: 'option-a' },
{ questionId: 'needs-q2', answerId: 'true' },
{ questionId: 'needs-q3', orderedItemIds: ['water', 'food', 'medicine', 'shoes', 'toy', 'crown'] },
{ questionId: 'needs-q4', answerId: 'option-a' },
{ questionId: 'needs-q5', answerId: 'true' },
],
4: [
{ questionId: 'gold-q1', answerId: 'option-a' },
{ questionId: 'gold-q2', answerId: 'true' },
{ questionId: 'gold-q3', answerId: 'option-a' },
{ questionId: 'gold-q4', answerId: 'false' },
{ questionId: 'gold-q5', answerId: 'option-b' },
],
5: [
{ questionId: 'market-q1', answerId: 'option-a' },
{ questionId: 'market-q2', answerId: 'false' },
{ questionId: 'market-q3', answerId: 'option-a' },
{ questionId: 'market-q4', answerId: 'true' },
{ questionId: 'market-q5', answerId: 'option-b' },
],
6: [
{ questionId: 'inflation-q1', answerId: 'option-b' },
{ questionId: 'inflation-q2', answerId: 'true' },
{ questionId: 'inflation-q3', answerId: 'option-a' },
{ questionId: 'inflation-q4', answerId: 'false' },
{ questionId: 'inflation-q5', answerId: 'option-b' },
],
}
async function answerPortfolio(page: import('@playwright/test').Page) {
const choices = [
'نتیجه بیشتر به همان گزینه وابسته می‌شود',
'خیر؛ افت همگانی می‌تواند چند گزینه را هم‌زمان کم کند',
'خیر؛ رفتار و وابستگی آن‌ها مهم است',
'خیر؛ یک سناریو آینده را تضمین نمی‌کند',
'توجه به هدف، زمان نیاز به پول و ریسک؛ در مسائل واقعی کمک از بزرگسال قابل اعتماد',
]
for (let index = 0; index < choices.length; index += 1) {
await page.getByText(choices[index]!, { exact: true }).click()
if (index < choices.length - 1) await page.getByRole('button', { name: 'بعدی' }).click()
else await page.getByRole('button', { name: 'مرور پاسخ‌ها' }).click()
}
}
test('portfolio game from the map', async ({ page }) => {
const user = uniqueUsername('port')
await registerViaUi(page, user, 'pass1234', 'سبد')
const token = await page.evaluate(() => localStorage.getItem('gold-land-auth-token'))
const headers = { authorization: `Bearer ${token}` }
for (let levelId = 1; levelId <= 6; levelId += 1) {
expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy()
expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, {
headers,
data: { answers: lessons[levelId] },
})).ok()).toBeTruthy()
}
expect((await page.request.post('/api/gold-land/levels/7/start', { headers })).ok()).toBeTruthy()
expect((await page.request.post('/api/gold-land/levels/7/complete', {
headers,
data: {
answers: [
{ questionId: 'initial', allocations: { transport: 20, food: 25, savings: 20, fun: 0 } },
{ questionId: 'final', allocations: { transport: 30, food: 25, savings: 20, fun: 0 } },
],
},
})).ok()).toBeTruthy()
expect((await page.request.post('/api/gold-land/levels/8/start', { headers })).ok()).toBeTruthy()
expect((await page.request.post('/api/gold-land/levels/8/complete', {
headers,
data: {
answers: [
{ 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' },
],
},
})).ok()).toBeTruthy()
await page.goto('/map')
await page.getByRole('button', { name: 'مرحله 9: سبد سرمایه‌گذاری' }).click()
await page.getByRole('button', { name: 'شروع بازی' }).click()
await page.getByRole('button', { name: 'ساخت سبد' }).click()
await expect(page.getByRole('button', { name: 'دیدن سه سناریو' })).toBeDisabled()
const sunUp = page.getByRole('button', { name: 'افزایش آفتاب' })
const rainUp = page.getByRole('button', { name: 'افزایش باران' })
const windUp = page.getByRole('button', { name: 'افزایش باد' })
for (let index = 0; index < 4; index += 1) await sunUp.click()
for (let index = 0; index < 3; index += 1) await rainUp.click()
for (let index = 0; index < 3; index += 1) await windUp.click()
await page.getByRole('button', { name: 'دیدن سه سناریو' }).click()
await expect(page.getByText('سود 5 سکه')).toBeVisible()
await expect(page.getByText('زیان 5 سکه')).toBeVisible()
await expect(page.getByText('زیان 10 سکه').first()).toBeVisible()
await page.getByRole('button', { name: 'تغییر ترکیب' }).click()
await page.getByRole('button', { name: 'کاهش آفتاب' }).click()
await page.getByRole('button', { name: 'افزایش باد' }).click()
await page.getByRole('button', { name: 'دیدن سه سناریو' }).click()
await page.getByRole('button', { name: 'رفتن به سؤال‌ها' }).click()
await answerPortfolio(page)
const before = await fetchMe(page)
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await expect(page.getByText('پاداش:')).toBeVisible()
await expect(page.getByText('زیان را حذف یا سود را تضمین نمی‌کند')).toBeVisible()
await page.getByRole('button', { name: 'بازگشت به نقشه' }).click()
await page.reload()
const after = await fetchMe(page)
expect(after.progress.mapLevels.find((level) => level.id === 9)?.stars).toBe(3)
expect(after.user.coins).toBe(before.user.coins + 20 + 9)
await page.getByRole('button', { name: 'مرحله 9: سبد سرمایه‌گذاری' }).click()
await page.getByRole('button', { name: 'مرور مجدد' }).click()
await page.getByRole('button', { name: 'ساخت سبد' }).click()
for (let index = 0; index < 10; index += 1) await page.getByRole('button', { name: 'افزایش آفتاب' }).click()
await page.getByRole('button', { name: 'دیدن سه سناریو' }).click()
await page.getByRole('button', { name: 'رفتن به سؤال‌ها' }).click()
await answerPortfolio(page)
const replayBefore = await fetchMe(page)
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await expect(page.getByText('پاداش تازه‌ای اضافه نشد')).toBeVisible()
const replayAfter = await fetchMe(page)
expect(replayAfter.user.coins).toBe(replayBefore.user.coins)
await page.goto('/map')
await expect(page.getByRole('button', { name: 'مرحله 11: خرید هوشمند' })).toBeDisabled()
})
+123
View File
@@ -0,0 +1,123 @@
import { test, expect } from '@playwright/test'
import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth'
const lessons: Record<number, Array<Record<string, unknown>>> = {
1: [
{ questionId: 'money-q1', answerId: 'option-a' },
{ questionId: 'money-q2', answerId: 'true' },
{ questionId: 'money-q3', answerId: 'option-a' },
{ questionId: 'money-q4', answerId: 'true' },
{ questionId: 'money-q5', answerId: 'option-b' },
],
2: [
{ questionId: 'saving-q1', answerId: 'option-b' },
{ questionId: 'saving-q2', answerId: 'true' },
{ questionId: 'saving-q3', answerId: 'option-a' },
{ questionId: 'saving-q4', answerId: 'true' },
{ questionId: 'saving-q5', answerId: 'option-a' },
],
3: [
{ questionId: 'needs-q1', answerId: 'option-a' },
{ questionId: 'needs-q2', answerId: 'true' },
{ questionId: 'needs-q3', orderedItemIds: ['water', 'food', 'medicine', 'shoes', 'toy', 'crown'] },
{ questionId: 'needs-q4', answerId: 'option-a' },
{ questionId: 'needs-q5', answerId: 'true' },
],
4: [
{ questionId: 'gold-q1', answerId: 'option-a' },
{ questionId: 'gold-q2', answerId: 'true' },
{ questionId: 'gold-q3', answerId: 'option-a' },
{ questionId: 'gold-q4', answerId: 'false' },
{ questionId: 'gold-q5', answerId: 'option-b' },
],
5: [
{ questionId: 'market-q1', answerId: 'option-a' },
{ questionId: 'market-q2', answerId: 'false' },
{ questionId: 'market-q3', answerId: 'option-a' },
{ questionId: 'market-q4', answerId: 'true' },
{ questionId: 'market-q5', answerId: 'option-b' },
],
6: [
{ questionId: 'inflation-q1', answerId: 'option-b' },
{ questionId: 'inflation-q2', answerId: 'true' },
{ questionId: 'inflation-q3', answerId: 'option-a' },
{ questionId: 'inflation-q4', answerId: 'false' },
{ questionId: 'inflation-q5', answerId: 'option-b' },
],
}
async function answerChallenge(page: import('@playwright/test').Page) {
const choices = [
'پیشنهاد ب',
'خیر، ۳۶ فقط یکی از نتیجه‌های ممکن است',
'این پول ضروری را وارد پیشنهادهای نامطمئن نکن',
'پیشنهاد ب امکان نتیجه بالاتر و زیان بیشتری دارد؛ سود بیشتر قطعی نیست',
'وعده را تضمین معتبر فرض نکن، بررسی کن و از بزرگسال قابل اعتماد کمک بگیر',
]
for (let index = 0; index < choices.length; index += 1) {
await page.getByText(choices[index]!, { exact: true }).click()
if (index < choices.length - 1) {
await page.getByRole('button', { name: 'بعدی' }).click()
} else {
await page.getByRole('button', { name: 'مرور پاسخ‌ها' }).click()
}
}
}
test('risk and return challenge from the map', async ({ page }) => {
const user = uniqueUsername('risk')
await registerViaUi(page, user, 'pass1234', 'ریسک')
const token = await page.evaluate(() => localStorage.getItem('gold-land-auth-token'))
const headers = { authorization: `Bearer ${token}` }
for (let levelId = 1; levelId <= 6; levelId += 1) {
expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy()
expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, {
headers,
data: { answers: lessons[levelId] },
})).ok()).toBeTruthy()
}
const budget = {
answers: [
{ questionId: 'initial', allocations: { transport: 20, food: 25, savings: 20, fun: 0 } },
{ questionId: 'final', allocations: { transport: 30, food: 25, savings: 20, fun: 0 } },
],
}
expect((await page.request.post('/api/gold-land/levels/7/start', { headers })).ok()).toBeTruthy()
expect((await page.request.post('/api/gold-land/levels/7/complete', { headers, data: budget })).ok()).toBeTruthy()
await page.goto('/map')
await page.getByRole('button', { name: 'مرحله 8: ریسک و بازده' }).click()
await page.getByRole('button', { name: 'شروع چالش' }).click()
await page.getByRole('button', { name: 'دیدن دو پیشنهاد' }).click()
await expect(page.getByText('کارت 1 از 4: 8 سکه')).toBeVisible()
await expect(page.getByText('کارت 4 از 4: 36 سکه')).toBeVisible()
await expect(page.getByText('کارت 3 از 4: 22 سکه')).toBeVisible()
await page.getByRole('button', { name: 'رفتن به فعالیت‌ها' }).click()
await answerChallenge(page)
const before = await fetchMe(page)
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await expect(page.getByText('پاداش:')).toBeVisible()
await expect(page.getByText('امکان سود بیشتر، تضمین سود نیست')).toBeVisible()
await page.getByRole('button', { name: 'بازگشت به نقشه' }).click()
await page.reload()
const after = await fetchMe(page)
expect(after.progress.mapLevels.find((level) => level.id === 8)?.stars).toBe(3)
expect(after.user.coins).toBe(before.user.coins + 20 + 9)
await page.getByRole('button', { name: 'مرحله 8: ریسک و بازده' }).click()
await page.getByRole('button', { name: 'مرور مجدد' }).click()
await page.getByRole('button', { name: 'دیدن دو پیشنهاد' }).click()
await page.getByRole('button', { name: 'رفتن به فعالیت‌ها' }).click()
await answerChallenge(page)
const replayBefore = await fetchMe(page)
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await expect(page.getByText('پاداش تازه‌ای اضافه نشد')).toBeVisible()
const replayAfter = await fetchMe(page)
expect(replayAfter.user.coins).toBe(replayBefore.user.coins)
await page.goto('/map')
await expect(page.getByRole('button', { name: 'مرحله 10: سود مرکب' })).toBeDisabled()
})
+135
View File
@@ -0,0 +1,135 @@
import { test, expect } from '@playwright/test'
import { fetchMe, registerViaUi, uniqueUsername } from './helpers/auth'
async function completePrior(page: import('@playwright/test').Page, headers: { authorization: string }) {
const lessons: Record<number, Array<Record<string, unknown>>> = {
1: [
{ questionId: 'money-q1', answerId: 'option-a' },
{ questionId: 'money-q2', answerId: 'true' },
{ questionId: 'money-q3', answerId: 'option-a' },
{ questionId: 'money-q4', answerId: 'true' },
{ questionId: 'money-q5', answerId: 'option-b' },
],
2: [
{ questionId: 'saving-q1', answerId: 'option-b' },
{ questionId: 'saving-q2', answerId: 'true' },
{ questionId: 'saving-q3', answerId: 'option-a' },
{ questionId: 'saving-q4', answerId: 'true' },
{ questionId: 'saving-q5', answerId: 'option-a' },
],
3: [
{ questionId: 'needs-q1', answerId: 'option-a' },
{ questionId: 'needs-q2', answerId: 'true' },
{ questionId: 'needs-q3', orderedItemIds: ['water', 'food', 'medicine', 'shoes', 'toy', 'crown'] },
{ questionId: 'needs-q4', answerId: 'option-a' },
{ questionId: 'needs-q5', answerId: 'true' },
],
4: [
{ questionId: 'gold-q1', answerId: 'option-a' },
{ questionId: 'gold-q2', answerId: 'true' },
{ questionId: 'gold-q3', answerId: 'option-a' },
{ questionId: 'gold-q4', answerId: 'false' },
{ questionId: 'gold-q5', answerId: 'option-b' },
],
5: [
{ questionId: 'market-q1', answerId: 'option-a' },
{ questionId: 'market-q2', answerId: 'false' },
{ questionId: 'market-q3', answerId: 'option-a' },
{ questionId: 'market-q4', answerId: 'true' },
{ questionId: 'market-q5', answerId: 'option-b' },
],
6: [
{ questionId: 'inflation-q1', answerId: 'option-b' },
{ questionId: 'inflation-q2', answerId: 'true' },
{ questionId: 'inflation-q3', answerId: 'option-a' },
{ questionId: 'inflation-q4', answerId: 'false' },
{ questionId: 'inflation-q5', answerId: 'option-b' },
],
}
for (let levelId = 1; levelId <= 6; levelId += 1) {
expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy()
expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, { headers, data: { answers: lessons[levelId] } })).ok()).toBeTruthy()
}
const posts: Array<[string, unknown]> = [
['7', { answers: [
{ questionId: 'initial', allocations: { transport: 20, food: 25, savings: 20, fun: 0 } },
{ questionId: 'final', allocations: { transport: 30, food: 25, savings: 20, fun: 0 } },
] }],
['8', { answers: [
{ 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' },
] }],
['9', { answers: [
{ questionId: 'portfolio', holdings: { sun: 40, rain: 30, wind: 30 } },
{ 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' },
] }],
['10', { answers: [
{ 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' },
] }],
]
for (const [levelId, data] of posts) {
expect((await page.request.post(`/api/gold-land/levels/${levelId}/start`, { headers })).ok()).toBeTruthy()
expect((await page.request.post(`/api/gold-land/levels/${levelId}/complete`, { headers, data })).ok()).toBeTruthy()
}
}
async function answerShop(page: import('@playwright/test').Page) {
const picks = [
['دفتر ۶۰برگ، ۴۰ سکه، تحویل ۵ سکه', 'نیاز را برآورده می‌کند و هزینه نهایی‌اش بین گزینه‌های مناسب کمتر است'],
['مداد با قیمت نهایی ۲۸، بدون تخفیف', 'برای کالای یکسان، مبلغ نهایی پرداختی مهم است؛ درصد تخفیف به‌تنهایی کافی نیست'],
['فعلاً نخر و اندازه و هزینه نهایی را بپرس، با کمک بزرگسال قابل اعتماد', 'اطلاعات لازم برای تصمیم و مقایسه هنوز کامل نیست'],
]
for (let index = 0; index < picks.length; index += 1) {
if (index === 2) await expect(page.getByText('نامشخص').first()).toBeVisible()
await page.getByText(picks[index]![0]!, { exact: true }).click()
await page.getByText(picks[index]![1]!, { exact: true }).click()
await page.getByRole('button', { name: index === picks.length - 1 ? 'مرور انتخاب‌ها' : 'مأموریت بعد' }).click()
}
}
test('smart shop from the map', async ({ page }) => {
const user = uniqueUsername('shop')
await registerViaUi(page, user, 'pass1234', 'خرید')
const token = await page.evaluate(() => localStorage.getItem('gold-land-auth-token'))
await completePrior(page, { authorization: `Bearer ${token}` })
await page.goto('/map')
await page.getByRole('button', { name: 'مرحله 11: خرید هوشمند' }).click()
await page.getByRole('button', { name: 'شروع بازی' }).click()
await page.getByRole('button', { name: 'شروع خریدها' }).click()
await expect(page.getByText('بودجه:')).toBeVisible()
await expect(page.getByText('جمع نهایی: ۴۵ سکه')).toBeVisible()
await answerShop(page)
const before = await fetchMe(page)
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await expect(page.getByText('پاداش:')).toBeVisible()
await page.getByRole('button', { name: 'بازگشت به نقشه' }).click()
await page.reload()
const after = await fetchMe(page)
expect(after.progress.mapLevels.find((level) => level.id === 11)?.stars).toBe(3)
expect(after.user.coins).toBe(before.user.coins + 18 + 9)
await page.getByRole('button', { name: 'مرحله 11: خرید هوشمند' }).click()
await page.getByRole('button', { name: 'مرور مجدد' }).click()
await page.getByRole('button', { name: 'شروع خریدها' }).click()
await answerShop(page)
const replayBefore = await fetchMe(page)
await page.getByRole('button', { name: 'ثبت نتیجه' }).click()
await expect(page.getByText('پاداش تازه‌ای اضافه نشد')).toBeVisible()
const replayAfter = await fetchMe(page)
expect(replayAfter.user.coins).toBe(replayBefore.user.coins)
await page.goto('/map')
await expect(page.getByRole('button', { name: 'مرحله 13: آزمون قلعه' })).toBeDisabled()
})
@@ -0,0 +1,69 @@
export interface BudgetAmounts {
transport: number
food: number
savings: number
fun: number
}
export type BudgetKey = keyof BudgetAmounts
export interface BudgetCategoryRule {
id: BudgetKey
label: string
requiredInitial: number | null
requiredFinal: number | null
}
export function emptyBudget(): BudgetAmounts {
return { transport: 0, food: 0, savings: 0, fun: 0 }
}
export function budgetSum(amounts: BudgetAmounts): number {
return amounts.transport + amounts.food + amounts.savings + amounts.fun
}
export function budgetReserve(total: number, amounts: BudgetAmounts): number {
return total - budgetSum(amounts)
}
export function canIncrease(amounts: BudgetAmounts, step: number, total: number): boolean {
return budgetSum(amounts) + step <= total
}
export function canDecrease(amounts: BudgetAmounts, key: BudgetKey, step: number): boolean {
return amounts[key] - step >= 0
}
export function adjustBudget(
amounts: BudgetAmounts,
key: BudgetKey,
delta: number,
step: number,
total: number,
): BudgetAmounts {
if (delta > 0 && !canIncrease(amounts, step, total)) return amounts
if (delta < 0 && !canDecrease(amounts, key, step)) return amounts
return { ...amounts, [key]: amounts[key] + delta }
}
export function missingEssentials(
amounts: BudgetAmounts,
categories: BudgetCategoryRule[],
phase: 'initial' | 'final',
): BudgetCategoryRule[] {
return categories.filter((category) => {
const required = phase === 'initial' ? category.requiredInitial : category.requiredFinal
return required != null && amounts[category.id] < required
})
}
export function cloneBudget(amounts: BudgetAmounts): BudgetAmounts {
return { ...amounts }
}
export function budgetSubmission(initial: BudgetAmounts, finalAmounts: BudgetAmounts) {
return [
{ questionId: 'initial' as const, allocations: cloneBudget(initial) },
{ questionId: 'final' as const, allocations: cloneBudget(finalAmounts) },
]
}
@@ -0,0 +1,11 @@
export function sectionIds(questions: Array<{ sectionId: string }>): string[] {
return [...new Set(questions.map((question) => question.sectionId))]
}
export function unansweredCount(questionIds: string[], answers: Record<string, string>): number {
return questionIds.filter((id) => !answers[id]).length
}
export function clearExamAnswers(): Record<string, string> {
return {}
}
@@ -0,0 +1,376 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import PrimaryButton from '@/components/common/PrimaryButton.vue'
import { illustrations } from '@/assets/illustrations'
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
import { runMeMutation } from '@/api/sync'
import { ApiClientError, ApiNetworkError, StaleSessionError } from '@/api/errors'
import {
adjustBudget,
budgetReserve,
budgetSubmission,
canDecrease,
canIncrease,
cloneBudget,
emptyBudget,
missingEssentials,
type BudgetAmounts,
type BudgetKey,
} from '@/modules/gold-land/budgeting/budgetMath'
import type { BudgetMissionContent, CompleteLevelResponse, LevelItem } from '@/modules/gold-land/types/goldLand.types'
const props = defineProps<{ levelId: number }>()
const emit = defineEmits<{
backToMap: [focusLevel?: number]
nextLevel: [levelId: number]
}>()
type Phase = 'intro' | 'plan' | 'review' | 'event' | 'revise' | 'result'
const loading = ref(true)
const error = ref('')
const submitError = ref('')
const submitting = ref(false)
const phase = ref<Phase>('intro')
const mission = ref<BudgetMissionContent | null>(null)
const levelTitle = ref('')
const draft = ref<BudgetAmounts>(emptyBudget())
const initialSnapshot = ref<BudgetAmounts>(emptyBudget())
const finalDraft = ref<BudgetAmounts>(emptyBudget())
const result = ref<CompleteLevelResponse | null>(null)
const levels = ref<LevelItem[]>([])
const limitNote = ref('')
const categories = computed(() => mission.value?.categories ?? [])
const total = computed(() => mission.value?.totalCoins ?? 100)
const step = computed(() => mission.value?.step ?? 5)
const activeAmounts = computed(() => (phase.value === 'revise' ? finalDraft.value : draft.value))
const reserve = computed(() => budgetReserve(total.value, activeAmounts.value))
const finalGaps = computed(() => missingEssentials(finalDraft.value, categories.value, 'final'))
const budgetPlans = computed(() => {
const feedback = result.value?.feedback
if (!feedback || !('plans' in feedback)) return []
return feedback.plans
})
const guideImage = computed(() => illustrations.professor)
const nextPlayable = computed(() => {
if (!result.value?.unlockedLevelId) return null
const next = levels.value.find((level) => level.id === result.value?.unlockedLevelId)
if (!next || next.status === 'locked' || next.status === 'coming_soon') return null
return next
})
function amountsForPhase(): BudgetAmounts {
return phase.value === 'revise' ? finalDraft.value : draft.value
}
function setAmounts(next: BudgetAmounts) {
if (phase.value === 'revise') finalDraft.value = next
else draft.value = next
}
function change(key: BudgetKey, direction: 1 | -1) {
limitNote.value = ''
const current = amountsForPhase()
if (direction > 0 && !canIncrease(current, step.value, total.value)) {
limitNote.value = 'بیشتر از ۱۰۰ سکه نمی‌شود. اول از بخش دیگری کم کن.'
return
}
if (direction < 0 && !canDecrease(current, key, step.value)) {
limitNote.value = 'این بخش از صفر کمتر نمی‌شود.'
return
}
setAmounts(adjustBudget(current, key, direction * step.value, step.value, total.value))
}
async function loadMission() {
loading.value = true
error.value = ''
submitError.value = ''
result.value = null
phase.value = 'intro'
draft.value = emptyBudget()
finalDraft.value = emptyBudget()
try {
const detail = await goldLandApi.getLevel(props.levelId)
if (detail.level.status === 'locked') {
error.value = 'این مرحله هنوز قفل است.'
return
}
if (detail.level.status === 'coming_soon') {
error.value = 'این مرحله به‌زودی باز می‌شود.'
return
}
if (detail.activity?.type !== 'budgeting' || !detail.activity.budgeting) {
error.value = 'محتوای این مرحله در دسترس نیست.'
return
}
await goldLandApi.startLevel(props.levelId)
mission.value = detail.activity.budgeting
levelTitle.value = detail.level.title
} catch (err) {
error.value = err instanceof Error ? err.message : 'خطا در دریافت مأموریت'
} finally {
loading.value = false
}
}
function finalNeedMessage(gaps: { label: string }[]) {
return `قبل از ثبت، این نیازها را خودت کامل کن: ${gaps.map((item) => item.label).join('، ')}. رفت‌وآمد باید حداقل ۳۰ سکه و آب و غذا حداقل ۲۵ سکه باشد.`
}
function goReview() {
phase.value = 'review'
}
function backToPlan() {
phase.value = 'plan'
}
function confirmInitial() {
const gaps = missingEssentials(draft.value, categories.value, 'initial')
if (gaps.length > 0) return
initialSnapshot.value = cloneBudget(draft.value)
finalDraft.value = cloneBudget(draft.value)
phase.value = 'event'
}
function startRevise() {
finalDraft.value = cloneBudget(initialSnapshot.value)
phase.value = 'revise'
}
async function submitMission() {
if (submitting.value || !mission.value) return
if (finalGaps.value.length > 0) {
submitError.value = finalNeedMessage(finalGaps.value)
return
}
submitting.value = true
submitError.value = ''
try {
const response = await runMeMutation(
() =>
goldLandApi.completeLevel(
props.levelId,
budgetSubmission(initialSnapshot.value, finalDraft.value),
),
(value) => value.me,
)
result.value = response
phase.value = 'result'
levels.value = (await goldLandApi.getProgress()).levels
} catch (err) {
if (err instanceof StaleSessionError) return
submitError.value =
err instanceof ApiNetworkError
? 'اتصال برقرار نشد. دوباره بفرست.'
: err instanceof ApiClientError && err.status === 422
? err.message
: err instanceof ApiClientError
? `برنامه پذیرفته نشد: ${err.message}`
: err instanceof Error
? err.message
: 'ارسال نتیجه ناموفق بود'
} finally {
submitting.value = false
}
}
onMounted(loadMission)
</script>
<template>
<div class="budget-mission">
<p v-if="loading" class="budget-mission__status">در حال بارگذاری مأموریت...</p>
<template v-else-if="error">
<p class="budget-mission__status" role="alert">{{ error }}</p>
<PrimaryButton variant="purple" full-width @click="loadMission">تلاش دوباره</PrimaryButton>
</template>
<template v-else-if="mission && phase === 'intro'">
<img :src="guideImage" alt="" class="budget-mission__guide" />
<p class="budget-mission__guide-name">{{ mission.guideTitle }}</p>
<h2>{{ mission.title }}</h2>
<p>{{ mission.intro }}</p>
<ul>
<li v-for="rule in mission.rules" :key="rule">{{ rule }}</li>
</ul>
<PrimaryButton variant="gold" full-width @click="phase = 'plan'">ساخت بودجه</PrimaryButton>
</template>
<template v-else-if="mission && (phase === 'plan' || phase === 'revise')">
<h2>{{ phase === 'plan' ? 'بودجهٔ اول' : 'بودجهٔ بعد از تغییر' }}</h2>
<p class="budget-mission__totals">
بودجهٔ کل {{ total }} سکه. {{ mission.reserveLabel }}: {{ reserve }} سکه.
</p>
<p v-if="limitNote" role="status">{{ limitNote }}</p>
<div v-for="category in categories" :key="category.id" class="budget-row">
<div>
<strong>{{ category.label }}</strong>
<p>{{ category.hint }}</p>
<p>مقدار: {{ activeAmounts[category.id] }} سکه</p>
</div>
<div class="budget-row__actions">
<button
type="button"
:aria-label="`کاهش ${category.label}`"
:disabled="!canDecrease(activeAmounts, category.id, step)"
@click="change(category.id, -1)"
>
کم کردن
</button>
<button
type="button"
:aria-label="`افزایش ${category.label}`"
:disabled="!canIncrease(activeAmounts, step, total)"
@click="change(category.id, 1)"
>
زیاد کردن
</button>
</div>
</div>
<PrimaryButton
v-if="phase === 'plan'"
variant="purple"
full-width
@click="goReview"
>
بررسی برنامه
</PrimaryButton>
<template v-else>
<p v-if="finalGaps.length" role="status">
{{ finalNeedMessage(finalGaps) }}
</p>
<PrimaryButton
variant="gold"
full-width
:disabled="submitting || finalGaps.length > 0"
@click="submitMission"
>
{{ submitting ? 'در حال ارسال...' : 'ثبت نتیجه' }}
</PrimaryButton>
</template>
<p v-if="submitError" role="alert">{{ submitError }}</p>
<PrimaryButton
v-if="submitError"
variant="purple"
full-width
:disabled="submitting"
@click="submitMission"
>
ارسال دوباره
</PrimaryButton>
</template>
<template v-else-if="mission && phase === 'review'">
<h2>بررسی برنامه</h2>
<p v-if="missingEssentials(draft, categories, 'initial').length" role="status">
برنامهٔ اول هنوز تأیید نمی‌شود. رفت‌وآمد حداقل ۲۰ سکه و آب و غذا حداقل ۲۵ سکه لازم است.
کم است: {{ missingEssentials(draft, categories, 'initial').map((item) => item.label).join('، ') }}.
خودت مقدار را اصلاح کن. پس‌انداز، تفریح و ذخیره مانع تأیید نیستند.
</p>
<template v-else>
<p>رفت‌وآمد و آب و غذا برای این گردش کافی است. پس‌انداز برای هدف جدا از این نیازهاست و تفریح اختیاری است.</p>
<p>{{ mission.reserveLabel }}: {{ budgetReserve(total, draft) }} سکه. صفر بودن ذخیره به‌تنهایی اشکال ندارد.</p>
</template>
<PrimaryButton variant="purple" full-width @click="backToPlan">اصلاح برنامه</PrimaryButton>
<PrimaryButton
variant="gold"
full-width
:disabled="missingEssentials(draft, categories, 'initial').length > 0"
@click="confirmInitial"
>
تأیید برنامه
</PrimaryButton>
</template>
<template v-else-if="mission && phase === 'event'">
<img :src="guideImage" alt="" class="budget-mission__guide" />
<p class="budget-mission__guide-name">{{ mission.guideTitle }}</p>
<h2>شرایط تغییر کرد</h2>
<p>{{ mission.eventMessage }}</p>
<PrimaryButton variant="gold" full-width @click="startRevise">بازتنظیم بودجه</PrimaryButton>
</template>
<template v-else-if="result && mission">
<h2>نتیجهٔ مأموریت</h2>
<p>امتیاز {{ result.score }} از ۱۰۰. ستاره: {{ result.stars }} از ۳.</p>
<p v-if="result.reward.xp > 0 || result.reward.coins > 0">
پاداش: {{ result.reward.xp }} تجربه و {{ result.reward.coins }} سکه.
</p>
<p v-else>این بار پاداش تازه‌ای اضافه نشد.</p>
<section v-for="plan in budgetPlans" :key="plan.phase">
<h3>{{ plan.title }}</h3>
<ul>
<li v-for="item in plan.criteria" :key="item.id">
{{ item.earned ? 'انجام شد' : 'قابل بهتر شدن' }}: {{ item.label }}. {{ item.detail }}
</li>
</ul>
</section>
<PrimaryButton variant="gold" full-width @click="emit('backToMap', result.unlockedLevelId ?? props.levelId)">
بازگشت به نقشه
</PrimaryButton>
<PrimaryButton v-if="nextPlayable" variant="purple" full-width @click="emit('nextLevel', nextPlayable.id)">
رفتن به مرحله بعد
</PrimaryButton>
</template>
</div>
</template>
<style scoped>
.budget-mission {
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 24px;
}
.budget-mission__status,
.budget-mission p,
.budget-mission li {
line-height: 1.8;
}
.budget-mission__guide {
width: 96px;
height: 96px;
object-fit: contain;
align-self: center;
}
.budget-mission__guide-name {
text-align: center;
font-weight: 800;
margin: 0;
}
.budget-mission__totals {
font-weight: 800;
}
.budget-row {
border: 1px solid rgba(118, 75, 190, 0.35);
border-radius: var(--radius-lg);
padding: 12px;
}
.budget-row p {
margin: 4px 0 0;
color: var(--text-muted);
}
.budget-row__actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.budget-row__actions button {
min-height: 44px;
flex: 1;
}
</style>
@@ -0,0 +1,216 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import PrimaryButton from '@/components/common/PrimaryButton.vue'
import { illustrations } from '@/assets/illustrations'
import { ApiClientError, ApiNetworkError, StaleSessionError } from '@/api/errors'
import { runMeMutation } from '@/api/sync'
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
import { clearExamAnswers, sectionIds, unansweredCount } from '@/modules/gold-land/castle-exam/castleState'
import { setRiskAnswer } from '@/modules/gold-land/risk-return/riskChoices'
import type { CastleExamContent, CompleteLevelResponse } from '@/modules/gold-land/types/goldLand.types'
const props = defineProps<{ levelId: number }>()
const emit = defineEmits<{ backToMap: [focusLevel?: number] }>()
const router = useRouter()
type Phase = 'intro' | 'questions' | 'review' | 'result'
const loading = ref(true)
const error = ref('')
const submitError = ref('')
const submitting = ref(false)
const phase = ref<Phase>('intro')
const exam = ref<CastleExamContent | null>(null)
const sectionIndex = ref(0)
const questionIndex = ref(0)
const answers = ref<Record<string, string>>({})
const result = ref<CompleteLevelResponse | null>(null)
const sections = computed(() => sectionIds(exam.value?.questions ?? []).map((id) => exam.value?.sections.find((section) => section.id === id) ?? { id, title: id }))
const sectionQuestions = computed(() => exam.value?.questions.filter((question) => question.sectionId === sections.value[sectionIndex.value]?.id) ?? [])
const current = computed(() => sectionQuestions.value[questionIndex.value])
const answered = computed(() => (exam.value?.questions.length ?? 0) - unansweredCount(exam.value?.questions.map((question) => question.id) ?? [], answers.value))
const feedback = computed(() => {
const value = result.value?.feedback
if (!value || !('completed' in value)) return null
return value
})
const passed = computed(() => (result.value?.score ?? 0) >= 60 && feedback.value?.completed === true)
function resetAnswers() {
answers.value = clearExamAnswers()
result.value = null
sectionIndex.value = 0
questionIndex.value = 0
phase.value = 'questions'
submitError.value = ''
}
async function loadExam() {
loading.value = true
error.value = ''
result.value = null
phase.value = 'intro'
try {
const detail = await goldLandApi.getLevel(props.levelId)
if (detail.level.status === 'locked') {
error.value = 'این مرحله هنوز قفل است.'
return
}
if (detail.level.status === 'coming_soon') {
error.value = 'این مرحله به‌زودی باز می‌شود.'
return
}
if (detail.activity?.type !== 'castle-exam' || !detail.activity.castleExam) {
error.value = 'محتوای این مرحله در دسترس نیست.'
return
}
await goldLandApi.startLevel(props.levelId)
exam.value = detail.activity.castleExam
} catch (err) {
error.value = err instanceof Error ? err.message : 'خطا در دریافت آزمون'
} finally {
loading.value = false
}
}
function goPrev() {
if (questionIndex.value > 0) {
questionIndex.value -= 1
return
}
if (sectionIndex.value === 0) return
sectionIndex.value -= 1
questionIndex.value = Math.max(0, sectionQuestions.value.length - 1)
}
function editQuestion(questionId: string) {
const question = exam.value?.questions.find((item) => item.id === questionId)
if (!question) return
sectionIndex.value = sections.value.findIndex((section) => section.id === question.sectionId)
questionIndex.value = (exam.value?.questions.filter((item) => item.sectionId === question.sectionId).findIndex((item) => item.id === questionId)) ?? 0
phase.value = 'questions'
}
function nextQuestion() {
if (!current.value || !answers.value[current.value.id]) return
if (questionIndex.value < sectionQuestions.value.length - 1) {
questionIndex.value += 1
return
}
if (sectionIndex.value < sections.value.length - 1) {
sectionIndex.value += 1
questionIndex.value = 0
return
}
phase.value = 'review'
}
async function submitExam() {
if (!exam.value || submitting.value) return
if (unansweredCount(exam.value.questions.map((question) => question.id), answers.value) > 0) {
submitError.value = 'هنوز به همه سؤال‌ها پاسخ نداده‌ای.'
return
}
submitting.value = true
submitError.value = ''
try {
const response = await runMeMutation(
() => goldLandApi.completeLevel(props.levelId, exam.value!.questions.map((question) => ({
questionId: question.id,
answerId: answers.value[question.id] ?? '',
}))),
(value) => value.me,
)
result.value = response
phase.value = 'result'
} catch (err) {
if (err instanceof StaleSessionError) {
resetAnswers()
return
}
submitError.value = err instanceof ApiNetworkError
? 'اتصال برقرار نشد. پاسخ‌ها حفظ شده‌اند. دوباره بفرست.'
: err instanceof ApiClientError
? err.message
: err instanceof Error
? err.message
: 'ارسال نتیجه ناموفق بود'
} finally {
submitting.value = false
}
}
onMounted(loadExam)
</script>
<template>
<div class="castle-exam">
<p v-if="loading">در حال بارگذاری آزمون...</p>
<template v-else-if="error">
<p role="alert">{{ error }}</p>
<PrimaryButton variant="purple" full-width @click="loadExam">تلاش دوباره</PrimaryButton>
</template>
<template v-else-if="exam && phase === 'intro'">
<img :src="illustrations.professor" alt="" class="castle-exam__guide" />
<p class="castle-exam__guide-name">{{ exam.guideTitle }}</p>
<h2>{{ exam.title }}</h2>
<p>{{ exam.intro }}</p>
<PrimaryButton variant="gold" full-width @click="phase = 'questions'">شروع سؤال‌ها</PrimaryButton>
</template>
<template v-else-if="exam && phase === 'questions' && current">
<p>{{ sections[sectionIndex]?.title }}، سؤال {{ questionIndex + 1 }} از {{ sectionQuestions.length }}</p>
<p>پاسخ‌داده‌شده: {{ answered }} از {{ exam.questions.length }}. بی‌پاسخ: {{ exam.questions.length - answered }}</p>
<fieldset>
<legend>{{ current.prompt }}</legend>
<label v-for="option in current.options" :key="option.id">
<input type="radio" :name="current.id" :value="option.id" :checked="answers[current.id] === option.id" @change="answers = setRiskAnswer(answers, current.id, option.id)" />
<span>{{ option.label }}</span>
</label>
</fieldset>
<div class="castle-exam__nav">
<PrimaryButton variant="purple" :disabled="sectionIndex === 0 && questionIndex === 0" @click="goPrev">قبلی</PrimaryButton>
<PrimaryButton variant="gold" :disabled="!answers[current.id]" @click="nextQuestion">{{ sectionIndex === sections.length - 1 && questionIndex === sectionQuestions.length - 1 ? 'مرور پاسخ‌ها' : 'بعدی' }}</PrimaryButton>
</div>
</template>
<template v-else-if="exam && phase === 'review'">
<h2>مرور پاسخ‌ها</h2>
<p>پاسخ‌داده‌شده: {{ answered }} از {{ exam.questions.length }}</p>
<article v-for="question in exam.questions" :key="question.id">
<p>{{ question.sectionTitle }}: {{ question.prompt }}</p>
<p>{{ answers[question.id] ? question.options.find((option) => option.id === answers[question.id])?.label : 'هنوز پاسخی نیست' }}</p>
<button type="button" @click="editQuestion(question.id)">تغییر این پاسخ</button>
</article>
<p v-if="submitError" role="alert">{{ submitError }}</p>
<PrimaryButton variant="gold" full-width :disabled="submitting" @click="submitExam">{{ submitting ? 'در حال ارسال...' : 'ثبت نتیجه' }}</PrimaryButton>
<PrimaryButton v-if="submitError" variant="purple" full-width :disabled="submitting" @click="submitExam">ارسال دوباره</PrimaryButton>
</template>
<template v-else-if="result && feedback && exam">
<h2>نتیجه این تلاش</h2>
<p>امتیاز این تلاش: {{ result.score }} از ۱۰۰.</p>
<p v-if="passed">ستاره: {{ result.stars }} از ۳.</p>
<p v-else-if="result.previousStars > 0">این تلاش کامل نشد. ستاره ذخیره‌شده همچنان {{ result.previousStars }} از ۳ است.</p>
<p v-else>این تلاش کامل نشد و هنوز ستاره‌ای ثبت نشده.</p>
<p v-if="result.reward.xp > 0 || result.reward.coins > 0">پاداش: {{ result.reward.xp }} تجربه و {{ result.reward.coins }} سکه.</p>
<p v-else>این بار پاداش تازه‌ای اضافه نشد.</p>
<p v-if="passed">{{ feedback.summary }}</p>
<article v-for="item in feedback.questions" :key="item.id">
<p>{{ item.earned ? 'درست' : 'قابل بهتر شدن' }}: {{ exam.questions.find((question) => question.id === item.id)?.options.find((option) => option.id === item.selectedAnswerId)?.label }}</p>
<p>{{ item.explanation }}</p>
<button v-if="!item.earned" type="button" @click="router.push(`/gold-land/levels/${item.sourceLevelId}`)">مرور مرحله {{ item.sourceLevelId }}</button>
</article>
<PrimaryButton variant="purple" full-width @click="resetAnswers">تلاش مجدد</PrimaryButton>
<PrimaryButton variant="gold" full-width @click="emit('backToMap', props.levelId)">بازگشت به نقشه</PrimaryButton>
</template>
</div>
</template>
<style scoped>
.castle-exam { display: flex; flex-direction: column; gap: 12px; padding-bottom: 24px; }
.castle-exam p, .castle-exam legend { line-height: 1.8; }
.castle-exam__guide { width: 96px; height: 96px; object-fit: contain; align-self: center; }
.castle-exam__guide-name { text-align: center; font-weight: 800; margin: 0; }
.castle-exam__nav { display: flex; gap: 8px; }
label, article { border: 1px solid rgba(118, 75, 190, 0.35); border-radius: var(--radius-lg); padding: 12px; }
label { display: flex; gap: 8px; }
button { min-height: 44px; }
</style>
@@ -0,0 +1,249 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import PrimaryButton from '@/components/common/PrimaryButton.vue'
import { illustrations } from '@/assets/illustrations'
import { ApiClientError, ApiNetworkError, StaleSessionError } from '@/api/errors'
import { runMeMutation } from '@/api/sync'
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
import { clonePlan, initialPlan, revisedPlan } from '@/modules/gold-land/financial-goal/goalMath'
import { setRiskAnswer } from '@/modules/gold-land/risk-return/riskChoices'
import type { CompleteLevelResponse, FinancialGoalContent, LevelItem } from '@/modules/gold-land/types/goldLand.types'
const props = defineProps<{ levelId: number }>()
const emit = defineEmits<{ backToMap: [focusLevel?: number]; nextLevel: [levelId: number] }>()
type Phase = 'intro' | 'initial' | 'event' | 'questions' | 'review' | 'result'
const loading = ref(true)
const error = ref('')
const submitError = ref('')
const submitting = ref(false)
const phase = ref<Phase>('intro')
const mission = ref<FinancialGoalContent | null>(null)
const initialWeekly = ref<number | null>(null)
const initialSnapshot = ref<number | null>(null)
const exceptionWeekly = ref<number | null>(null)
const continueWeekly = ref<number | null>(null)
const answers = ref<Record<string, string>>({})
const questionIndex = ref(0)
const result = ref<CompleteLevelResponse | null>(null)
const levels = ref<LevelItem[]>([])
const plan = computed(() => mission.value?.plan)
const preview = computed(() => {
if (!plan.value || initialWeekly.value == null) return null
return initialPlan(plan.value.currentSavings, plan.value.goalPrice, initialWeekly.value, plan.value.weeklyIncome, plan.value.normalEssential)
})
const revised = computed(() => {
if (!plan.value || initialSnapshot.value == null || exceptionWeekly.value == null || continueWeekly.value == null) return null
return revisedPlan(plan.value.currentSavings, plan.value.goalPrice, initialSnapshot.value, exceptionWeekly.value, continueWeekly.value)
})
const questions = computed(() => mission.value?.questions ?? [])
const currentQuestion = computed(() => questions.value[questionIndex.value])
const feedback = computed(() => {
const value = result.value?.feedback
if (!value || !('timeline' in value)) return null
return value
})
const nextPlayable = computed(() => {
if (!result.value?.unlockedLevelId) return null
const next = levels.value.find((level) => level.id === result.value?.unlockedLevelId)
if (!next || next.status === 'locked' || next.status === 'coming_soon') return null
return next
})
function confirmInitial() {
if (initialWeekly.value == null) return
initialSnapshot.value = initialWeekly.value
exceptionWeekly.value = null
continueWeekly.value = null
phase.value = 'event'
}
function changeInitial() {
if (initialSnapshot.value == null) return
const saved = clonePlan(initialSnapshot.value)
phase.value = 'initial'
initialWeekly.value = saved
}
async function loadMission() {
loading.value = true
error.value = ''
result.value = null
phase.value = 'intro'
try {
const detail = await goldLandApi.getLevel(props.levelId)
if (detail.level.status === 'locked') {
error.value = 'این مرحله هنوز قفل است.'
return
}
if (detail.level.status === 'coming_soon') {
error.value = 'این مرحله به‌زودی باز می‌شود.'
return
}
if (detail.activity?.type !== 'financial-goal' || !detail.activity.financialGoal) {
error.value = 'محتوای این مرحله در دسترس نیست.'
return
}
await goldLandApi.startLevel(props.levelId)
mission.value = detail.activity.financialGoal
} catch (err) {
error.value = err instanceof Error ? err.message : 'خطا در دریافت چالش'
} finally {
loading.value = false
}
}
function goReview() {
if (questions.value.some((question) => !answers.value[question.id])) {
submitError.value = 'اول به هر پنج سؤال پاسخ بده.'
return
}
submitError.value = ''
phase.value = 'review'
}
async function submitGoal() {
if (submitting.value || initialSnapshot.value == null || exceptionWeekly.value == null || continueWeekly.value == null) return
submitting.value = true
submitError.value = ''
try {
const response = await runMeMutation(
() => goldLandApi.completeLevel(props.levelId, [
{
questionId: 'plan',
plan: {
initialWeekly: initialSnapshot.value!,
exceptionWeekly: exceptionWeekly.value!,
continueWeekly: continueWeekly.value!,
},
},
...questions.value.map((question) => ({ questionId: question.id, answerId: answers.value[question.id] ?? '' })),
]),
(value) => value.me,
)
result.value = response
phase.value = 'result'
levels.value = (await goldLandApi.getProgress()).levels
} catch (err) {
if (err instanceof StaleSessionError) return
submitError.value = err instanceof ApiNetworkError
? 'اتصال برقرار نشد. برنامه و پاسخ‌ها حفظ شده‌اند. دوباره بفرست.'
: err instanceof ApiClientError
? err.message
: err instanceof Error
? err.message
: 'ارسال نتیجه ناموفق بود'
} finally {
submitting.value = false
}
}
onMounted(loadMission)
</script>
<template>
<div class="goal-game">
<p v-if="loading">در حال بارگذاری چالش...</p>
<template v-else-if="error">
<p role="alert">{{ error }}</p>
<PrimaryButton variant="purple" full-width @click="loadMission">تلاش دوباره</PrimaryButton>
</template>
<template v-else-if="mission && phase === 'intro'">
<img :src="illustrations.professor" alt="" class="goal-game__guide" />
<p class="goal-game__guide-name">{{ mission.guideTitle }}</p>
<h2>{{ mission.title }}</h2>
<p>{{ mission.intro }}</p>
<ul><li v-for="rule in mission.rules" :key="rule">{{ rule }}</li></ul>
<PrimaryButton variant="gold" full-width @click="phase = 'initial'">ساخت برنامه</PrimaryButton>
</template>
<template v-else-if="mission && plan && phase === 'initial'">
<h2>برنامه اولیه</h2>
<p>هدف {{ plan.goalPrice }} سکه. موجودی {{ plan.currentSavings }} سکه. پول هفتگی {{ plan.weeklyIncome }} و نیاز ضروری {{ plan.normalEssential }} سکه.</p>
<p>پس‌انداز هفتگی را انتخاب کن. سریع‌ترین برنامه نمره اضافه ندارد.</p>
<div class="goal-game__choices">
<button v-for="rate in plan.initialRates" :key="rate" type="button" :aria-pressed="initialWeekly === rate" @click="initialWeekly = rate">{{ rate }} سکه در هفته</button>
</div>
<template v-if="preview && initialWeekly != null">
<p>مبلغ باقی‌مانده تا هدف: {{ preview.remaining }} سکه.</p>
<p>برآورد زمان: {{ preview.weeks }} هفته. مجموع پس‌انداز در هفته رسیدن: {{ preview.totalSaved }} سکه.</p>
<p>{{ preview.extra > 0 ? `مبلغ اضافه بر هدف: ${preview.extra} سکه.` : 'مبلغ اضافه بر هدف نداری.' }}</p>
<p>خرج اختیاری یا ذخیره همان هفته: {{ preview.optional }} سکه. صفر بودن آن به‌تنهایی خطا نیست.</p>
</template>
<PrimaryButton variant="gold" full-width :disabled="initialWeekly == null" @click="confirmInitial">تأیید برنامه اولیه</PrimaryButton>
</template>
<template v-else-if="mission && plan && phase === 'event'">
<h2>تغییر شرایط</h2>
<p>{{ mission.eventMessage }}</p>
<p>هفته اول با {{ initialSnapshot }} سکه پس‌انداز ثبت شده و با تغییر بعدی عوض نمی‌شود.</p>
<p>برای هفته دوم یکی از این مقدارها را انتخاب کن. پس‌انداز نکردن هم معتبر است.</p>
<div class="goal-game__choices">
<button v-for="rate in plan.exceptionRates" :key="`ex-${rate}`" type="button" :aria-pressed="exceptionWeekly === rate" @click="exceptionWeekly = rate">هفته دوم: {{ rate }} سکه</button>
</div>
<p>از هفته سوم شرایط اولیه برمی‌گردد. نرخ ادامه را خودت انتخاب کن.</p>
<div class="goal-game__choices">
<button v-for="rate in plan.continueRates" :key="`go-${rate}`" type="button" :aria-pressed="continueWeekly === rate" @click="continueWeekly = rate">ادامه: {{ rate }} سکه</button>
</div>
<template v-if="revised">
<p>موجودی پس از هفته دوم: {{ revised.balanceAfterTwo }} سکه.</p>
<p>هفته‌های باقی‌مانده: {{ revised.remainingWeeks }}. کل زمان: {{ revised.totalWeeks }} هفته.</p>
<p>موجودی نهایی برآوردی: {{ revised.finalBalance }} سکه. {{ revised.extra > 0 ? `${revised.extra} سکه اضافه بر هدف.` : 'بدون مبلغ اضافه.' }}</p>
<p>خط زمان: هفته ۱ با برنامه اولیه، هفته ۲ با نیاز بیشتر، بعد نرخ ادامه تا رسیدن به هدف.</p>
</template>
<PrimaryButton variant="purple" full-width @click="changeInitial">تغییر برنامه اولیه</PrimaryButton>
<PrimaryButton variant="gold" full-width :disabled="exceptionWeekly == null || continueWeekly == null" @click="phase = 'questions'">رفتن به سؤال‌ها</PrimaryButton>
</template>
<template v-else-if="mission && phase === 'questions' && currentQuestion">
<p>سؤال {{ questionIndex + 1 }} از {{ questions.length }}</p>
<fieldset>
<legend>{{ currentQuestion.prompt }}</legend>
<label v-for="option in currentQuestion.options" :key="option.id">
<input type="radio" :name="currentQuestion.id" :value="option.id" :checked="answers[currentQuestion.id] === option.id" @change="answers = setRiskAnswer(answers, currentQuestion.id, option.id)" />
<span>{{ option.label }}</span>
</label>
</fieldset>
<div class="goal-game__nav">
<PrimaryButton variant="purple" :disabled="questionIndex === 0" @click="questionIndex -= 1">قبلی</PrimaryButton>
<PrimaryButton v-if="questionIndex < questions.length - 1" variant="gold" :disabled="!answers[currentQuestion.id]" @click="questionIndex += 1">بعدی</PrimaryButton>
<PrimaryButton v-else variant="gold" :disabled="!answers[currentQuestion.id]" @click="goReview">مرور پاسخ‌ها</PrimaryButton>
</div>
<p v-if="submitError" role="alert">{{ submitError }}</p>
</template>
<template v-else-if="mission && phase === 'review'">
<h2>مرور</h2>
<p>برنامه اولیه {{ initialSnapshot }}، هفته دوم {{ exceptionWeekly }}، ادامه {{ continueWeekly }}. نمره از این عددها نمی‌آید.</p>
<article v-for="(question, index) in questions" :key="question.id">
<p>{{ question.prompt }}</p>
<p>انتخاب تو: {{ question.options.find((option) => option.id === answers[question.id])?.label }}</p>
<button type="button" @click="questionIndex = index; phase = 'questions'">تغییر این پاسخ</button>
</article>
<PrimaryButton variant="gold" full-width :disabled="submitting" @click="submitGoal">{{ submitting ? 'در حال ارسال...' : 'ثبت نتیجه' }}</PrimaryButton>
<p v-if="submitError" role="alert">{{ submitError }}</p>
<PrimaryButton v-if="submitError" variant="purple" full-width :disabled="submitting" @click="submitGoal">ارسال دوباره</PrimaryButton>
</template>
<template v-else-if="result && feedback">
<h2>نتیجه</h2>
<p>امتیاز {{ result.score }} از ۱۰۰. ستاره: {{ result.stars }} از ۳.</p>
<p v-if="result.reward.xp > 0 || result.reward.coins > 0">پاداش: {{ result.reward.xp }} تجربه و {{ result.reward.coins }} سکه.</p>
<p v-else>این بار پاداش تازه‌ای اضافه نشد.</p>
<p>برنامه اولیه {{ feedback.timeline.initialWeeks }} هفته. بعد از هفته دوم {{ feedback.timeline.balanceAfterTwo }} سکه، {{ feedback.timeline.remainingWeeks }} هفته دیگر، کل {{ feedback.timeline.totalWeeks }} هفته و موجودی نهایی {{ feedback.timeline.finalBalance }} سکه.</p>
<ul>
<li v-for="item in feedback.questions" :key="item.id">{{ item.earned ? 'درست' : 'قابل بهتر شدن' }}: {{ item.explanation }}</li>
</ul>
<p>{{ feedback.summary }}</p>
<PrimaryButton variant="gold" full-width @click="emit('backToMap', result.unlockedLevelId ?? props.levelId)">بازگشت به نقشه</PrimaryButton>
<PrimaryButton v-if="nextPlayable" variant="purple" full-width @click="emit('nextLevel', nextPlayable.id)">رفتن به مرحله بعد</PrimaryButton>
</template>
</div>
</template>
<style scoped>
.goal-game { display: flex; flex-direction: column; gap: 12px; padding-bottom: 24px; }
.goal-game p, .goal-game li, .goal-game legend { line-height: 1.8; }
.goal-game__guide { width: 96px; height: 96px; object-fit: contain; align-self: center; }
.goal-game__guide-name { text-align: center; font-weight: 800; margin: 0; }
.goal-game__choices, .goal-game__nav { display: flex; flex-wrap: wrap; gap: 8px; }
.goal-game button, label { min-height: 44px; }
label, article { border: 1px solid rgba(118, 75, 190, 0.35); border-radius: var(--radius-lg); padding: 12px; }
label { display: flex; gap: 8px; }
</style>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import SectionCard from '@/components/common/SectionCard.vue'
import { formatCoinTenths, nextShown, previousShown, revealPeriods } from '@/modules/gold-land/lesson/growthTable'
import type { GrowthTableStep } from '@/modules/gold-land/types/goldLand.types'
const props = defineProps<{ step: GrowthTableStep }>()
const shown = ref(1)
const visible = computed(() => revealPeriods(props.step.periods, shown.value))
const current = computed(() => visible.value[visible.value.length - 1])
function equation(start: number, growth: number, end: number): string {
return `${formatCoinTenths(start)} + ${formatCoinTenths(growth)} = ${formatCoinTenths(end)}`
}
</script>
<template>
<div class="growth-table">
<SectionCard>
<h2 v-if="step.title">{{ step.title }}</h2>
<p>{{ step.body }}</p>
<p>مبلغ اولیه {{ formatCoinTenths(step.principalTenths) }} سکه. نرخ فرضی {{ formatCoinTenths(step.ratePercent * 10) }}٪ در هر {{ step.unitLabel }}.</p>
</SectionCard>
<p v-for="method in step.methods" :key="method.id"><strong>{{ method.label }}:</strong> {{ method.note }}</p>
<div class="growth-table__nav">
<button type="button" :disabled="shown <= 1" @click="shown = previousShown(shown)">دوره قبل</button>
<button type="button" :disabled="shown >= step.periods.length" @click="shown = nextShown(shown, step.periods.length)">دوره بعد</button>
</div>
<p v-if="current">{{ step.unitLabel }} {{ formatCoinTenths(current.index * 10) }}</p>
<table v-if="current">
<thead>
<tr>
<th scope="col">روش</th>
<th scope="col">مبلغ شروع</th>
<th scope="col">رشد این دوره</th>
<th scope="col">مبلغ پایان</th>
</tr>
</thead>
<tbody>
<tr v-for="row in current.rows" :key="row.methodId">
<th scope="row">{{ row.label }}</th>
<td>{{ formatCoinTenths(row.startTenths) }} سکه</td>
<td>{{ formatCoinTenths(row.growthTenths) }} سکه</td>
<td>{{ formatCoinTenths(row.endTenths) }} سکه</td>
</tr>
</tbody>
</table>
<p v-for="row in current?.rows ?? []" :key="`${row.methodId}-eq`">
{{ row.label }}: {{ equation(row.startTenths, row.growthTenths, row.endTenths) }}
</p>
<p>{{ step.disclaimer }}</p>
</div>
</template>
<style scoped>
.growth-table { display: flex; flex-direction: column; gap: 12px; }
.growth-table p, .growth-table h2 { line-height: 1.8; margin: 0; }
.growth-table__nav { display: flex; gap: 8px; }
.growth-table button { min-height: 44px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: right; padding: 6px 4px; }
</style>
@@ -28,6 +28,7 @@ const emit = defineEmits<{
const loading = ref(true) const loading = ref(true)
const error = ref('') const error = ref('')
const submitError = ref('')
const submitting = ref(false) const submitting = ref(false)
const steps = ref<SanitizedLessonStep[]>([]) const steps = ref<SanitizedLessonStep[]>([])
const levelOrder = ref(1) const levelOrder = ref(1)
@@ -85,6 +86,7 @@ function initSortOrders(stepList: SanitizedLessonStep[]) {
async function loadLesson() { async function loadLesson() {
loading.value = true loading.value = true
error.value = '' error.value = ''
submitError.value = ''
result.value = null result.value = null
currentIndex.value = 0 currentIndex.value = 0
choiceAnswers.value = {} choiceAnswers.value = {}
@@ -175,12 +177,12 @@ async function submitLesson() {
}) })
if (missing) { if (missing) {
error.value = 'لطفاً به همه سؤال‌ها پاسخ بده.' submitError.value = 'لطفاً به همه سؤال‌ها پاسخ بده.'
return return
} }
submitting.value = true submitting.value = true
error.value = '' submitError.value = ''
try { try {
const durationMs = Date.now() - startedAt.value const durationMs = Date.now() - startedAt.value
@@ -195,7 +197,7 @@ async function submitLesson() {
levelsProgress.value = progress.levels levelsProgress.value = progress.levels
} catch (err) { } catch (err) {
if (err instanceof StaleSessionError) return if (err instanceof StaleSessionError) return
error.value = err instanceof Error ? err.message : 'خطا در ارسال پاسخ‌ها' submitError.value = err instanceof Error ? err.message : 'خطا در ارسال پاسخ‌ها'
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -253,7 +255,10 @@ onMounted(loadLesson)
@reorder-sort="reorderSort(currentStep.id, $event)" @reorder-sort="reorderSort(currentStep.id, $event)"
/> />
<p v-if="error" class="lesson-player__error">{{ error }}</p> <p v-if="submitError" class="lesson-player__error" role="alert">{{ submitError }}</p>
<PrimaryButton v-if="submitError" variant="purple" full-width :disabled="submitting" @click="submitLesson">
ارسال دوباره
</PrimaryButton>
<div class="lesson-player__nav"> <div class="lesson-player__nav">
<PrimaryButton <PrimaryButton
@@ -15,6 +15,7 @@ const emit = defineEmits<{
}>() }>()
const starDisplay = '★'.repeat(props.result.stars) + '☆'.repeat(3 - props.result.stars) const starDisplay = '★'.repeat(props.result.stars) + '☆'.repeat(3 - props.result.stars)
const explanations = props.result.feedback && 'questions' in props.result.feedback ? props.result.feedback.questions : []
</script> </script>
<template> <template>
@@ -44,6 +45,10 @@ const starDisplay = '★'.repeat(props.result.stars) + '☆'.repeat(3 - props.re
</div> </div>
</div> </div>
<ul v-if="explanations.length" class="lesson-result__notes">
<li v-for="item in explanations" :key="item.id">{{ item.earned ? 'درست' : 'قابل بهتر شدن' }}: {{ item.explanation }}</li>
</ul>
<p v-if="result.bestScoreImproved && !result.isFirstCompletion" class="lesson-result__note"> <p v-if="result.bestScoreImproved && !result.isFirstCompletion" class="lesson-result__note">
رکورد امتیازت بهتر شد! رکورد امتیازت بهتر شد!
</p> </p>
@@ -4,6 +4,7 @@ import LessonSingleChoiceStep from './LessonSingleChoiceStep.vue'
import LessonTrueFalseStep from './LessonTrueFalseStep.vue' import LessonTrueFalseStep from './LessonTrueFalseStep.vue'
import LessonSortStep from './LessonSortStep.vue' import LessonSortStep from './LessonSortStep.vue'
import LessonSummaryStep from './LessonSummaryStep.vue' import LessonSummaryStep from './LessonSummaryStep.vue'
import LessonGrowthTableStep from './LessonGrowthTableStep.vue'
import type { SanitizedLessonStep } from '@/modules/gold-land/types/goldLand.types' import type { SanitizedLessonStep } from '@/modules/gold-land/types/goldLand.types'
defineProps<{ defineProps<{
@@ -48,4 +49,6 @@ const emit = defineEmits<{
/> />
<LessonSummaryStep v-else-if="step.type === 'summary'" :step="step" /> <LessonSummaryStep v-else-if="step.type === 'summary'" :step="step" />
<LessonGrowthTableStep v-else-if="step.type === 'growth_table'" :step="step" />
</template> </template>
@@ -6,6 +6,7 @@ const IMAGE_MAP: Record<string, string> = {
coins: illustrations.coinsStack, coins: illustrations.coinsStack,
'gold-bar': illustrations.goldBar, 'gold-bar': illustrations.goldBar,
piggy: illustrations.gamePiggy, piggy: illustrations.gamePiggy,
'gold-chart': illustrations.gameGoldChart,
market: illustrations.gameGoldChart, market: illustrations.gameGoldChart,
} }
@@ -0,0 +1,283 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import PrimaryButton from '@/components/common/PrimaryButton.vue'
import { illustrations } from '@/assets/illustrations'
import { ApiClientError, ApiNetworkError, StaleSessionError } from '@/api/errors'
import { runMeMutation } from '@/api/sync'
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
import {
adjustFund,
canDecreaseFund,
canIncreaseFund,
emptyFunds,
previewScenarios,
remainingCoins,
type FundAmounts,
type FundId,
} from '@/modules/gold-land/portfolio/portfolioMath'
import { setRiskAnswer } from '@/modules/gold-land/risk-return/riskChoices'
import type { CompleteLevelResponse, LevelItem, PortfolioMissionContent } from '@/modules/gold-land/types/goldLand.types'
const props = defineProps<{ levelId: number }>()
const emit = defineEmits<{ backToMap: [focusLevel?: number]; nextLevel: [levelId: number] }>()
type Phase = 'intro' | 'build' | 'outcomes' | 'questions' | 'review' | 'result'
const loading = ref(true)
const error = ref('')
const submitError = ref('')
const submitting = ref(false)
const phase = ref<Phase>('intro')
const mission = ref<PortfolioMissionContent | null>(null)
const funds = ref<FundAmounts>(emptyFunds())
const answers = ref<Record<string, string>>({})
const questionIndex = ref(0)
const result = ref<CompleteLevelResponse | null>(null)
const levels = ref<LevelItem[]>([])
const limitNote = ref('')
const total = computed(() => mission.value?.totalCoins ?? 100)
const step = computed(() => mission.value?.step ?? 10)
const left = computed(() => remainingCoins(total.value, funds.value))
const questions = computed(() => mission.value?.questions ?? [])
const currentQuestion = computed(() => questions.value[questionIndex.value])
const previews = computed(() => {
if (!mission.value) return []
return previewScenarios(funds.value, mission.value.scenarios, mission.value.funds)
})
const serverFeedback = computed(() => {
const feedback = result.value?.feedback
if (!feedback || !('scenarios' in feedback)) return null
return feedback
})
const nextPlayable = computed(() => {
if (!result.value?.unlockedLevelId) return null
const next = levels.value.find((level) => level.id === result.value?.unlockedLevelId)
if (!next || next.status === 'locked' || next.status === 'coming_soon') return null
return next
})
function change(id: FundId, direction: 1 | -1) {
limitNote.value = ''
if (direction > 0 && !canIncreaseFund(funds.value, step.value, total.value)) {
limitNote.value = 'بیشتر از ۱۰۰ سکه نمی‌شود.'
return
}
if (direction < 0 && !canDecreaseFund(funds.value, id, step.value)) {
limitNote.value = 'این صندوق از صفر کمتر نمی‌شود.'
return
}
funds.value = adjustFund(funds.value, id, direction * step.value, step.value, total.value)
}
function seeOutcomes() {
if (left.value !== 0) return
phase.value = 'outcomes'
}
async function loadMission() {
loading.value = true
error.value = ''
result.value = null
phase.value = 'intro'
try {
const detail = await goldLandApi.getLevel(props.levelId)
if (detail.level.status === 'locked') {
error.value = 'این مرحله هنوز قفل است.'
return
}
if (detail.level.status === 'coming_soon') {
error.value = 'این مرحله به‌زودی باز می‌شود.'
return
}
if (detail.activity?.type !== 'portfolio' || !detail.activity.portfolio) {
error.value = 'محتوای این مرحله در دسترس نیست.'
return
}
await goldLandApi.startLevel(props.levelId)
mission.value = detail.activity.portfolio
} catch (err) {
error.value = err instanceof Error ? err.message : 'خطا در دریافت بازی'
} finally {
loading.value = false
}
}
function goReview() {
if (questions.value.some((question) => !answers.value[question.id])) {
submitError.value = 'اول به هر پنج سؤال پاسخ بده.'
return
}
submitError.value = ''
phase.value = 'review'
}
async function submitPortfolio() {
if (submitting.value || !mission.value || left.value !== 0) return
submitting.value = true
submitError.value = ''
try {
const response = await runMeMutation(
() =>
goldLandApi.completeLevel(props.levelId, [
{ questionId: 'portfolio', holdings: { ...funds.value } },
...questions.value.map((question) => ({
questionId: question.id,
answerId: answers.value[question.id] ?? '',
})),
]),
(value) => value.me,
)
result.value = response
phase.value = 'result'
levels.value = (await goldLandApi.getProgress()).levels
} catch (err) {
if (err instanceof StaleSessionError) return
submitError.value =
err instanceof ApiNetworkError
? 'اتصال برقرار نشد. سبد و پاسخ‌ها حفظ شده‌اند. دوباره بفرست.'
: err instanceof ApiClientError
? err.message
: err instanceof Error
? err.message
: 'ارسال نتیجه ناموفق بود'
} finally {
submitting.value = false
}
}
onMounted(loadMission)
</script>
<template>
<div class="portfolio-game">
<p v-if="loading">در حال بارگذاری بازی...</p>
<template v-else-if="error">
<p role="alert">{{ error }}</p>
<PrimaryButton variant="purple" full-width @click="loadMission">تلاش دوباره</PrimaryButton>
</template>
<template v-else-if="mission && phase === 'intro'">
<img :src="illustrations.professor" alt="" class="portfolio-game__guide" />
<p class="portfolio-game__guide-name">{{ mission.guideTitle }}</p>
<h2>{{ mission.title }}</h2>
<p>{{ mission.intro }}</p>
<ul>
<li v-for="rule in mission.rules" :key="rule">{{ rule }}</li>
</ul>
<PrimaryButton variant="gold" full-width @click="phase = 'build'">ساخت سبد</PrimaryButton>
</template>
<template v-else-if="mission && phase === 'build'">
<h2>تقسیم {{ total }} سکه</h2>
<p>مانده برای تخصیص: {{ left }} سکه. ادامه فقط وقتی مانده صفر است.</p>
<p v-if="limitNote" role="status">{{ limitNote }}</p>
<p v-if="left !== 0" role="status">مجموع هنوز ۱۰۰ نیست؛ برای دیدن پیامدها باید دقیقاً ۱۰۰ سکه تقسیم شود.</p>
<div v-for="fund in mission.funds" :key="fund.id" class="portfolio-row">
<strong>{{ fund.label }}</strong>
<p>{{ fund.hint }}</p>
<p>مقدار: {{ funds[fund.id] }} سکه</p>
<div class="portfolio-row__actions">
<button type="button" :aria-label="`کاهش ${fund.label}`" :disabled="!canDecreaseFund(funds, fund.id, step)" @click="change(fund.id, -1)">کم کردن</button>
<button type="button" :aria-label="`افزایش ${fund.label}`" :disabled="!canIncreaseFund(funds, step, total)" @click="change(fund.id, 1)">زیاد کردن</button>
</div>
</div>
<PrimaryButton variant="gold" full-width :disabled="left !== 0" @click="seeOutcomes">دیدن سه سناریو</PrimaryButton>
</template>
<template v-else-if="mission && phase === 'outcomes'">
<h2>سه مثال ساختگی</h2>
<p>قرعه‌کشی نشده. هیچ سبدی به‌خاطر یک سناریو بهترین نیست. سبد مرجع، ۱۰۰ سکه فقط در آفتاب است.</p>
<section v-for="scenario in previews" :key="scenario.id" class="portfolio-scenario">
<h3>{{ scenario.title }}</h3>
<p>{{ scenario.note }}</p>
<table>
<tbody>
<tr v-for="part in scenario.parts" :key="part.label">
<th scope="row">{{ part.label }}</th>
<td>{{ part.value }} سکه</td>
</tr>
<tr>
<th scope="row">مجموع سبد تو</th>
<td>{{ scenario.total }} سکه، {{ scenario.changeLabel }}</td>
</tr>
<tr>
<th scope="row">سبد مرجع آفتاب</th>
<td>{{ scenario.referenceSunTotal }} سکه، {{ scenario.referenceChangeLabel }}</td>
</tr>
</tbody>
</table>
</section>
<PrimaryButton variant="purple" full-width @click="phase = 'build'">تغییر ترکیب</PrimaryButton>
<PrimaryButton variant="gold" full-width @click="phase = 'questions'">رفتن به سؤال‌ها</PrimaryButton>
</template>
<template v-else-if="mission && phase === 'questions' && currentQuestion">
<p>سؤال {{ questionIndex + 1 }} از {{ questions.length }}</p>
<fieldset>
<legend>{{ currentQuestion.prompt }}</legend>
<label v-for="option in currentQuestion.options" :key="option.id" class="portfolio-option">
<input
type="radio"
:name="currentQuestion.id"
:value="option.id"
:checked="answers[currentQuestion.id] === option.id"
@change="answers = setRiskAnswer(answers, currentQuestion.id, option.id)"
/>
<span>{{ option.label }}</span>
</label>
</fieldset>
<div class="portfolio-game__nav">
<PrimaryButton variant="purple" :disabled="questionIndex === 0" @click="questionIndex -= 1">قبلی</PrimaryButton>
<PrimaryButton v-if="questionIndex < questions.length - 1" variant="gold" :disabled="!answers[currentQuestion.id]" @click="questionIndex += 1">بعدی</PrimaryButton>
<PrimaryButton v-else variant="gold" :disabled="!answers[currentQuestion.id]" @click="goReview">مرور پاسخ‌ها</PrimaryButton>
</div>
<p v-if="submitError" role="alert">{{ submitError }}</p>
</template>
<template v-else-if="mission && phase === 'review'">
<h2>مرور پاسخ‌ها</h2>
<p>سبد: آفتاب {{ funds.sun }}، باران {{ funds.rain }}، باد {{ funds.wind }}. نمره از ترکیب سبد نمی‌آید.</p>
<article v-for="(question, index) in questions" :key="question.id">
<p>{{ question.prompt }}</p>
<p>انتخاب تو: {{ question.options.find((option) => option.id === answers[question.id])?.label ?? 'هنوز انتخاب نشده' }}</p>
<button type="button" @click="questionIndex = index; phase = 'questions'">تغییر این پاسخ</button>
</article>
<PrimaryButton variant="gold" full-width :disabled="submitting" @click="submitPortfolio">
{{ submitting ? 'در حال ارسال...' : 'ثبت نتیجه' }}
</PrimaryButton>
<p v-if="submitError" role="alert">{{ submitError }}</p>
<PrimaryButton v-if="submitError" variant="purple" full-width :disabled="submitting" @click="submitPortfolio">ارسال دوباره</PrimaryButton>
</template>
<template v-else-if="result && serverFeedback">
<h2>نتیجه</h2>
<p>امتیاز {{ result.score }} از ۱۰۰. ستاره: {{ result.stars }} از ۳.</p>
<p v-if="result.reward.xp > 0 || result.reward.coins > 0">پاداش: {{ result.reward.xp }} تجربه و {{ result.reward.coins }} سکه.</p>
<p v-else>این بار پاداش تازه‌ای اضافه نشد.</p>
<section v-for="scenario in serverFeedback.scenarios" :key="scenario.id">
<h3>{{ scenario.title }}</h3>
<p>مجموع {{ scenario.total }} سکه، {{ scenario.changeLabel }}. مرجع آفتاب: {{ scenario.referenceSunTotal }} سکه، {{ scenario.referenceChangeLabel }}.</p>
</section>
<ul>
<li v-for="item in serverFeedback.questions" :key="item.id">{{ item.earned ? 'درست' : 'قابل بهتر شدن' }}: {{ item.explanation }}</li>
</ul>
<p>{{ serverFeedback.summary }}</p>
<PrimaryButton variant="gold" full-width @click="emit('backToMap', result.unlockedLevelId ?? props.levelId)">بازگشت به نقشه</PrimaryButton>
<PrimaryButton v-if="nextPlayable" variant="purple" full-width @click="emit('nextLevel', nextPlayable.id)">رفتن به مرحله بعد</PrimaryButton>
</template>
</div>
</template>
<style scoped>
.portfolio-game { display: flex; flex-direction: column; gap: 12px; padding-bottom: 24px; }
.portfolio-game p, .portfolio-game li, .portfolio-game legend { line-height: 1.8; }
.portfolio-game__guide { width: 96px; height: 96px; object-fit: contain; align-self: center; }
.portfolio-game__guide-name { text-align: center; font-weight: 800; margin: 0; }
.portfolio-row, .portfolio-scenario, .portfolio-option, article { border: 1px solid rgba(118, 75, 190, 0.35); border-radius: var(--radius-lg); padding: 12px; }
.portfolio-row__actions, .portfolio-game__nav { display: flex; gap: 8px; }
.portfolio-option { display: flex; gap: 8px; margin-top: 8px; }
.portfolio-game button, .portfolio-row button { min-height: 44px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: right; padding: 4px 0; }
</style>
@@ -0,0 +1,282 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import PrimaryButton from '@/components/common/PrimaryButton.vue'
import { illustrations } from '@/assets/illustrations'
import { ApiClientError, ApiNetworkError, StaleSessionError } from '@/api/errors'
import { runMeMutation } from '@/api/sync'
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
import { offerOutcomeLabels, riskSubmission, setRiskAnswer } from '@/modules/gold-land/risk-return/riskChoices'
import type { CompleteLevelResponse, LevelItem, RiskReturnContent } from '@/modules/gold-land/types/goldLand.types'
const props = defineProps<{ levelId: number }>()
const emit = defineEmits<{
backToMap: [focusLevel?: number]
nextLevel: [levelId: number]
}>()
type Phase = 'intro' | 'compare' | 'questions' | 'review' | 'result'
const loading = ref(true)
const error = ref('')
const submitError = ref('')
const submitting = ref(false)
const phase = ref<Phase>('intro')
const mission = ref<RiskReturnContent | null>(null)
const answers = ref<Record<string, string>>({})
const questionIndex = ref(0)
const result = ref<CompleteLevelResponse | null>(null)
const levels = ref<LevelItem[]>([])
const questions = computed(() => mission.value?.questions ?? [])
const currentQuestion = computed(() => questions.value[questionIndex.value])
const guideImage = computed(() => illustrations.professor)
const riskFeedback = computed(() => {
const feedback = result.value?.feedback
if (!feedback || !('questions' in feedback) || !('summary' in feedback) || 'scenarios' in feedback || 'timeline' in feedback) return null
return feedback
})
const nextPlayable = computed(() => {
if (!result.value?.unlockedLevelId) return null
const next = levels.value.find((level) => level.id === result.value?.unlockedLevelId)
if (!next || next.status === 'locked' || next.status === 'coming_soon') return null
return next
})
function choose(questionId: string, optionId: string) {
answers.value = setRiskAnswer(answers.value, questionId, optionId)
}
function selectedLabel(questionId: string) {
const question = questions.value.find((item) => item.id === questionId)
const optionId = answers.value[questionId]
return question?.options.find((option) => option.id === optionId)?.label ?? 'هنوز انتخاب نشده'
}
async function loadMission() {
loading.value = true
error.value = ''
submitError.value = ''
result.value = null
phase.value = 'intro'
try {
const detail = await goldLandApi.getLevel(props.levelId)
if (detail.level.status === 'locked') {
error.value = 'این مرحله هنوز قفل است.'
return
}
if (detail.level.status === 'coming_soon') {
error.value = 'این مرحله به‌زودی باز می‌شود.'
return
}
if (detail.activity?.type !== 'risk-return' || !detail.activity.riskReturn) {
error.value = 'محتوای این مرحله در دسترس نیست.'
return
}
await goldLandApi.startLevel(props.levelId)
mission.value = detail.activity.riskReturn
answers.value = {}
} catch (err) {
error.value = err instanceof Error ? err.message : 'خطا در دریافت چالش'
} finally {
loading.value = false
}
}
function goReview() {
if (questions.value.some((question) => !answers.value[question.id])) {
submitError.value = 'اول به هر پنج فعالیت پاسخ بده.'
return
}
submitError.value = ''
phase.value = 'review'
}
async function submitChallenge() {
if (submitting.value || !mission.value) return
submitting.value = true
submitError.value = ''
try {
const response = await runMeMutation(
() =>
goldLandApi.completeLevel(
props.levelId,
riskSubmission(questions.value.map((question) => question.id), answers.value),
),
(value) => value.me,
)
result.value = response
phase.value = 'result'
levels.value = (await goldLandApi.getProgress()).levels
} catch (err) {
if (err instanceof StaleSessionError) return
submitError.value =
err instanceof ApiNetworkError
? 'اتصال برقرار نشد. پاسخ‌هایت همین‌جا مانده. دوباره بفرست.'
: err instanceof ApiClientError
? err.message
: err instanceof Error
? err.message
: 'ارسال نتیجه ناموفق بود'
} finally {
submitting.value = false
}
}
onMounted(loadMission)
</script>
<template>
<div class="risk-challenge">
<p v-if="loading">در حال بارگذاری چالش...</p>
<template v-else-if="error">
<p role="alert">{{ error }}</p>
<PrimaryButton variant="purple" full-width @click="loadMission">تلاش دوباره</PrimaryButton>
</template>
<template v-else-if="mission && phase === 'intro'">
<img :src="guideImage" alt="" class="risk-challenge__guide" />
<p class="risk-challenge__guide-name">{{ mission.guideTitle }}</p>
<h2>{{ mission.title }}</h2>
<p>{{ mission.intro }}</p>
<PrimaryButton variant="gold" full-width @click="phase = 'compare'">دیدن دو پیشنهاد</PrimaryButton>
</template>
<template v-else-if="mission && phase === 'compare'">
<h2>دو پیشنهاد خیالی برای {{ mission.stakeCoins }} سکه</h2>
<p>{{ mission.disclaimer }}</p>
<p>{{ mission.exampleLimit }}</p>
<section v-for="offer in mission.offers" :key="offer.id" class="risk-offer">
<h3>{{ offer.label }}</h3>
<p>چهار نتیجهٔ هم‌احتمال. شانس هر کارت در این مثال یکسان است.</p>
<ul>
<li v-for="label in offerOutcomeLabels(offer.label, offer.outcomes)" :key="label">
<span>{{ label }}</span>
</li>
</ul>
</section>
<PrimaryButton variant="purple" full-width @click="phase = 'questions'">رفتن به فعالیت‌ها</PrimaryButton>
</template>
<template v-else-if="mission && phase === 'questions' && currentQuestion">
<p>فعالیت {{ questionIndex + 1 }} از {{ questions.length }}</p>
<fieldset>
<legend>{{ currentQuestion.prompt }}</legend>
<label v-for="option in currentQuestion.options" :key="option.id" class="risk-option">
<input
type="radio"
:name="currentQuestion.id"
:value="option.id"
:checked="answers[currentQuestion.id] === option.id"
@change="choose(currentQuestion.id, option.id)"
/>
<span>{{ option.label }}</span>
</label>
</fieldset>
<div class="risk-challenge__nav">
<PrimaryButton variant="purple" :disabled="questionIndex === 0" @click="questionIndex -= 1">قبلی</PrimaryButton>
<PrimaryButton
v-if="questionIndex < questions.length - 1"
variant="gold"
:disabled="!answers[currentQuestion.id]"
@click="questionIndex += 1"
>
بعدی
</PrimaryButton>
<PrimaryButton v-else variant="gold" :disabled="!answers[currentQuestion.id]" @click="goReview">
مرور پاسخ‌ها
</PrimaryButton>
</div>
<p v-if="submitError" role="alert">{{ submitError }}</p>
</template>
<template v-else-if="mission && phase === 'review'">
<h2>مرور پاسخ‌ها</h2>
<p>قبل از ارسال می‌توانی هر پاسخ را عوض کنی. امتیاز از نتیجهٔ شانسی نمی‌آید.</p>
<article v-for="(question, index) in questions" :key="question.id">
<p>{{ question.prompt }}</p>
<p>انتخاب تو: {{ selectedLabel(question.id) }}</p>
<button type="button" @click="questionIndex = index; phase = 'questions'">تغییر این پاسخ</button>
</article>
<PrimaryButton variant="gold" full-width :disabled="submitting" @click="submitChallenge">
{{ submitting ? 'در حال ارسال...' : 'ثبت نتیجه' }}
</PrimaryButton>
<p v-if="submitError" role="alert">{{ submitError }}</p>
<PrimaryButton v-if="submitError" variant="purple" full-width :disabled="submitting" @click="submitChallenge">
ارسال دوباره
</PrimaryButton>
</template>
<template v-else-if="result && riskFeedback">
<h2>نتیجهٔ چالش</h2>
<p>امتیاز {{ result.score }} از ۱۰۰. ستاره: {{ result.stars }} از ۳.</p>
<p v-if="result.reward.xp > 0 || result.reward.coins > 0">
پاداش: {{ result.reward.xp }} تجربه و {{ result.reward.coins }} سکه.
</p>
<p v-else>این بار پاداش تازه‌ای اضافه نشد.</p>
<ul>
<li v-for="item in riskFeedback.questions" :key="item.id">
{{ item.earned ? 'درست' : 'قابل بهتر شدن' }}: {{ item.explanation }}
</li>
</ul>
<p>{{ riskFeedback.summary }}</p>
<PrimaryButton variant="gold" full-width @click="emit('backToMap', result.unlockedLevelId ?? props.levelId)">
بازگشت به نقشه
</PrimaryButton>
<PrimaryButton v-if="nextPlayable" variant="purple" full-width @click="emit('nextLevel', nextPlayable.id)">
رفتن به مرحله بعد
</PrimaryButton>
</template>
</div>
</template>
<style scoped>
.risk-challenge {
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 24px;
}
.risk-challenge p,
.risk-challenge li,
.risk-challenge legend {
line-height: 1.8;
}
.risk-challenge__guide {
width: 96px;
height: 96px;
object-fit: contain;
align-self: center;
}
.risk-challenge__guide-name {
text-align: center;
font-weight: 800;
margin: 0;
}
.risk-offer,
.risk-option,
article {
border: 1px solid rgba(118, 75, 190, 0.35);
border-radius: var(--radius-lg);
padding: 12px;
}
.risk-option {
display: flex;
gap: 8px;
align-items: flex-start;
margin-top: 8px;
}
.risk-challenge__nav {
display: flex;
gap: 8px;
}
.risk-challenge button {
min-height: 44px;
}
</style>
@@ -0,0 +1,202 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import PrimaryButton from '@/components/common/PrimaryButton.vue'
import { illustrations } from '@/assets/illustrations'
import { ApiClientError, ApiNetworkError, StaleSessionError } from '@/api/errors'
import { runMeMutation } from '@/api/sync'
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
import { moneyText, setShopAnswer } from '@/modules/gold-land/smart-shop/shopText'
import type { CompleteLevelResponse, LevelItem, SmartShopContent } from '@/modules/gold-land/types/goldLand.types'
const props = defineProps<{ levelId: number }>()
const emit = defineEmits<{ backToMap: [focusLevel?: number]; nextLevel: [levelId: number] }>()
type Phase = 'intro' | 'mission' | 'review' | 'result'
const loading = ref(true)
const error = ref('')
const submitError = ref('')
const submitting = ref(false)
const phase = ref<Phase>('intro')
const missionIndex = ref(0)
const shop = ref<SmartShopContent | null>(null)
const answers = ref<Record<string, string>>({})
const result = ref<CompleteLevelResponse | null>(null)
const levels = ref<LevelItem[]>([])
const mission = computed(() => shop.value?.missions[missionIndex.value] ?? null)
const feedback = computed(() => {
const value = result.value?.feedback
if (!value || !('purchases' in value)) return null
return value
})
const nextPlayable = computed(() => {
if (!result.value?.unlockedLevelId) return null
const next = levels.value.find((level) => level.id === result.value?.unlockedLevelId)
if (!next || next.status === 'locked' || next.status === 'coming_soon') return null
return next
})
function choose(questionId: string, optionId: string) {
answers.value = setShopAnswer(answers.value, questionId, optionId)
}
function nextMission() {
if (!mission.value) return
if (!answers.value[mission.value.choice.id] || !answers.value[mission.value.reason.id]) {
submitError.value = 'اول انتخاب و دلیل این مأموریت را مشخص کن.'
return
}
submitError.value = ''
if (missionIndex.value >= (shop.value?.missions.length ?? 1) - 1) {
phase.value = 'review'
return
}
missionIndex.value += 1
}
async function loadShop() {
loading.value = true
error.value = ''
result.value = null
phase.value = 'intro'
try {
const detail = await goldLandApi.getLevel(props.levelId)
if (detail.level.status === 'locked') {
error.value = 'این مرحله هنوز قفل است.'
return
}
if (detail.level.status === 'coming_soon') {
error.value = 'این مرحله به‌زودی باز می‌شود.'
return
}
if (detail.activity?.type !== 'smart-shop' || !detail.activity.smartShop) {
error.value = 'محتوای این مرحله در دسترس نیست.'
return
}
await goldLandApi.startLevel(props.levelId)
shop.value = detail.activity.smartShop
} catch (err) {
error.value = err instanceof Error ? err.message : 'خطا در دریافت بازی'
} finally {
loading.value = false
}
}
async function submitShop() {
if (submitting.value || !shop.value) return
submitting.value = true
submitError.value = ''
try {
const response = await runMeMutation(
() => goldLandApi.completeLevel(
props.levelId,
shop.value!.missions.flatMap((item) => [
{ questionId: item.choice.id, answerId: answers.value[item.choice.id] ?? '' },
{ questionId: item.reason.id, answerId: answers.value[item.reason.id] ?? '' },
]),
),
(value) => value.me,
)
result.value = response
phase.value = 'result'
levels.value = (await goldLandApi.getProgress()).levels
} catch (err) {
if (err instanceof StaleSessionError) return
submitError.value = err instanceof ApiNetworkError
? 'اتصال برقرار نشد. انتخاب‌ها حفظ شده‌اند. دوباره بفرست.'
: err instanceof ApiClientError
? err.message
: err instanceof Error
? err.message
: 'ارسال نتیجه ناموفق بود'
} finally {
submitting.value = false
}
}
onMounted(loadShop)
</script>
<template>
<div class="shop-game">
<p v-if="loading">در حال بارگذاری بازی...</p>
<template v-else-if="error">
<p role="alert">{{ error }}</p>
<PrimaryButton variant="purple" full-width @click="loadShop">تلاش دوباره</PrimaryButton>
</template>
<template v-else-if="shop && phase === 'intro'">
<img :src="illustrations.professor" alt="" class="shop-game__guide" />
<p class="shop-game__guide-name">{{ shop.guideTitle }}</p>
<h2>{{ shop.title }}</h2>
<p>{{ shop.intro }}</p>
<ul>
<li v-for="rule in shop.rules" :key="rule">{{ rule }}</li>
</ul>
<PrimaryButton variant="gold" full-width @click="phase = 'mission'">شروع خریدها</PrimaryButton>
</template>
<template v-else-if="shop && mission && phase === 'mission'">
<h2>{{ mission.title }}</h2>
<p>نیاز: {{ mission.need }}</p>
<p>بودجه: {{ moneyText(mission.budget) }}</p>
<article v-for="card in mission.cards" :key="card.id" class="shop-card">
<h3>{{ card.label }}</h3>
<p v-for="fact in card.facts" :key="fact">{{ fact }}</p>
<p v-if="card.previousPrice !== null">قیمت قبلی: {{ moneyText(card.previousPrice) }}</p>
<p v-if="card.discountText">تخفیف: {{ card.discountText }}</p>
<p>قیمت: {{ moneyText(card.price) }}</p>
<p>تحویل: {{ card.delivery === 0 ? 'رایگان' : moneyText(card.delivery) }}</p>
<p>جمع نهایی: {{ moneyText(card.finalCost) }}</p>
</article>
<fieldset>
<legend>{{ mission.choice.prompt }}</legend>
<label v-for="option in mission.choice.options" :key="option.id">
<input type="radio" :name="mission.choice.id" :value="option.id" :checked="answers[mission.choice.id] === option.id" @change="choose(mission.choice.id, option.id)" />
<span>{{ option.label }}</span>
</label>
</fieldset>
<fieldset>
<legend>{{ mission.reason.prompt }}</legend>
<label v-for="option in mission.reason.options" :key="option.id">
<input type="radio" :name="mission.reason.id" :value="option.id" :checked="answers[mission.reason.id] === option.id" @change="choose(mission.reason.id, option.id)" />
<span>{{ option.label }}</span>
</label>
</fieldset>
<p v-if="submitError" role="alert">{{ submitError }}</p>
<PrimaryButton variant="gold" full-width @click="nextMission">{{ missionIndex === shop.missions.length - 1 ? 'مرور انتخاب‌ها' : 'مأموریت بعد' }}</PrimaryButton>
</template>
<template v-else-if="shop && phase === 'review'">
<h2>مرور خریدها</h2>
<article v-for="(item, index) in shop.missions" :key="item.id">
<h3>{{ item.title }}</h3>
<p>انتخاب: {{ item.choice.options.find((option) => option.id === answers[item.choice.id])?.label }}</p>
<p>دلیل: {{ item.reason.options.find((option) => option.id === answers[item.reason.id])?.label }}</p>
<button type="button" @click="missionIndex = index; phase = 'mission'">تغییر این مأموریت</button>
</article>
<PrimaryButton variant="gold" full-width :disabled="submitting" @click="submitShop">{{ submitting ? 'در حال ارسال...' : 'ثبت نتیجه' }}</PrimaryButton>
<p v-if="submitError" role="alert">{{ submitError }}</p>
<PrimaryButton v-if="submitError" variant="purple" full-width :disabled="submitting" @click="submitShop">ارسال دوباره</PrimaryButton>
</template>
<template v-else-if="result && feedback">
<h2>نتیجه</h2>
<p>امتیاز {{ result.score }} از ۱۰۰. ستاره: {{ result.stars }} از ۳.</p>
<p v-if="result.reward.xp > 0 || result.reward.coins > 0">پاداش: {{ result.reward.xp }} تجربه و {{ result.reward.coins }} سکه.</p>
<p v-else>این بار پاداش تازه‌ای اضافه نشد.</p>
<ul>
<li v-for="item in feedback.purchases" :key="item.id">{{ item.earned ? 'درست' : 'قابل بهتر شدن' }}: {{ item.explanation }}</li>
</ul>
<p>{{ feedback.summary }}</p>
<PrimaryButton variant="gold" full-width @click="emit('backToMap', result.unlockedLevelId ?? props.levelId)">بازگشت به نقشه</PrimaryButton>
<PrimaryButton v-if="nextPlayable" variant="purple" full-width @click="emit('nextLevel', nextPlayable.id)">رفتن به مرحله بعد</PrimaryButton>
</template>
</div>
</template>
<style scoped>
.shop-game { display: flex; flex-direction: column; gap: 12px; padding-bottom: 24px; }
.shop-game p, .shop-game li, .shop-game legend { line-height: 1.8; }
.shop-game__guide { width: 96px; height: 96px; object-fit: contain; align-self: center; }
.shop-game__guide-name { text-align: center; font-weight: 800; margin: 0; }
.shop-card, article, label { border: 1px solid rgba(118, 75, 190, 0.35); border-radius: var(--radius-lg); padding: 12px; }
label { display: flex; gap: 8px; margin-top: 8px; }
button, .shop-card { min-height: 44px; }
</style>
@@ -0,0 +1,18 @@
export function initialPlan(current: number, goal: number, weekly: number, income: number, essential: number) {
const remaining = goal - current
const weeks = Math.ceil(remaining / weekly)
const totalSaved = current + weeks * weekly
return { remaining, weeks, totalSaved, extra: totalSaved - goal, optional: income - essential - weekly }
}
export function revisedPlan(current: number, goal: number, initialWeekly: number, exceptionWeekly: number, continueWeekly: number) {
const balanceAfterTwo = current + initialWeekly + exceptionWeekly
const gap = Math.max(0, goal - 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 }
}
export function clonePlan<T>(plan: T): T {
return JSON.parse(JSON.stringify(plan)) as T
}
@@ -0,0 +1,21 @@
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
export function formatCoinTenths(tenths: number): string {
const abs = Math.abs(Math.trunc(tenths))
const whole = Math.floor(abs / 10)
const fraction = abs % 10
const plain = fraction === 0 ? String(whole) : `${whole}.${fraction}`
return plain.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] ?? digit).replace('.', '٫')
}
export function revealPeriods<T>(periods: T[], shown: number): T[] {
return periods.slice(0, Math.max(0, Math.min(shown, periods.length)))
}
export function nextShown(shown: number, total: number): number {
return Math.min(total, shown + 1)
}
export function previousShown(shown: number): number {
return Math.max(1, shown - 1)
}
@@ -0,0 +1,82 @@
export interface FundAmounts {
sun: number
rain: number
wind: number
}
export type FundId = keyof FundAmounts
export function emptyFunds(): FundAmounts {
return { sun: 0, rain: 0, wind: 0 }
}
export function fundSum(amounts: FundAmounts): number {
return amounts.sun + amounts.rain + amounts.wind
}
export function remainingCoins(total: number, amounts: FundAmounts): number {
return total - fundSum(amounts)
}
export function canIncreaseFund(amounts: FundAmounts, step: number, total: number): boolean {
return fundSum(amounts) + step <= total
}
export function canDecreaseFund(amounts: FundAmounts, id: FundId, step: number): boolean {
return amounts[id] - step >= 0
}
export function adjustFund(amounts: FundAmounts, id: FundId, delta: number, step: number, total: number): FundAmounts {
if (delta === 0 || delta % step !== 0) return amounts
const next = amounts[id] + delta
if (next < 0 || fundSum(amounts) + delta > total) return amounts
return { ...amounts, [id]: next }
}
export function applyPercent(amount: number, percent: number): number {
return (amount * (100 + percent)) / 100
}
export function changeLabel(total: number, baseline = 100): string {
const delta = total - baseline
if (delta > 0) return `سود ${delta} سکه`
if (delta < 0) return `زیان ${Math.abs(delta)} سکه`
return 'بدون تغییر'
}
export interface ScenarioRule {
id: string
title: string
note: string
changes: Array<{ fundId: FundId; percent: number }>
}
export function previewScenarios(
amounts: FundAmounts,
scenarios: ScenarioRule[],
funds: Array<{ id: FundId; label: string }>,
) {
const reference: FundAmounts = { sun: 100, rain: 0, wind: 0 }
return scenarios.map((scenario) => {
const percentOf = (id: FundId) => scenario.changes.find((change) => change.fundId === id)?.percent ?? 0
const parts = funds.map((fund) => ({
label: fund.label,
value: applyPercent(amounts[fund.id], percentOf(fund.id)),
}))
const total = parts.reduce((sum, part) => sum + part.value, 0)
const referenceSunTotal = (['sun', 'rain', 'wind'] as FundId[]).reduce(
(sum, id) => sum + applyPercent(reference[id], percentOf(id)),
0,
)
return {
id: scenario.id,
title: scenario.title,
note: scenario.note,
parts,
total,
changeLabel: changeLabel(total),
referenceSunTotal,
referenceChangeLabel: changeLabel(referenceSunTotal),
}
})
}
@@ -0,0 +1,18 @@
export interface RiskChoiceMap {
[questionId: string]: string
}
export function setRiskAnswer(answers: RiskChoiceMap, questionId: string, optionId: string): RiskChoiceMap {
return { ...answers, [questionId]: optionId }
}
export function riskSubmission(questionIds: string[], answers: RiskChoiceMap) {
return questionIds.map((questionId) => ({
questionId,
answerId: answers[questionId] ?? '',
}))
}
export function offerOutcomeLabels(label: string, outcomes: number[]) {
return outcomes.map((coins, index) => `${label}، کارت ${index + 1} از ${outcomes.length}: ${coins} سکه`)
}
@@ -0,0 +1,10 @@
import { formatNumber } from '@/utils/storage'
export function moneyText(value: number | null): string {
if (value === null) return 'نامشخص'
return `${formatNumber(value)} سکه`
}
export function setShopAnswer(answers: Record<string, string>, questionId: string, optionId: string): Record<string, string> {
return { ...answers, [questionId]: optionId }
}
+243 -2
View File
@@ -21,7 +21,7 @@ export interface LevelItem {
rewardCoins: number rewardCoins: number
} }
export type LessonStepType = 'story' | 'single_choice' | 'true_false' | 'sort' | 'summary' export type LessonStepType = 'story' | 'single_choice' | 'true_false' | 'sort' | 'summary' | 'growth_table'
export interface LessonOption { export interface LessonOption {
id: string id: string
@@ -67,12 +67,35 @@ export interface SummaryStep {
highlight?: string highlight?: string
} }
export interface GrowthTableStep {
id: string
type: 'growth_table'
title?: string
body: string
disclaimer: string
unitLabel: string
principalTenths: number
ratePercent: number
methods: Array<{ id: string; label: string; note: string }>
periods: Array<{
index: number
rows: Array<{
methodId: string
label: string
startTenths: number
growthTenths: number
endTenths: number
}>
}>
}
export type SanitizedLessonStep = export type SanitizedLessonStep =
| StoryStep | StoryStep
| SingleChoiceStep | SingleChoiceStep
| TrueFalseStep | TrueFalseStep
| SortStep | SortStep
| SummaryStep | SummaryStep
| GrowthTableStep
export interface SanitizedLesson { export interface SanitizedLesson {
id: string id: string
@@ -99,6 +122,12 @@ export interface LevelDetailResponse {
activity: { activity: {
type: string type: string
lesson?: SanitizedLesson lesson?: SanitizedLesson
budgeting?: BudgetMissionContent
riskReturn?: RiskReturnContent
portfolio?: PortfolioMissionContent
smartShop?: SmartShopContent
financialGoal?: FinancialGoalContent
castleExam?: CastleExamContent
} | null } | null
} }
@@ -119,6 +148,193 @@ export interface CompleteLevelResponse {
reward: { xp: number; coins: number } reward: { xp: number; coins: number }
unlockedLevelId: number | null unlockedLevelId: number | null
me: import('@/api/client').MeResponse me: import('@/api/client').MeResponse
feedback?:
| {
plans: Array<{
phase: 'initial' | 'final'
title: string
criteria: Array<{ id: string; label: string; earned: boolean; detail: string }>
}>
}
| {
summary: string
questions: Array<{ id: string; earned: boolean; explanation: string }>
}
| {
summary: string
scenarios: Array<{
id: string
title: string
note: string
parts: Array<{ fundId: string; label: string; value: number }>
total: number
changeLabel: string
referenceSunTotal: number
referenceChangeLabel: string
}>
questions: Array<{ id: string; earned: boolean; explanation: string }>
}
| {
questions: Array<{ id: string; earned: boolean; explanation: string }>
}
| {
summary: string
purchases: Array<{ id: string; earned: boolean; explanation: string }>
}
| {
summary: string
timeline: {
initialWeeks: number
balanceAfterTwo: number
remainingWeeks: number
totalWeeks: number
finalBalance: number
}
questions: Array<{ id: string; earned: boolean; explanation: string }>
}
| {
summary: string
completed: boolean
questions: Array<{
id: string
sourceLevelId: number
earned: boolean
selectedAnswerId: string
explanation: string
}>
}
}
export interface PortfolioMissionContent {
id: string
version: number
title: string
guideTitle: string
guideImageKey: string
totalCoins: number
step: number
intro: string
rules: string[]
funds: Array<{ id: 'sun' | 'rain' | 'wind'; label: string; hint: string }>
scenarios: Array<{
id: string
title: string
note: string
changes: Array<{ fundId: 'sun' | 'rain' | 'wind'; percent: number }>
}>
questions: Array<{ id: string; prompt: string; options: Array<{ id: string; label: string }> }>
summary: string
}
export interface FinancialGoalContent {
id: string
version: number
title: string
guideTitle: string
guideImageKey: string
intro: string
rules: string[]
eventMessage: string
plan: {
goalPrice: number
currentSavings: number
weeklyIncome: number
normalEssential: number
exceptionEssential: number
initialRates: number[]
exceptionRates: number[]
continueRates: number[]
}
questions: Array<{ id: string; prompt: string; options: Array<{ id: string; label: string }> }>
summary: string
}
export interface CastleExamContent {
id: string
version: number
title: string
guideTitle: string
guideImageKey: string
intro: string
sections: Array<{ id: string; title: string }>
questions: Array<{
id: string
sourceLevelId: number
sectionId: string
sectionTitle: string
prompt: string
options: Array<{ id: string; label: string }>
}>
}
export interface SmartShopContent {
id: string
version: number
title: string
guideTitle: string
guideImageKey: string
intro: string
rules: string[]
missions: Array<{
id: string
title: string
need: string
budget: number
cards: Array<{
id: string
label: string
facts: string[]
price: number | null
delivery: number | null
discountText: string | null
previousPrice: number | null
finalCost: number | null
}>
choice: { id: string; prompt: string; options: Array<{ id: string; label: string }> }
reason: { id: string; prompt: string; options: Array<{ id: string; label: string }> }
}>
summary: string
}
export interface RiskReturnContent {
id: string
version: number
title: string
guideTitle: string
guideImageKey: string
stakeCoins: number
intro: string
disclaimer: string
exampleLimit: string
summary: string
offers: Array<{ id: 'a' | 'b'; label: string; outcomes: number[] }>
questions: Array<{
id: string
prompt: string
options: Array<{ id: string; label: string }>
}>
}
export interface BudgetMissionContent {
id: string
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: 'transport' | 'food' | 'savings' | 'fun'
label: string
hint: string
requiredInitial: number | null
requiredFinal: number | null
}>
} }
export interface ChoiceLessonAnswer { export interface ChoiceLessonAnswer {
@@ -131,7 +347,32 @@ export interface SortLessonAnswer {
orderedItemIds: string[] orderedItemIds: string[]
} }
export type LessonSubmissionAnswer = ChoiceLessonAnswer | SortLessonAnswer export interface BudgetLevelAnswer {
questionId: 'initial' | 'final'
allocations: {
transport: number
food: number
savings: number
fun: number
}
}
export interface PortfolioLevelAnswer {
questionId: 'portfolio'
holdings: { sun: number; rain: number; wind: number }
}
export interface GoalLevelAnswer {
questionId: 'plan'
plan: { initialWeekly: number; exceptionWeekly: number; continueWeekly: number }
}
export type LessonSubmissionAnswer =
| ChoiceLessonAnswer
| SortLessonAnswer
| BudgetLevelAnswer
| PortfolioLevelAnswer
| GoalLevelAnswer
/** @deprecated Use LessonSubmissionAnswer */ /** @deprecated Use LessonSubmissionAnswer */
export interface LessonAnswer { export interface LessonAnswer {
+3
View File
@@ -153,6 +153,9 @@ onActivated(loadProgress)
> >
<template v-if="selectedLevel?.status === 'coming_soon'">به‌زودی</template> <template v-if="selectedLevel?.status === 'coming_soon'">به‌زودی</template>
<template v-else-if="isLevelCompleted(selectedLevel!)">مرور مجدد</template> <template v-else-if="isLevelCompleted(selectedLevel!)">مرور مجدد</template>
<template v-else-if="selectedLevel?.activityType === 'game'">شروع بازی</template>
<template v-else-if="selectedLevel?.activityType === 'challenge'">شروع چالش</template>
<template v-else-if="selectedLevel?.activityType === 'final_exam'">شروع آزمون</template>
<template v-else>شروع درس</template> <template v-else>شروع درس</template>
</PrimaryButton> </PrimaryButton>
</template> </template>
+90 -1
View File
@@ -4,6 +4,12 @@ import { useRoute, useRouter } from 'vue-router'
import AppHeader from '@/components/common/AppHeader.vue' import AppHeader from '@/components/common/AppHeader.vue'
import PrimaryButton from '@/components/common/PrimaryButton.vue' import PrimaryButton from '@/components/common/PrimaryButton.vue'
import LessonPlayer from '@/modules/gold-land/components/lesson/LessonPlayer.vue' import LessonPlayer from '@/modules/gold-land/components/lesson/LessonPlayer.vue'
import BudgetMissionPlayer from '@/modules/gold-land/components/budget/BudgetMissionPlayer.vue'
import RiskReturnPlayer from '@/modules/gold-land/components/risk-return/RiskReturnPlayer.vue'
import PortfolioPlayer from '@/modules/gold-land/components/portfolio/PortfolioPlayer.vue'
import SmartShopPlayer from '@/modules/gold-land/components/smart-shop/SmartShopPlayer.vue'
import FinancialGoalPlayer from '@/modules/gold-land/components/financial-goal/FinancialGoalPlayer.vue'
import CastleExamPlayer from '@/modules/gold-land/components/castle-exam/CastleExamPlayer.vue'
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api' import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
const route = useRoute() const route = useRoute()
@@ -13,6 +19,12 @@ const loading = ref(true)
const error = ref('') const error = ref('')
const levelDetail = ref<Awaited<ReturnType<typeof goldLandApi.getLevel>> | null>(null) const levelDetail = ref<Awaited<ReturnType<typeof goldLandApi.getLevel>> | null>(null)
const showPlayer = ref(false) const showPlayer = ref(false)
const showBudget = ref(false)
const showRisk = ref(false)
const showPortfolio = ref(false)
const showShop = ref(false)
const showGoal = ref(false)
const showCastle = ref(false)
const levelId = computed(() => Number(route.params.levelId)) const levelId = computed(() => Number(route.params.levelId))
const level = computed(() => levelDetail.value?.level ?? null) const level = computed(() => levelDetail.value?.level ?? null)
@@ -21,6 +33,12 @@ async function loadLevel() {
loading.value = true loading.value = true
error.value = '' error.value = ''
showPlayer.value = false showPlayer.value = false
showBudget.value = false
showRisk.value = false
showPortfolio.value = false
showShop.value = false
showGoal.value = false
showCastle.value = false
try { try {
levelDetail.value = await goldLandApi.getLevel(levelId.value) levelDetail.value = await goldLandApi.getLevel(levelId.value)
@@ -34,10 +52,34 @@ async function loadLevel() {
error.value = 'این مرحله به‌زودی باز می‌شود.' error.value = 'این مرحله به‌زودی باز می‌شود.'
return return
} }
if (item.activityType === 'lesson' && levelDetail.value.activity?.lesson) { if (levelDetail.value.activity?.type === 'lesson' && levelDetail.value.activity.lesson) {
showPlayer.value = true showPlayer.value = true
return return
} }
if (levelDetail.value.activity?.type === 'budgeting' && levelDetail.value.activity.budgeting) {
showBudget.value = true
return
}
if (levelDetail.value.activity?.type === 'risk-return' && levelDetail.value.activity.riskReturn) {
showRisk.value = true
return
}
if (levelDetail.value.activity?.type === 'portfolio' && levelDetail.value.activity.portfolio) {
showPortfolio.value = true
return
}
if (levelDetail.value.activity?.type === 'smart-shop' && levelDetail.value.activity.smartShop) {
showShop.value = true
return
}
if (levelDetail.value.activity?.type === 'financial-goal' && levelDetail.value.activity.financialGoal) {
showGoal.value = true
return
}
if (levelDetail.value.activity?.type === 'castle-exam' && levelDetail.value.activity.castleExam) {
showCastle.value = true
return
}
if (item.activityRoute) { if (item.activityRoute) {
router.replace(item.activityRoute) router.replace(item.activityRoute)
return return
@@ -94,6 +136,53 @@ watch(levelId, () => {
@back-to-map="goToMap" @back-to-map="goToMap"
@next-level="goToNextLevel" @next-level="goToNextLevel"
/> />
<BudgetMissionPlayer
v-else-if="showBudget"
:key="`budget-${levelId}`"
:level-id="levelId"
@back-to-map="goToMap"
@next-level="goToNextLevel"
/>
<RiskReturnPlayer
v-else-if="showRisk"
:key="`risk-${levelId}`"
:level-id="levelId"
@back-to-map="goToMap"
@next-level="goToNextLevel"
/>
<PortfolioPlayer
v-else-if="showPortfolio"
:key="`portfolio-${levelId}`"
:level-id="levelId"
@back-to-map="goToMap"
@next-level="goToNextLevel"
/>
<SmartShopPlayer
v-else-if="showShop"
:key="`shop-${levelId}`"
:level-id="levelId"
@back-to-map="goToMap"
@next-level="goToNextLevel"
/>
<FinancialGoalPlayer
v-else-if="showGoal"
:key="`goal-${levelId}`"
:level-id="levelId"
@back-to-map="goToMap"
@next-level="goToNextLevel"
/>
<CastleExamPlayer
v-else-if="showCastle"
:key="`castle-${levelId}`"
:level-id="levelId"
@back-to-map="goToMap"
/>
</div> </div>
</template> </template>
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
adjustBudget,
budgetReserve,
canDecrease,
canIncrease,
budgetSubmission,
cloneBudget,
emptyBudget,
missingEssentials,
} from '../src/modules/gold-land/budgeting/budgetMath'
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
import { invalidateSession } from '../src/api/authSession'
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
const categories = [
{ id: 'transport' as const, label: 'رفت‌وآمد', requiredInitial: 20, requiredFinal: 30 },
{ id: 'food' as const, label: 'آب و غذا', requiredInitial: 25, requiredFinal: 25 },
{ id: 'savings' as const, label: 'پس‌انداز', requiredInitial: null, requiredFinal: null },
{ id: 'fun' as const, label: 'تفریح', requiredInitial: null, requiredFinal: null },
]
test('reserve and button limits stay inside 100 coins', () => {
let amounts = emptyBudget()
assert.equal(budgetReserve(100, amounts), 100)
assert.equal(canDecrease(amounts, 'fun', 5), false)
for (let i = 0; i < 20; i += 1) {
amounts = adjustBudget(amounts, 'fun', 5, 5, 100)
}
assert.equal(budgetReserve(100, amounts), 0)
assert.equal(canIncrease(amounts, 5, 100), false)
const unchanged = adjustBudget(amounts, 'food', 5, 5, 100)
assert.equal(unchanged.food, 0)
assert.equal(unchanged.fun, 100)
})
test('editing the final budget does not change the submitted initial allocations', () => {
const initial = { transport: 20, food: 25, savings: 20, fun: 0 }
const finalPlan = cloneBudget(initial)
const payload = budgetSubmission(initial, finalPlan)
finalPlan.transport += 10
finalPlan.fun = 15
assert.deepEqual(payload[0]?.allocations, { transport: 20, food: 25, savings: 20, fun: 0 })
assert.equal(payload[1]?.allocations.transport, 20)
assert.equal(missingEssentials(initial, categories, 'initial').length, 0)
assert.equal(missingEssentials(finalPlan, categories, 'final').some((item) => item.id === 'food'), false)
})
test('network and late session responses do not apply a new account', async () => {
resetMeMutationQueueForTests()
let applied = 0
bindMeMutationApply(() => {
applied += 1
return true
})
await assert.rejects(
() => runMeMutation(async () => {
throw new ApiNetworkError()
}),
ApiNetworkError,
)
assert.equal(applied, 0)
await assert.rejects(
() =>
runMeMutation(async () => {
invalidateSession()
return { me: { user: { coins: 999 } } }
}),
StaleSessionError,
)
assert.equal(applied, 0)
})
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
import { invalidateSession } from '../src/api/authSession'
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
import { clearExamAnswers, sectionIds, unansweredCount } from '../src/modules/gold-land/castle-exam/castleState'
const questions = [
{ id: 'castle-q1', sectionId: 'basics' },
{ id: 'castle-q2', sectionId: 'basics' },
{ id: 'castle-q5', sectionId: 'market' },
{ id: 'castle-q9', sectionId: 'choices' },
]
test('sections keep answers while moving and incomplete exams do not submit', () => {
assert.deepEqual(sectionIds(questions), ['basics', 'market', 'choices'])
let answers: Record<string, string> = { 'castle-q1': 'exchange' }
answers = { ...answers, 'castle-q5': 'no' }
assert.equal(answers['castle-q1'], 'exchange')
assert.equal(unansweredCount(questions.map((question) => question.id), answers), 2)
assert.equal(unansweredCount(questions.map((question) => question.id), answers) === 0, false)
})
test('network and session failures keep or clear exam answers without applying me', async () => {
let answers: Record<string, string> = { 'castle-q1': 'exchange' }
resetMeMutationQueueForTests()
let applied = 0
bindMeMutationApply(() => {
applied += 1
return true
})
await assert.rejects(() => runMeMutation(async () => { throw new ApiNetworkError() }), ApiNetworkError)
assert.equal(answers['castle-q1'], 'exchange')
await assert.rejects(() => runMeMutation(async () => {
invalidateSession()
answers = clearExamAnswers()
return { me: { user: { coins: 1 } } }
}), StaleSessionError)
assert.equal(applied, 0)
assert.deepEqual(answers, {})
})
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
import { invalidateSession } from '../src/api/authSession'
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
import { formatCoinTenths, nextShown, previousShown, revealPeriods } from '../src/modules/gold-land/lesson/growthTable'
const periods = [1000, 1100, 1210, 1331]
test('period reveal can move forward and back without changing amounts', () => {
const snapshot = periods.join(',')
let shown = 1
assert.deepEqual(revealPeriods(periods, shown), [1000])
shown = nextShown(shown, periods.length)
shown = nextShown(shown, periods.length)
shown = nextShown(shown, periods.length)
assert.deepEqual(revealPeriods(periods, shown), periods)
shown = previousShown(shown)
assert.equal(revealPeriods(periods, shown).at(-1), 1210)
assert.equal(periods.join(','), snapshot)
assert.equal(formatCoinTenths(1331), '۱۳۳٫۱')
assert.equal(formatCoinTenths(1210), '۱۲۱')
assert.equal(formatCoinTenths(1300), '۱۳۰')
})
test('review keeps the chosen answer and a failed submit does not apply me', async () => {
let answer = '120'
answer = '121'
resetMeMutationQueueForTests()
let applied = 0
bindMeMutationApply(() => {
applied += 1
return true
})
await assert.rejects(() => runMeMutation(async () => {
throw new ApiNetworkError()
}), ApiNetworkError)
assert.equal(answer, '121')
await assert.rejects(() => runMeMutation(async () => {
invalidateSession()
return { me: { user: { coins: 9 } } }
}), StaleSessionError)
assert.equal(applied, 0)
assert.equal(answer, '121')
})
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
import { invalidateSession } from '../src/api/authSession'
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
import { clonePlan, initialPlan, revisedPlan } from '../src/modules/gold-land/financial-goal/goalMath'
test('changing the later rate does not change the initial snapshot', () => {
const initial = initialPlan(30, 120, 15, 30, 10)
assert.equal(initial.weeks, 6)
assert.equal(initial.remaining, 90)
const snapshot = clonePlan(15)
const later = revisedPlan(30, 120, snapshot, 5, 20)
assert.equal(snapshot, 15)
assert.equal(later.balanceAfterTwo, 50)
assert.equal(later.totalWeeks, 2 + Math.ceil(70 / 20))
const paused = revisedPlan(30, 120, snapshot, 0, 15)
assert.equal(paused.balanceAfterTwo, 45)
assert.equal(snapshot, 15)
})
test('review and a failed submit keep the chosen plan', async () => {
let answers = { 'q-left': '120' }
answers = { ...answers, 'q-left': '90' }
const plan = clonePlan({ initialWeekly: 15, exceptionWeekly: 5, continueWeekly: 15 })
resetMeMutationQueueForTests()
let applied = 0
bindMeMutationApply(() => {
applied += 1
return true
})
await assert.rejects(() => runMeMutation(async () => { throw new ApiNetworkError() }), ApiNetworkError)
await assert.rejects(() => runMeMutation(async () => {
invalidateSession()
return { me: { user: { coins: 1 } } }
}), StaleSessionError)
assert.equal(applied, 0)
assert.equal(answers['q-left'], '90')
assert.equal(plan.exceptionWeekly, 5)
})
+96
View File
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
import { invalidateSession } from '../src/api/authSession'
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
import {
adjustFund,
canIncreaseFund,
changeLabel,
emptyFunds,
fundSum,
previewScenarios,
remainingCoins,
} from '../src/modules/gold-land/portfolio/portfolioMath'
import { setRiskAnswer } from '../src/modules/gold-land/risk-return/riskChoices'
const scenarios = [
{
id: 'sunny',
title: 'هوای آفتابی',
note: 'مثال',
changes: [
{ fundId: 'sun' as const, percent: 20 },
{ fundId: 'rain' as const, percent: -10 },
{ fundId: 'wind' as const, percent: 0 },
],
},
{
id: 'drop',
title: 'افت همگانی',
note: 'مثال',
changes: [
{ fundId: 'sun' as const, percent: -10 },
{ fundId: 'rain' as const, percent: -10 },
{ fundId: 'wind' as const, percent: -10 },
],
},
]
const funds = [
{ id: 'sun' as const, label: 'آفتاب' },
{ id: 'rain' as const, label: 'باران' },
{ id: 'wind' as const, label: 'باد' },
]
test('allocation stays on step 10 and cannot pass 100 or go negative', () => {
let amounts = emptyFunds()
assert.equal(remainingCoins(100, amounts), 100)
assert.equal(canIncreaseFund(amounts, 10, 100), true)
for (let index = 0; index < 4; index += 1) amounts = adjustFund(amounts, 'sun', 10, 10, 100)
amounts = adjustFund(amounts, 'rain', 10, 10, 100)
amounts = adjustFund(amounts, 'wind', 50, 10, 100)
assert.equal(fundSum(amounts), 100)
assert.equal(remainingCoins(100, amounts), 0)
assert.deepEqual(adjustFund(amounts, 'sun', 10, 10, 100), amounts)
assert.deepEqual(adjustFund(amounts, 'rain', -20, 10, 100), amounts)
const reduced = adjustFund(amounts, 'rain', -10, 10, 100)
assert.equal(reduced.rain, 0)
assert.equal(remainingCoins(100, reduced), 10)
})
test('changing the mix refreshes all shown outcomes and loss is written in words', () => {
const first = previewScenarios({ sun: 100, rain: 0, wind: 0 }, scenarios, funds)
assert.equal(first[0]?.changeLabel, 'سود 20 سکه')
const drop = first.find((item) => item.id === 'drop')
assert.equal(drop?.total, 90)
assert.equal(drop?.changeLabel, changeLabel(90))
assert.equal(drop?.changeLabel.includes('زیان'), true)
const second = previewScenarios({ sun: 40, rain: 30, wind: 30 }, scenarios, funds)
assert.equal(second[0]?.total, 105)
assert.notEqual(second[0]?.total, first[0]?.total)
})
test('review keeps answers and a network or stale session does not apply me', async () => {
let answers = setRiskAnswer({}, 'q-one', 'safe')
answers = setRiskAnswer(answers, 'q-one', 'depend')
assert.equal(answers['q-one'], 'depend')
const mix = { sun: 40, rain: 30, wind: 30 }
resetMeMutationQueueForTests()
let applied = 0
bindMeMutationApply(() => {
applied += 1
return true
})
await assert.rejects(() => runMeMutation(async () => {
throw new ApiNetworkError()
}), ApiNetworkError)
assert.equal(applied, 0)
assert.equal(mix.sun, 40)
assert.equal(answers['q-one'], 'depend')
await assert.rejects(() => runMeMutation(async () => {
invalidateSession()
return { me: { user: { coins: 1 } } }
}), StaleSessionError)
assert.equal(applied, 0)
assert.equal(answers['q-one'], 'depend')
})
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
import { invalidateSession } from '../src/api/authSession'
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
import { offerOutcomeLabels, riskSubmission, setRiskAnswer } from '../src/modules/gold-land/risk-return/riskChoices'
const offers = [
{ label: 'پیشنهاد الف', outcomes: [20, 20, 22, 22] },
{ label: 'پیشنهاد ب', outcomes: [8, 16, 28, 36] },
]
test('both offers show all four outcomes in text', () => {
const labels = offers.flatMap((offer) => offerOutcomeLabels(offer.label, offer.outcomes))
assert.equal(labels.length, 8)
assert.equal(labels.filter((label) => label.includes('20 سکه')).length, 2)
assert.equal(labels.some((label) => label.includes('کارت 1 از 4: 8 سکه')), true)
assert.equal(labels.some((label) => label.includes('36 سکه')), true)
assert.equal(labels.some((label) => label.includes('22 سکه')), true)
})
test('review can change an answer before submit', () => {
let answers = setRiskAnswer({}, 'q-loss', 'a')
answers = setRiskAnswer(answers, 'q-loss', 'b')
const payload = riskSubmission(['q-loss', 'q-certain'], answers)
assert.equal(payload[0]?.answerId, 'b')
assert.equal(payload[1]?.answerId, '')
answers = setRiskAnswer(answers, 'q-certain', 'no')
assert.equal(riskSubmission(['q-certain'], answers)[0]?.answerId, 'no')
})
test('network failure and a late session do not clear local answers or apply me', async () => {
let answers = setRiskAnswer({}, 'q-loss', 'b')
resetMeMutationQueueForTests()
let applied = 0
bindMeMutationApply(() => {
applied += 1
return true
})
await assert.rejects(() => runMeMutation(async () => {
throw new ApiNetworkError()
}), ApiNetworkError)
assert.equal(answers['q-loss'], 'b')
await assert.rejects(() => runMeMutation(async () => {
invalidateSession()
return { me: { user: { coins: 1 } } }
}), StaleSessionError)
assert.equal(applied, 0)
assert.equal(answers['q-loss'], 'b')
})
+7
View File
@@ -11,6 +11,13 @@ const files = [
'catalog-api.test.ts', 'catalog-api.test.ts',
'mutation-queue.test.ts', 'mutation-queue.test.ts',
'gold-guess-rewards.test.ts', 'gold-guess-rewards.test.ts',
'budgeting-mission.test.ts',
'risk-return.test.ts',
'portfolio.test.ts',
'compound-interest.test.ts',
'smart-shop.test.ts',
'financial-goal.test.ts',
'castle-exam.test.ts',
] ]
let failed = false let failed = false
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ApiNetworkError, StaleSessionError } from '../src/api/errors'
import { invalidateSession } from '../src/api/authSession'
import { bindMeMutationApply, resetMeMutationQueueForTests, runMeMutation } from '../src/api/meMutationQueue'
import { moneyText, setShopAnswer } from '../src/modules/gold-land/smart-shop/shopText'
test('need budget and cost parts stay distinct, and unknown is not zero', () => {
assert.equal(moneyText(50).includes('۵۰'), true)
assert.equal(moneyText(45).includes('۴۵'), true)
assert.equal(moneyText(null), 'نامشخص')
assert.notEqual(moneyText(null), moneyText(0))
})
test('review can change a choice and a failed submit keeps it', async () => {
let answers = setShopAnswer({}, 'notebook-choice', 'a')
answers = setShopAnswer(answers, 'notebook-choice', 'b')
assert.equal(answers['notebook-choice'], 'b')
resetMeMutationQueueForTests()
let applied = 0
bindMeMutationApply(() => {
applied += 1
return true
})
await assert.rejects(() => runMeMutation(async () => {
throw new ApiNetworkError()
}), ApiNetworkError)
await assert.rejects(() => runMeMutation(async () => {
invalidateSession()
return { me: { user: { coins: 1 } } }
}), StaleSessionError)
assert.equal(applied, 0)
assert.equal(answers['notebook-choice'], 'b')
})