From bced4dc6f6cb8820adcbc3f21688565131e402fd Mon Sep 17 00:00:00 2001 From: "hamed@B" Date: Sat, 31 Oct 2026 09:19:25 +0300 Subject: [PATCH] feat: step-based missions around park machines with 3D objective arrow Phase 5 of turning the game into the company landing page. - Replace purchase-only missions with a data-driven list of short step chains (goto, locate on map, inspect, order, buy, buy at N places) in src/game/missions, with pure progress logic on the game state. - Six missions, most about machines installed in Park Mellat; add two sites there (lake and sports fields) and record the product model installed at every site. - Inspecting a machine opens a machine card with the installed model, specs, price, an order request and sales contact. - The objective chip shows the current step; the missions sheet lists missions with the current one's steps ticked; a brand-coloured arrow hovers over the target machine. - Rewards and banners per finished mission, the next step is spoken. - Tests for mission data, step logic, text and the machine card. Co-Authored-By: Claude Opus 5.5 --- README.md | 19 ++++- src/constants/locations.js | 41 +++++++++- src/game/economy/gameState.js | 3 + src/game/economy/scoring.js | 89 -------------------- src/game/missions/missionProgress.js | 85 +++++++++++++++++++ src/game/missions/missionText.js | 52 ++++++++++++ src/game/missions/missions.js | 67 +++++++++++++++ src/game/models/objectiveArrow.js | 40 +++++++++ src/game/runtime/mapInteraction.js | 2 + src/game/runtime/missionRuntime.js | 118 +++++++++++++++++++++++++++ src/game/runtime/navigationHud.js | 22 ++--- src/game/runtime/purchaseFlow.js | 35 +------- src/game/runtime/renderLoop.js | 2 + src/game/runtime/runtimeBindings.js | 10 +++ src/game/runtime/runtimeSetup.js | 13 ++- src/i18n/fa.js | 45 ++++++++-- src/pages/game/GameDialogs.jsx | 2 + src/pages/game/MachineCard.jsx | 65 +++++++++++++++ src/pages/game/MachineCard.test.jsx | 42 ++++++++++ src/pages/game/ViewLists.jsx | 34 ++++++-- src/styles/gameTheme.js | 12 +++ tests/missions.test.js | 97 ++++++++++++++++++++++ tests/scoring.test.js | 26 +----- 23 files changed, 745 insertions(+), 176 deletions(-) create mode 100644 src/game/missions/missionProgress.js create mode 100644 src/game/missions/missionText.js create mode 100644 src/game/missions/missions.js create mode 100644 src/game/models/objectiveArrow.js create mode 100644 src/game/runtime/missionRuntime.js create mode 100644 src/pages/game/MachineCard.jsx create mode 100644 src/pages/game/MachineCard.test.jsx create mode 100644 tests/missions.test.js diff --git a/README.md b/README.md index ca07c9b..316236f 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,24 @@ npm run format:check | خرید از هر پنج نوع مکان شهری | ۵۰۰ | | خرید در حالت دنیای واقعی | ۲۰۰ | -هفت مأموریت امتیاز و پاداش نقدی (هتومان) دارند. درجه‌ها از «تازه‌وارد» تا «افسانهٔ مشهد» بالا می‌روند. بودجهٔ اولیه ۵۰۰ هتومان است (۱۰ هتومان = ۱۰۰٬۰۰۰ ریال). وقتی پول کافی برای هیچ خریدی نماند، یا همهٔ مأموریت‌ها انجام شود، عملیات تمام می‌شود. هر ثانیهٔ واقعی ده ثانیه از ساعت بازی است. +مأموریت‌ها امتیاز و پاداش نقدی (هتومان) دارند. درجه‌ها از «تازه‌وارد» تا «افسانهٔ مشهد» بالا می‌روند. بودجهٔ اولیه ۵۰۰ هتومان است (۱۰ هتومان = ۱۰۰٬۰۰۰ ریال). وقتی پول کافی برای هیچ خریدی نماند، یا همهٔ مأموریت‌ها انجام شود، عملیات تمام می‌شود. هر ثانیهٔ واقعی ده ثانیه از ساعت بازی است. + +## مأموریت‌ها + +هر مأموریت چند گام کوتاه دارد و نشانگر هدف بالای صفحه همیشه گام فعلی را نشان می‌دهد. یک فلش سه‌بعدی به رنگ برند هم بالای دستگاهِ هدف شناور است و مسیر طلایی تا آن کشیده می‌شود. بیشتر مأموریت‌ها دربارهٔ دستگاه‌های نصب‌شده در بوستان ملت‌اند (ورودی جنوبی، کنار دریاچه و زمین‌های ورزشی): + +| مأموریت | گام‌ها | +|---|---| +| اولین خرید در کارخانه | رفتن به دستگاه دفتر فنی ← خرید | +| دستگاه ورودی بوستان ملت | پیدا کردن روی نقشه ← رفتن ← خرید | +| بازدید از دستگاه کنار دریاچه | رفتن ← ایستادن کنار دستگاه (کارت مشخصات باز می‌شود) | +| سفارش دستگاه برای زمین ورزشی | رفتن ← ایستادن کنار دستگاه ← «ثبت درخواست سفارش» در کارت | +| گشت دستگاه‌های بوستان | خرید از دستگاه دریاچه ← خرید از دستگاه زمین ورزشی | +| کاشف شهر | خرید از پنج دستگاه مختلف شهر | + +کارت دستگاه مدل نصب‌شده در آن محل (از فهرست محصولات `src/config/company.js`)، مشخصات و قیمتش، دکمهٔ سفارش و تماس با فروش را نشان می‌دهد. + +برای تغییر مأموریت‌ها، فهرست `src/game/missions/missions.js` را ویرایش کنید (نوع گام‌ها: `goto`، `locate`، `inspect`، `order`، `buy`، `places`) و عنوانش را در `missions` و متن گام‌ها را در `steps` فایل `src/i18n/fa.js` بنویسید. دستگاه‌ها و مدل نصب‌شده در هر محل در `src/constants/locations.js` هستند. ## نصب روی گوشی (PWA) diff --git a/src/constants/locations.js b/src/constants/locations.js index 759a516..465a385 100644 --- a/src/constants/locations.js +++ b/src/constants/locations.js @@ -51,6 +51,7 @@ export const FACTORY = { export const machines = [ { id: 'factory', + model: 'rm-35', name: strings.places.officeMachine, category: 'factory', address: strings.places.officeAddress(FACTORY.address), @@ -62,7 +63,8 @@ export const machines = [ }, { id: 'mellat-park', - name: 'بوستان ملت', + model: 'rm-48', + name: 'بوستان ملت · ورودی جنوبی', category: 'park', address: 'بزرگراه وکیل‌آباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت', anchor: { @@ -74,8 +76,39 @@ export const machines = [ products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'chocolate', 'cake', 'nuts'], note: 'نوشیدنی‌های خنک برای پیاده‌روی در پارک', }, + { + id: 'mellat-lake', + model: 'rm-35', + name: 'بوستان ملت · کنار دریاچه', + category: 'park', + address: 'بوستان ملت، مسیر پیاده‌روی کنار دریاچه', + anchor: { + x: -210.6, + z: -484.8, + }, + hours: ALL_DAY, + priceFactor: 1, + products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'cake'], + note: 'دستگاه کنار دریاچه؛ مناسب پیک‌نیک خانوادگی', + }, + { + id: 'mellat-sports', + model: 'rm-60', + name: 'بوستان ملت · زمین‌های ورزشی', + category: 'park', + address: 'بوستان ملت، مسیر کنار زمین‌های ورزشی', + anchor: { + x: -401.8, + z: -713, + }, + hours: ALL_DAY, + priceFactor: 1, + products: ['water', 'juice', 'lemonade', 'nuts', 'chocolate', 'biscuit'], + note: 'نوشیدنی خنک برای بعد از ورزش', + }, { id: 'school-rahil', + model: 'rm-35', name: 'مجموعه آموزشی راحیل', category: 'school', address: 'بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه', @@ -90,6 +123,7 @@ export const machines = [ }, { id: 'ferdowsi-university', + model: 'rm-60', name: 'دانشگاه فردوسی مشهد', category: 'university', address: 'میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیاده‌روی مرکزی', @@ -104,6 +138,7 @@ export const machines = [ }, { id: 'literature-faculty', + model: 'rm-48', name: 'دانشکدهٔ ادبیات دکتر شریعتی', category: 'university', address: 'پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات', @@ -118,6 +153,7 @@ export const machines = [ }, { id: 'medical-university', + model: 'rm-48', name: 'دانشگاه علوم پزشکی مشهد', category: 'university', address: 'میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد', @@ -132,6 +168,7 @@ export const machines = [ }, { id: 'azadi-metro', + model: 'rm-60', name: 'ایستگاه مترو آزادی', category: 'metro', address: 'بولوار ملک‌آباد، ورودی ایستگاه مترو آزادی', @@ -146,6 +183,7 @@ export const machines = [ }, { id: 'mashhad-mall', + model: 'rm-60', name: 'مشهد مال', category: 'mall', address: 'خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال', @@ -171,6 +209,7 @@ export const machines = [ */ const testPoint = (n, lat, lon) => ({ id: `test-${n}`, + model: 'rm-35', name: strings.places.testPoint(n.toLocaleString('fa-IR')), category: 'test', test: true, diff --git a/src/game/economy/gameState.js b/src/game/economy/gameState.js index 78b364b..01cfeac 100644 --- a/src/game/economy/gameState.js +++ b/src/game/economy/gameState.js @@ -123,6 +123,9 @@ export function createState() { purchases: [], xp: 0, missionsDone: [], + // Current mission and step (see game/missions). + missionIndex: 0, + missionStep: 0, minutes: START_MINUTES, busy: false, }; diff --git a/src/game/economy/scoring.js b/src/game/economy/scoring.js index 4087e49..74e812f 100644 --- a/src/game/economy/scoring.js +++ b/src/game/economy/scoring.js @@ -94,92 +94,3 @@ export function rankFor(xp) { progress: next ? (xp - RANKS[index].xp) / (next.xp - RANKS[index].xp) : 1, }; } -/** - * Runs the places visited operation for this subsystem. - * @param {*} purchases - purchases. - * @returns {*} Operation result, when applicable. - */ -const placesVisited = (purchases) => new Set(purchases.map((p) => p.machineId)).size; -export const MISSIONS = [ - { - id: 'factory', - title: strings.missions.factory, - target: 'factory', - xp: 100, - hetoman: 20, - done: (ps) => ps.some((p) => p.machineId === 'factory'), - }, - { - id: 'first', - title: strings.missions.first, - target: 'mellat-park', - xp: 150, - hetoman: 30, - done: (ps) => ps.some((p) => p.machineId === 'mellat-park'), - }, - { - id: 'school', - title: strings.missions.school, - target: 'school-rahil', - xp: 200, - hetoman: 40, - done: (ps) => ps.some((p) => p.machineId === 'school-rahil'), - }, - { - id: 'campus', - title: strings.missions.campus, - target: 'ferdowsi-university', - xp: 200, - hetoman: 40, - done: (ps) => ps.some((p) => p.machineId === 'ferdowsi-university' && p.type === 'snack'), - }, - { - id: 'metro', - title: strings.missions.metro, - target: 'azadi-metro', - xp: 150, - hetoman: 30, - done: (ps) => ps.some((p) => p.machineId === 'azadi-metro'), - }, - { - id: 'mall', - title: strings.missions.mall, - target: 'mashhad-mall', - xp: 250, - hetoman: 50, - done: (ps) => ps.some((p) => p.machineId === 'mashhad-mall'), - }, - { - id: 'explorer', - title: strings.missions.explorer, - target: null, - xp: 400, - hetoman: 80, - done: (ps) => placesVisited(ps.filter((p) => CITY_CATEGORIES.includes(p.category))) >= 5, - }, -]; - -// Marks and returns missions that the current purchases complete for the first time. -/** - * Runs the complete missions operation for this subsystem. - * @param {*} state - state. - * @returns {*} Operation result, when applicable. - */ -export function completeMissions(state) { - const fresh = MISSIONS.filter( - (m) => !state.missionsDone.includes(m.id) && m.done(state.purchases), - ); - for (const m of fresh) { - state.missionsDone.push(m.id); - state.xp += m.xp; - state.balance += m.hetoman; - } - return fresh; -} -/** - * Runs the active mission operation for this subsystem. - * @param {*} state - state. - * @returns {*} Operation result, when applicable. - */ -export const activeMission = (state) => - MISSIONS.find((m) => !state.missionsDone.includes(m.id)) ?? null; diff --git a/src/game/missions/missionProgress.js b/src/game/missions/missionProgress.js new file mode 100644 index 0000000..febd72d --- /dev/null +++ b/src/game/missions/missionProgress.js @@ -0,0 +1,85 @@ +import { MISSIONS } from './missions.js'; + +// Pure mission bookkeeping on the game state: which step is current, and what an event (arriving, +// buying, inspecting…) completes. No rendering or timing here; the runtime reports events. + +// The event that completes each step type; only `goto` is finished by a differently named one. +const EVENT_FOR_STEP = { + goto: 'arrive', + locate: 'locate', + inspect: 'inspect', + order: 'order', + buy: 'buy', +}; + +/** + * The mission the player is working on. + * @param {object} state - Game state. + * @returns {object | null} Mission, or null when all are done. + */ +export const currentMission = (state) => MISSIONS[state.missionIndex] ?? null; + +/** + * The step the player is working on. + * @param {object} state - Game state. + * @returns {object | null} Step, or null when all missions are done. + */ +export const currentStep = (state) => currentMission(state)?.steps[state.missionStep] ?? null; + +/** + * Machine id the current step points at, if any. + * @param {object} state - Game state. + * @returns {string | null} Target machine id. + */ +export const stepTarget = (state) => currentStep(state)?.target ?? null; + +/** + * Number of different city machines (outside the factory) the player has bought from. + * @param {object[]} purchases - Purchase records. + * @returns {number} Distinct machines. + */ +export const cityPlacesVisited = (purchases) => + new Set(purchases.filter((p) => p.category !== 'factory').map((p) => p.machineId)).size; + +/** + * Whether an event completes a step. + * @param {object} step - Mission step. + * @param {{type: string, machineId?: string}} event - What happened. + * @param {object} state - Game state (for purchase counts). + * @returns {boolean} True when the step is done. + */ +function completes(step, event, state) { + if (step.type === 'places') + return event.type === 'buy' && cityPlacesVisited(state.purchases) >= step.count; + return EVENT_FOR_STEP[step.type] === event.type && step.target === event.machineId; +} + +/** + * Pays a finished mission's reward and moves on to the next mission. + * @param {object} state - Game state, changed in place. + * @param {object} mission - The finished mission. + */ +function finishMission(state, mission) { + state.missionsDone.push(mission.id); + state.xp += mission.reward.xp; + state.balance += mission.reward.hetoman; + state.missionIndex += 1; + state.missionStep = 0; +} + +/** + * Applies an event to the current step. + * @param {object} state - Game state, changed in place. + * @param {{type: string, machineId?: string}} event - What happened. + * @returns {{stepDone: boolean, mission: object | null}} Whether the step finished, and the + * mission it finished (null while the mission still has steps left). + */ +export function applyMissionEvent(state, event) { + const mission = currentMission(state), + step = currentStep(state); + if (!step || !completes(step, event, state)) return { stepDone: false, mission: null }; + state.missionStep += 1; + if (state.missionStep < mission.steps.length) return { stepDone: true, mission: null }; + finishMission(state, mission); + return { stepDone: true, mission }; +} diff --git a/src/game/missions/missionText.js b/src/game/missions/missionText.js new file mode 100644 index 0000000..09b9a5a --- /dev/null +++ b/src/game/missions/missionText.js @@ -0,0 +1,52 @@ +import { machineById } from '../../constants/locations.js'; +import { strings } from '../../i18n/fa.js'; +import { fa } from '../economy/gameState.js'; +import { MISSIONS } from './missions.js'; +import { cityPlacesVisited, currentStep } from './missionProgress.js'; + +/** + * Short instruction for a step, such as "go to Park Mellat". + * @param {object} step - Mission step. + * @param {object} state - Game state (for the places count). + * @returns {string} One line of text. + */ +export function stepText(step, state) { + if (step.type === 'places') + return strings.steps.places( + fa(Math.min(step.count, cityPlacesVisited(state.purchases))), + fa(step.count), + ); + return strings.steps[step.type](machineById(step.target)?.name ?? ''); +} + +/** + * The current instruction, or a note that everything is done. + * @param {object} state - Game state. + * @returns {string} One line of text. + */ +export function currentStepText(state) { + const step = currentStep(state); + return step ? stepText(step, state) : strings.steps.allDone; +} + +/** + * The mission list for the missions sheet: every mission with its state, and the steps of the + * current one. + * @param {object} state - Game state. + * @returns {object[]} List items for ViewLists. + */ +export function missionListItems(state) { + return MISSIONS.map((mission, index) => ({ + id: mission.id, + title: strings.missions[mission.id], + done: state.missionsDone.includes(mission.id), + active: index === state.missionIndex, + steps: + index === state.missionIndex + ? mission.steps.map((step, i) => ({ + text: stepText(step, state), + done: i < state.missionStep, + })) + : [], + })); +} diff --git a/src/game/missions/missions.js b/src/game/missions/missions.js new file mode 100644 index 0000000..0fbcd7e --- /dev/null +++ b/src/game/missions/missions.js @@ -0,0 +1,67 @@ +// The mission list, as data. Each mission is a short chain of steps; the player always works on +// the first unfinished step of the first unfinished mission. Titles and step texts come from the +// strings file (missions., steps.). +// +// Step types +// - goto: reach the machine at `target`. +// - locate: find `target` on the city map (select it there). +// - inspect: stand at `target` for a few seconds while the robot introduces the machine; its +// machine card then opens. +// - order: send an order request from the machine card of `target`. +// - buy: buy anything from `target`. +// - places: buy from `count` different machines in the city (not the factory). + +export const MISSIONS = [ + { + id: 'firstBuy', + steps: [ + { type: 'goto', target: 'factory' }, + { type: 'buy', target: 'factory' }, + ], + reward: { xp: 100, hetoman: 20 }, + }, + { + id: 'parkGate', + steps: [ + { type: 'locate', target: 'mellat-park' }, + { type: 'goto', target: 'mellat-park' }, + { type: 'buy', target: 'mellat-park' }, + ], + reward: { xp: 150, hetoman: 30 }, + }, + { + id: 'parkLake', + steps: [ + { type: 'goto', target: 'mellat-lake' }, + { type: 'inspect', target: 'mellat-lake' }, + ], + reward: { xp: 150, hetoman: 20 }, + }, + { + id: 'parkSports', + steps: [ + { type: 'goto', target: 'mellat-sports' }, + { type: 'inspect', target: 'mellat-sports' }, + { type: 'order', target: 'mellat-sports' }, + ], + reward: { xp: 200, hetoman: 30 }, + }, + { + id: 'parkTour', + steps: [ + { type: 'buy', target: 'mellat-lake' }, + { type: 'buy', target: 'mellat-sports' }, + ], + reward: { xp: 250, hetoman: 40 }, + }, + { + id: 'explorer', + steps: [{ type: 'places', count: 5 }], + reward: { xp: 400, hetoman: 80 }, + }, +]; + +// Distances (metres) for `goto` arrival and `inspect` range, and how long inspecting takes. +export const ARRIVE_RADIUS = 12; +export const INSPECT_RADIUS = 6; +export const INSPECT_SECONDS = 3; diff --git a/src/game/models/objectiveArrow.js b/src/game/models/objectiveArrow.js new file mode 100644 index 0000000..45291ec --- /dev/null +++ b/src/game/models/objectiveArrow.js @@ -0,0 +1,40 @@ +import * as THREE from 'three'; +import { COMPANY } from '../../config/company.js'; + +// Height above the ground the arrow floats at, and how far it bobs. +const HOVER_HEIGHT = 4.6; +const BOB = 0.35; + +/** + * Creates the brand-coloured arrow that hovers over the current objective, pointing down at it. + * It glows so it reads from far away and from any angle. + * @returns {{group: THREE.Group, update: function}} The arrow and its per-frame update. + */ +export function createObjectiveArrow() { + const material = new THREE.MeshStandardMaterial({ + color: COMPANY.colors.primary, + emissive: COMPANY.colors.primary, + emissiveIntensity: 0.6, + }); + const group = new THREE.Group(); + const head = new THREE.Mesh(new THREE.ConeGeometry(0.55, 0.9, 4), material); + head.rotation.x = Math.PI; // point down + const shaft = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.8, 0.3), material); + shaft.position.y = 0.8; + group.add(head, shaft); + group.visible = false; + return { + group, + /** + * Places the arrow over a target, or hides it. + * @param {{x: number, z: number} | null} target - Objective position, or null for none. + * @param {number} time - Elapsed seconds, for the bob and spin. + */ + update(target, time) { + group.visible = Boolean(target); + if (!target) return; + group.position.set(target.x, HOVER_HEIGHT + Math.sin(time * 2.4) * BOB, target.z); + group.rotation.y = time * 1.2; + }, + }; +} diff --git a/src/game/runtime/mapInteraction.js b/src/game/runtime/mapInteraction.js index 817cfdd..f341a55 100644 --- a/src/game/runtime/mapInteraction.js +++ b/src/game/runtime/mapInteraction.js @@ -88,6 +88,8 @@ export function renderMachineList(context) { */ export function selectMachine(context, id, center = false) { context.selected = id; + // Finding the machine on the map is a mission step ("locate"). + context.reportMissionEvent({ type: 'locate', machineId: id }); const s = context.stationById(id), def = s.def; if (center) { diff --git a/src/game/runtime/missionRuntime.js b/src/game/runtime/missionRuntime.js new file mode 100644 index 0000000..46f0efb --- /dev/null +++ b/src/game/runtime/missionRuntime.js @@ -0,0 +1,118 @@ +import { machineById } from '../../constants/locations.js'; +import { strings } from '../../i18n/fa.js'; +import { fa, formatHetoman } from '../economy/gameState.js'; +import { applyMissionEvent, currentStep } from '../missions/missionProgress.js'; +import { ARRIVE_RADIUS, INSPECT_RADIUS, INSPECT_SECONDS } from '../missions/missions.js'; +import { currentStepText } from '../missions/missionText.js'; + +// Connects the mission steps to the running game: it reports what the player does (arrive, +// inspect, buy, locate, order), celebrates finished steps and missions, and opens machine cards. + +// Delay before the next instruction is spoken, so it follows any reward banner. +const NEXT_LINE_DELAY = 1500; + +/** + * Speaker for game guidance: the robot when it is out of a machine, otherwise the father. + * @param {object} context - Game context. + * @returns {string} Speaker id. + */ +const guide = (context) => (context.guideOut() ? 'robot' : 'father'); + +/** + * Reports something the player did; finishes the current step or mission when it matches. + * @param {object} context - Game context. + * @param {{type: string, machineId?: string}} event - What happened. + * @returns {{stepDone: boolean, mission: object | null}} The result of applying the event. + */ +export function reportMissionEvent(context, event) { + const result = applyMissionEvent(context.state, event); + if (!result.stepDone) return result; + context.inspectTime = 0; + // A new target: drop the old route so it is rebuilt to the next machine. + context.waypoint = null; + context.route = null; + if (result.mission) announceMission(context, result.mission); + else context.tone(760, 0.25); + context.setTimeout( + () => context.say(guide(context), strings.dialogue.nextStep(currentStepText(context.state))), + NEXT_LINE_DELAY, + ); + context.refreshHUD(); + return result; +} + +/** + * Shows the reward banner for a finished mission. + * @param {object} context - Game context. + * @param {object} mission - The finished mission. + */ +function announceMission(context, mission) { + context.showBanner( + strings.rewards.missionDone, + strings.missions[mission.id], + strings.rewards.missionReward(fa(mission.reward.xp), formatHetoman(mission.reward.hetoman)), + ); + context.tone(660, 0.5); +} + +/** + * Distance from the player to the front of a machine. + * @param {object} context - Game context. + * @param {string} machineId - Machine id. + * @returns {number} Metres, or Infinity for an unknown machine. + */ +function distanceTo(context, machineId) { + const station = context.stationById(machineId); + if (!station) return Infinity; + return Math.hypot(station.front.x - context.player.x, station.front.z - context.player.z); +} + +/** + * Per-frame mission checks: arriving at a `goto` target, and standing at an `inspect` target + * long enough to finish inspecting it. + * @param {object} context - Game context. + * @param {number} dt - Step length in seconds. + */ +export function updateMissions(context, dt) { + const step = currentStep(context.state); + if (!step?.target) return; + // In real-world mode "being there" uses the GPS reach radius, which allows for GPS error. + const near = (radius) => + distanceTo(context, step.target) < (context.real.on ? context.reachRadius() : radius); + if (step.type === 'goto' && near(ARRIVE_RADIUS)) + reportMissionEvent(context, { type: 'arrive', machineId: step.target }); + if (step.type !== 'inspect') return; + context.inspectTime = near(INSPECT_RADIUS) ? (context.inspectTime ?? 0) + dt : 0; + if (context.inspectTime < INSPECT_SECONDS) return; + openMachineCard(context, step.target); + reportMissionEvent(context, { type: 'inspect', machineId: step.target }); +} + +/** + * Opens the machine card: which model stands here, its specs and an order button. + * @param {object} context - Game context. + * @param {string} machineId - Machine id. + */ +export function openMachineCard(context, machineId) { + const site = machineById(machineId); + context.ui.patch('machine-card', { + machineId, + modelId: site.model, + site: site.name, + ordered: false, + }); + context.keys.clear(); + document.exitPointerLock?.(); + context.ui.open('machine-card'); +} + +/** + * Handles the order button on a machine card. + * @param {object} context - Game context. + */ +export function orderFromCard(context) { + const { machineId } = context.ui.read('machine-card'); + context.ui.patch('machine-card', { ordered: true }); + context.say('robot', strings.machineCard.orderThanks, 'happy'); + reportMissionEvent(context, { type: 'order', machineId }); +} diff --git a/src/game/runtime/navigationHud.js b/src/game/runtime/navigationHud.js index 6199cbe..cef7168 100644 --- a/src/game/runtime/navigationHud.js +++ b/src/game/runtime/navigationHud.js @@ -2,7 +2,9 @@ import * as THREE from 'three'; import { routeBetween } from '../world/routing.js'; import { FACTORY } from '../../constants/locations.js'; import { isOpen, formatClock, fa } from '../economy/gameState.js'; -import { rankFor, activeMission, MISSIONS } from '../economy/scoring.js'; +import { rankFor } from '../economy/scoring.js'; +import { stepTarget } from '../missions/missionProgress.js'; +import { currentStepText, missionListItems } from '../missions/missionText.js'; import { kmh } from '../systems/vehicle.js'; import { nearestRide } from './garage.js'; import { drawMinimap } from '../world/mapRender.js'; @@ -122,10 +124,9 @@ export function updateRoute(context, dt) { * Objective station, player position and camera bearing. */ function updateObjectiveChip(context, { objective, pos, headingDeg }) { - const mission = activeMission(context.state); - // A route the player picked on the map shows the place; otherwise show the mission itself. - const ownRoute = context.waypoint && context.waypoint !== mission?.target; - const title = ownRoute ? objective?.def.name : (mission?.title ?? objective?.def.name); + // A route the player picked on the map shows the place; otherwise show the current step. + const ownRoute = context.waypoint && context.waypoint !== stepTarget(context.state); + const title = ownRoute ? objective?.def.name : currentStepText(context.state); context.ui.patch('objective', { hidden: !title }); if (!title) return; context.ui.patch('objective-title', { text: title }); @@ -157,15 +158,7 @@ export function refreshHUD(context) { context.ui.style('xp-bar', { width: `${Math.round(rank.progress * 100)}%`, }); - const active = activeMission(context.state); - context.ui.patch('mission-list', { - items: MISSIONS.map((m) => ({ - id: m.id, - title: m.title, - done: context.state.missionsDone.includes(m.id), - active: m === active, - })), - }); + context.ui.patch('mission-list', { items: missionListItems(context.state) }); context.ui.publishGame(context.state); context.ui.patch('speed-stat', { hidden: context.mode !== 'drive', @@ -245,6 +238,7 @@ export function updateHUD(context) { }; const headingDeg = bearing(forward.x, forward.z); const objective = context.objectiveStation(); + context.objectiveArrow.update(objective, context.elapsed); updateObjectiveChip(context, { objective, pos, headingDeg }); // The minimap is optional (floating menu); skip drawing it while it is hidden. if (context.showMinimap) diff --git a/src/game/runtime/purchaseFlow.js b/src/game/runtime/purchaseFlow.js index e8c20e1..3f55340 100644 --- a/src/game/runtime/purchaseFlow.js +++ b/src/game/runtime/purchaseFlow.js @@ -17,14 +17,9 @@ import { swipePayment, cancelPayment, } from '../economy/payment.js'; -import { - awardsFor, - rankFor, - completeMissions, - activeMission, - MISSIONS, -} from '../economy/scoring.js'; +import { awardsFor, rankFor } from '../economy/scoring.js'; import { strings } from '../../i18n/fa.js'; +import { MISSIONS } from '../missions/missions.js'; /** * Runs the open showroom operation for this subsystem. * @param {*} context - context. @@ -266,15 +261,7 @@ export function reward(context, record, product) { 3200, ); context.say('child', strings.dialogue.childGotIt(product.icon, product.name)); - const done = completeMissions(context.state); - for (const m of done) { - context.showBanner( - strings.rewards.missionDone, - m.title, - strings.rewards.missionReward(fa(m.xp), formatHetoman(m.hetoman)), - ); - context.tone(660, 0.5); - } + const { mission } = context.reportMissionEvent({ type: 'buy', machineId: record.machineId }); if (rankFor(context.state.xp).index > rankBefore) context.setTimeout( () => @@ -283,22 +270,8 @@ export function reward(context, record, product) { rankFor(context.state.xp).name, strings.rewards.findMore, ), - done.length ? 3400 : 0, + mission ? 3400 : 0, ); - const next = activeMission(context.state); - if (next) { - context.waypoint = null; - context.route = null; - context.setTimeout( - () => - context.say( - context.guideOut() ? 'robot' : 'father', - strings.dialogue.nextMission(next.title), - 'wink', - ), - 1500, - ); - } context.refreshHUD(); context.checkEnd(); } diff --git a/src/game/runtime/renderLoop.js b/src/game/runtime/renderLoop.js index 8c02dcd..c24ca38 100644 --- a/src/game/runtime/renderLoop.js +++ b/src/game/runtime/renderLoop.js @@ -78,6 +78,7 @@ export function frame(context) { context.ui.read('map-dialog').open || context.ui.read('end').open || context.ui.read('showroom').open || + context.ui.read('machine-card').open || document.hidden; const steps = Math.max(1, Math.ceil(dt / (1 / 30))), step = dt / steps; @@ -89,6 +90,7 @@ export function frame(context) { else context.updateWalk(step); context.updatePayment(step); context.updateDelivery(step); + context.updateMissions(step); context.traffic.update(step, trafficObstacles(context), context.player); } context.updateRoute(dt); diff --git a/src/game/runtime/runtimeBindings.js b/src/game/runtime/runtimeBindings.js index 6a2de4c..0c21f70 100644 --- a/src/game/runtime/runtimeBindings.js +++ b/src/game/runtime/runtimeBindings.js @@ -30,6 +30,12 @@ import { openMap, } from './mapInteraction.js'; import { setRealMode, setArMode, askRealMode, updateReal } from './realMode.js'; +import { + reportMissionEvent, + updateMissions, + openMachineCard, + orderFromCard, +} from './missionRuntime.js'; import { resize, updateCamera, frame } from './renderLoop.js'; import { updateTalks, greet, updateGuide, updateHints } from './worldEncounters.js'; import { @@ -105,6 +111,10 @@ const RUNTIME_METHODS = { selectMachine, openMap, startGame, + reportMissionEvent, + updateMissions, + openMachineCard, + orderFromCard, restartGame, backToMenu, offerOffline, diff --git a/src/game/runtime/runtimeSetup.js b/src/game/runtime/runtimeSetup.js index 4728c9d..bc6b9d0 100644 --- a/src/game/runtime/runtimeSetup.js +++ b/src/game/runtime/runtimeSetup.js @@ -17,7 +17,7 @@ import { } from '../world/routing.js'; import { machines, CITY_START, FACTORY } from '../../constants/locations.js'; import { isOpen, formatClock } from '../economy/gameState.js'; -import { activeMission } from '../economy/scoring.js'; +import { stepTarget } from '../missions/missionProgress.js'; import { createFamily } from '../models/characters.js'; import { createFactory } from '../models/factory.js'; import { createTraffic } from '../systems/traffic.js'; @@ -26,6 +26,7 @@ import { createMapAssets } from '../world/mapRender.js'; import { formatDistance, toLocal } from '../../utils/geo.js'; import { strings } from '../../i18n/fa.js'; import { COMPANY } from '../../config/company.js'; +import { createObjectiveArrow } from '../models/objectiveArrow.js'; // Brand colours for materials and canvas drawings. const BRAND = COMPANY.colors; @@ -436,6 +437,7 @@ export async function initializeSpawnCar(context) { context.ui.read('map-dialog').open || context.ui.read('end').open || context.ui.read('showroom').open || + context.ui.read('machine-card').open || !context.ui.read('landing').hidden; // Physical key codes, so the controls also work with a Persian keyboard layout. context.keyName = (e) => @@ -737,6 +739,8 @@ export async function registerPreferences(context) { context.ui.on('continue', () => context.ui.close('end')); context.ui.on('restart', context.restartGame); context.ui.on('back-to-menu', context.backToMenu); + context.ui.on('machine-order', context.orderFromCard); + context.ui.on('machine-card-close', () => context.ui.close('machine-card')); // The minimap is off by default to keep the screen clear; the floating menu toggles it. try { context.showMinimap = localStorage.getItem('minimap') === 'on'; @@ -759,8 +763,8 @@ export async function registerPreferences(context) { // ---------- Objective, route and HUD ---------- context.objectiveStation = () => { if (context.waypoint) return context.stationById(context.waypoint); - const mission = activeMission(context.state); - if (mission?.target) return context.stationById(mission.target); + const target = stepTarget(context.state); + if (target) return context.stationById(target); // Open-ended missions point to the nearest place not bought from yet. const visited = new Set(context.state.purchases.map((p) => p.machineId)); return ( @@ -793,6 +797,9 @@ export async function initializeRouteMaterial(context) { }); context.routeMesh = null; context.routeTarget = null; + // Arrow hovering over the current objective machine. + context.objectiveArrow = createObjectiveArrow(); + context.scene.add(context.objectiveArrow.group); context.routeFrom = { x: 0, z: 0, diff --git a/src/i18n/fa.js b/src/i18n/fa.js index 1a3d009..6a6ab84 100644 --- a/src/i18n/fa.js +++ b/src/i18n/fa.js @@ -353,6 +353,8 @@ export const strings = { childGotIt: (icon, product) => `${icon} ${product} گرفتم! مرسی بابا.`, /** @type {(mission: string) => string} */ nextMission: (mission) => `مأموریت بعدی: ${mission}`, + /** @type {(step: string) => string} */ + nextStep: (step) => `خب! حالا: ${step}`, /** @type {(machine: string, distance: string) => string} */ routeSet: (machine, distance) => `مسیر تا ${machine} رو گذاشتم؛ ${distance}. دنبال خط طلایی بریم.`, @@ -364,15 +366,44 @@ export const strings = { combo: 'ترکیب نوشیدنی و خوراکی', allCategories: 'همهٔ انواع مکان', }, + // Mission titles, keyed by mission id (game/missions/missions.js). missions: { - factory: 'در دفتر فنی مهندسی کارخانه از دستگاه وندینگ خرید کن', - first: 'با ماشین تا بوستان ملت برو و اولین خرید شهری را انجام بده', - school: 'قبل از تعطیلی مدرسه (۱۳:۳۰) از دستگاه مدرسه خرید کن', - campus: 'به دانشگاه فردوسی برو و یک خوراکی بخر', - metro: 'از دستگاه ایستگاه مترو آزادی خرید کن', - mall: 'با ماشین تا مشهد مال برو و خرید کن', - explorer: 'از ۵ مکان متفاوت شهر خرید کن', + firstBuy: 'اولین خرید در کارخانه', + parkGate: 'دستگاه ورودی بوستان ملت', + parkLake: 'بازدید از دستگاه کنار دریاچه', + parkSports: 'سفارش دستگاه برای زمین ورزشی', + parkTour: 'گشت دستگاه‌های بوستان', + explorer: 'کاشف شهر', }, + // One short line per step type, shown on the objective chip. + steps: { + /** @type {(place: string) => string} */ + goto: (place) => `برو به ${place}`, + /** @type {(place: string) => string} */ + locate: (place) => `${place} را روی نقشه پیدا کن`, + /** @type {(place: string) => string} */ + inspect: () => 'کنار دستگاه بایست تا معرفی‌اش کنم', + /** @type {(place: string) => string} */ + order: () => 'از کارت دستگاه سفارش بده', + /** @type {(place: string) => string} */ + buy: (place) => `از ${place} خرید کن`, + /** @type {(done: string, count: string) => string} */ + places: (done, count) => `از ${count} مکان شهر خرید کن (${done}/${count})`, + allDone: 'همهٔ مأموریت‌ها انجام شد', + }, + machineCard: { + title: 'مشخصات دستگاه', + installedAt: 'محل نصب', + model: 'مدل', + price: 'قیمت', + order: 'ثبت درخواست سفارش', + ordered: 'درخواست ثبت شد ✓', + contact: 'تماس با فروش', + close: 'بستن', + orderThanks: 'درخواستت ثبت شد؛ کارشناس فروش با تو تماس می‌گیرد.', + robotIntro: (model) => `این دستگاه ${model} است. مشخصاتش را روی صفحه ببین!`, + }, + snacks: { juice: 'آب‌میوه', water: 'آب معدنی', diff --git a/src/pages/game/GameDialogs.jsx b/src/pages/game/GameDialogs.jsx index beb86b0..ada88db 100644 --- a/src/pages/game/GameDialogs.jsx +++ b/src/pages/game/GameDialogs.jsx @@ -7,6 +7,7 @@ import { InstallDialog } from './InstallDialog.jsx'; import { UpdateNotice } from './UpdateNotice.jsx'; import { EndDialog } from './EndDialog.jsx'; import { GpsFallbackDialog } from './GpsFallbackDialog.jsx'; +import { MachineCard } from './MachineCard.jsx'; /** Composes independently managed game dialogs. */ export function GameDialogs(props) { return ( @@ -20,6 +21,7 @@ export function GameDialogs(props) { + ); } diff --git a/src/pages/game/MachineCard.jsx b/src/pages/game/MachineCard.jsx new file mode 100644 index 0000000..f35f2f6 --- /dev/null +++ b/src/pages/game/MachineCard.jsx @@ -0,0 +1,65 @@ +import { GameNode } from '@/components/shared/GameNode.jsx'; +import { COMPANY } from '@/config/company.js'; +import { strings } from '@/i18n/fa.js'; +import { showroomModelById, formatRial } from '@/utils/showroomAnswers.js'; + +/** @typedef {{nodes: object, actions: object}} MachineCardProps */ +/** + * Card for an installed machine: where it stands, which model it is, key specs, and an order + * request button (a mission step) with the sales contact. + * @param {MachineCardProps} props - Interface snapshots and actions. + * @returns {import('react').ReactElement} Dialog. + */ +export function MachineCard({ nodes, actions }) { + const card = nodes['machine-card'] ?? {}; + const model = showroomModelById(card.modelId); + const text = strings.machineCard; + + return ( + actions.emit('machine-card-close')} + > +

+ {model?.name ?? text.title} +

+
+
{text.installedAt}
+
{card.site}
+
{text.price}
+
{model && formatRial(model.priceNumeric)}
+
+

{model?.description}

+
    + {model?.highlights.map((item) => ( +
  • {item}
  • + ))} +
+
+ + + {text.contact} + + +
+
+ ); +} diff --git a/src/pages/game/MachineCard.test.jsx b/src/pages/game/MachineCard.test.jsx new file mode 100644 index 0000000..f4f55b9 --- /dev/null +++ b/src/pages/game/MachineCard.test.jsx @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { strings } from '@/i18n/fa.js'; +import { COMPANY } from '@/config/company.js'; +import { MachineCard } from './MachineCard.jsx'; + +afterEach(cleanup); +// jsdom has no modal dialogs; open them by setting the attribute. +beforeAll(() => { + HTMLDialogElement.prototype.showModal = function showModal() { + this.setAttribute('open', ''); + }; +}); + +/** + * Renders the card for the 60-channel model with an actions spy. + * @param {object} card - Extra card fields. + * @returns {{emit: import('vitest').Mock}} Actions spy. + */ +function renderCard(card = {}) { + const actions = { emit: vi.fn() }; + const nodes = { 'machine-card': { open: true, modelId: 'rm-60', site: 'زمین ورزشی', ...card } }; + render(); + return actions; +} + +describe('MachineCard', () => { + it('shows the installed model with its site and specs, and sends an order request', () => { + const actions = renderCard(); + const product = COMPANY.products.find((p) => p.id === 'rm-60'); + expect(screen.getByRole('heading', { name: product.name })).toBeInTheDocument(); + expect(screen.getByText('زمین ورزشی')).toBeInTheDocument(); + expect(screen.getByText(product.highlights[0])).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: strings.machineCard.order })); + expect(actions.emit).toHaveBeenCalledWith('machine-order'); + }); + + it('confirms an order and does not let it be sent twice', () => { + renderCard({ ordered: true }); + expect(screen.getByRole('button', { name: strings.machineCard.ordered })).toBeDisabled(); + }); +}); diff --git a/src/pages/game/ViewLists.jsx b/src/pages/game/ViewLists.jsx index a09023a..c544772 100644 --- a/src/pages/game/ViewLists.jsx +++ b/src/pages/game/ViewLists.jsx @@ -1,6 +1,33 @@ import { formatHetoman } from '@/game/economy/gameState.js'; import { strings } from '@/i18n/fa.js'; +// Class for a mission row: finished, current or still to come. +const MISSION_STATE = { done: 'done', active: 'active' }; + +/** + * One mission row; the current mission also lists its steps with ticks. + * @param {{item: {title: string, done: boolean, active: boolean, steps: object[]}}} props - + * Mission list item. + * @returns {import('react').ReactElement} List item. + */ +function MissionItem({ item }) { + const state = (item.done && MISSION_STATE.done) || (item.active && MISSION_STATE.active) || ''; + return ( +
  • + {item.title} + {item.steps?.length > 0 && ( +
      + {item.steps.map((step) => ( +
    1. + {step.text} +
    2. + ))} +
    + )} +
  • + ); +} + /** @typedef {{id:string,model?:object,onAction:function}} ViewListsProps */ /** Renders structured game lists as JSX; strings are never interpreted as HTML. * @param {ViewListsProps} props - List identifier, snapshot, and command callback. @@ -26,12 +53,7 @@ export function ViewLists({ id, model = {}, onAction }) { )); - if (id === 'mission-list') - return items.map((item) => ( -
  • - {item.title} -
  • - )); + if (id === 'mission-list') return items.map((item) => ); if (id === 'end-stats') return items.map(([label, value]) => (
  • diff --git a/src/styles/gameTheme.js b/src/styles/gameTheme.js index 411a519..d1a8c9e 100644 --- a/src/styles/gameTheme.js +++ b/src/styles/gameTheme.js @@ -449,6 +449,18 @@ export const gameTheme = { '.mission-list li.hidden': { display: 'none', }, + // Steps of the current mission, indented under it; finished ones are ticked. + '.mission-steps': { + margin: 'var(--game-token-17) 0 var(--game-token-45)', + 'padding-inline-start': 'var(--game-token-42)', + 'list-style': 'none', + 'font-weight': '400', + 'font-size': 'var(--game-token-30)', + }, + '.mission-steps li:not(.done)::before': { + content: "'○ '", + color: 'var(--color-primary)', + }, '#waypoint': { transform: 'translate(-50%, -100%)', display: 'grid', diff --git a/tests/missions.test.js b/tests/missions.test.js new file mode 100644 index 0000000..4fd92eb --- /dev/null +++ b/tests/missions.test.js @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createState, purchase } from '../src/game/economy/gameState.js'; +import { machineById, machines } from '../src/constants/locations.js'; +import { MISSIONS } from '../src/game/missions/missions.js'; +import { + applyMissionEvent, + currentMission, + currentStep, + stepTarget, +} from '../src/game/missions/missionProgress.js'; +import { currentStepText, missionListItems } from '../src/game/missions/missionText.js'; +import { strings } from '../src/i18n/fa.js'; + +/** + * Buys water at a machine and reports the purchase to the missions. + * @param {object} state - Game state. + * @param {string} id - Machine id. + * @returns {object} Mission event result. + */ +function buyAt(state, id) { + state.minutes = 12 * 60; + purchase(state, machineById(id), 'water'); + return applyMissionEvent(state, { type: 'buy', machineId: id }); +} + +test('Every mission step points at a real machine and has text', () => { + for (const mission of MISSIONS) { + assert.ok(strings.missions[mission.id], `title for ${mission.id}`); + for (const step of mission.steps) { + assert.ok(strings.steps[step.type], `text for ${step.type}`); + if (step.target) assert.ok(machineById(step.target), `machine ${step.target}`); + } + } + const parkSites = machines.filter((m) => m.category === 'park').map((m) => m.id); + assert.deepEqual(parkSites, ['mellat-park', 'mellat-lake', 'mellat-sports']); + const parkMissions = MISSIONS.filter((m) => m.steps.some((s) => parkSites.includes(s.target))); + assert.ok(parkMissions.length > MISSIONS.length / 2, 'most missions are about park machines'); +}); + +test('The first mission is: go to the factory, then buy there', () => { + const state = createState(); + assert.equal(currentMission(state).id, 'firstBuy'); + assert.equal(currentStepText(state), strings.steps.goto(machineById('factory').name)); + assert.equal(buyAt(state, 'factory').stepDone, false, 'buying before arriving does not count'); + assert.deepEqual(applyMissionEvent(state, { type: 'arrive', machineId: 'factory' }), { + stepDone: true, + mission: null, + }); + const balance = state.balance, + xp = state.xp; + const result = buyAt(state, 'factory'); + assert.equal(result.mission.id, 'firstBuy'); + assert.equal(state.xp, xp + result.mission.reward.xp); + assert.ok(state.balance > balance, 'reward paid after the purchase'); + assert.deepEqual(state.missionsDone, ['firstBuy']); + assert.equal(stepTarget(state), 'mellat-park'); +}); + +test('Park missions chain locate, goto, inspect, order and buy steps', () => { + const state = createState(); + state.missionIndex = 1; + const steps = [ + ['locate', 'mellat-park'], + ['arrive', 'mellat-park'], + ]; + for (const [type, machineId] of steps) applyMissionEvent(state, { type, machineId }); + assert.equal(applyMissionEvent(state, { type: 'buy', machineId: 'mellat-lake' }).stepDone, false); + assert.equal(buyAt(state, 'mellat-park').mission.id, 'parkGate'); + applyMissionEvent(state, { type: 'arrive', machineId: 'mellat-lake' }); + assert.equal(currentStep(state).type, 'inspect'); + assert.equal( + applyMissionEvent(state, { type: 'inspect', machineId: 'mellat-lake' }).mission.id, + 'parkLake', + ); + applyMissionEvent(state, { type: 'arrive', machineId: 'mellat-sports' }); + applyMissionEvent(state, { type: 'inspect', machineId: 'mellat-sports' }); + assert.equal( + applyMissionEvent(state, { type: 'order', machineId: 'mellat-sports' }).mission.id, + 'parkSports', + ); + buyAt(state, 'mellat-lake'); + assert.equal(buyAt(state, 'mellat-sports').mission.id, 'parkTour'); +}); + +test('The explorer mission counts different city machines and shows progress', () => { + const state = createState(); + state.missionIndex = MISSIONS.findIndex((m) => m.id === 'explorer'); + for (const id of ['mellat-park', 'mellat-lake', 'ferdowsi-university', 'azadi-metro']) + assert.equal(buyAt(state, id).stepDone, false); + assert.ok(currentStepText(state).includes('۴')); + assert.equal(buyAt(state, 'factory').stepDone, false, 'the factory does not count'); + assert.equal(buyAt(state, 'mellat-sports').mission.id, 'explorer'); + assert.equal(currentStepText(state), strings.steps.allDone); + const items = missionListItems(state); + assert.ok(items.every((item) => !item.active && item.steps.length === 0)); +}); diff --git a/tests/scoring.test.js b/tests/scoring.test.js index 0ba91af..7e37d04 100644 --- a/tests/scoring.test.js +++ b/tests/scoring.test.js @@ -1,13 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { - awardsFor, - rankFor, - completeMissions, - activeMission, - POINTS, - RANKS, -} from '../src/game/economy/scoring.js'; +import { awardsFor, rankFor, POINTS, RANKS } from '../src/game/economy/scoring.js'; import { createState, purchase } from '../src/game/economy/gameState.js'; import { machineById } from '../src/constants/locations.js'; @@ -33,23 +26,8 @@ test('Discovery, combo and all-category bonuses are paid once', () => { assert.ok(last.some((a) => a.points === POINTS.allCategories)); }); -test('Ranks and missions advance with progress', () => { +test('Ranks advance with experience', () => { assert.equal(rankFor(0).name, RANKS[0].name); assert.equal(rankFor(RANKS[1].xp).index, 1); assert.equal(rankFor(1e6).progress, 1); - const state = createState(); - assert.equal(activeMission(state).id, 'factory'); - purchase(state, machineById('factory'), 'water'); - assert.deepEqual( - completeMissions(state).map((m) => m.id), - ['factory'], - ); - assert.equal(state.balance, 500 - 8 + 20, 'factory sells at half price'); - purchase(state, machineById('mellat-park'), 'water'); - assert.deepEqual( - completeMissions(state).map((m) => m.id), - ['first'], - ); - assert.deepEqual(completeMissions(state), [], 'rewards are not repeated'); - assert.equal(activeMission(state).id, 'school'); });