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 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 09:19:25 +03:00
co-authored by Claude Opus 5.5
parent 5a4dffc9a3
commit bced4dc6f6
23 changed files with 745 additions and 176 deletions
+18 -1
View File
@@ -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) ## نصب روی گوشی (PWA)
+40 -1
View File
@@ -51,6 +51,7 @@ export const FACTORY = {
export const machines = [ export const machines = [
{ {
id: 'factory', id: 'factory',
model: 'rm-35',
name: strings.places.officeMachine, name: strings.places.officeMachine,
category: 'factory', category: 'factory',
address: strings.places.officeAddress(FACTORY.address), address: strings.places.officeAddress(FACTORY.address),
@@ -62,7 +63,8 @@ export const machines = [
}, },
{ {
id: 'mellat-park', id: 'mellat-park',
name: 'بوستان ملت', model: 'rm-48',
name: 'بوستان ملت · ورودی جنوبی',
category: 'park', category: 'park',
address: 'بزرگراه وکیل‌آباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت', address: 'بزرگراه وکیل‌آباد، ورودی جنوبی بوستان ملت، نزدیک ایستگاه مترو پارک ملت',
anchor: { anchor: {
@@ -74,8 +76,39 @@ export const machines = [
products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'chocolate', 'cake', 'nuts'], products: ['water', 'juice', 'lemonade', 'tea', 'biscuit', 'chocolate', 'cake', 'nuts'],
note: 'نوشیدنی‌های خنک برای پیاده‌روی در پارک', 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', id: 'school-rahil',
model: 'rm-35',
name: 'مجموعه آموزشی راحیل', name: 'مجموعه آموزشی راحیل',
category: 'school', category: 'school',
address: 'بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه', address: 'بولوار امامت، کوچهٔ امامت ۲، کنار در ورودی مدرسه',
@@ -90,6 +123,7 @@ export const machines = [
}, },
{ {
id: 'ferdowsi-university', id: 'ferdowsi-university',
model: 'rm-60',
name: 'دانشگاه فردوسی مشهد', name: 'دانشگاه فردوسی مشهد',
category: 'university', category: 'university',
address: 'میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیاده‌روی مرکزی', address: 'میدان آزادی، پردیس دانشگاه فردوسی، مسیر پیاده‌روی مرکزی',
@@ -104,6 +138,7 @@ export const machines = [
}, },
{ {
id: 'literature-faculty', id: 'literature-faculty',
model: 'rm-48',
name: 'دانشکدهٔ ادبیات دکتر شریعتی', name: 'دانشکدهٔ ادبیات دکتر شریعتی',
category: 'university', category: 'university',
address: 'پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات', address: 'پردیس دانشگاه فردوسی، بولوار دانش، جلوی دانشکدهٔ ادبیات',
@@ -118,6 +153,7 @@ export const machines = [
}, },
{ {
id: 'medical-university', id: 'medical-university',
model: 'rm-48',
name: 'دانشگاه علوم پزشکی مشهد', name: 'دانشگاه علوم پزشکی مشهد',
category: 'university', category: 'university',
address: 'میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد', address: 'میدان آزادی، پردیس دانشگاه علوم پزشکی مشهد',
@@ -132,6 +168,7 @@ export const machines = [
}, },
{ {
id: 'azadi-metro', id: 'azadi-metro',
model: 'rm-60',
name: 'ایستگاه مترو آزادی', name: 'ایستگاه مترو آزادی',
category: 'metro', category: 'metro',
address: 'بولوار ملک‌آباد، ورودی ایستگاه مترو آزادی', address: 'بولوار ملک‌آباد، ورودی ایستگاه مترو آزادی',
@@ -146,6 +183,7 @@ export const machines = [
}, },
{ {
id: 'mashhad-mall', id: 'mashhad-mall',
model: 'rm-60',
name: 'مشهد مال', name: 'مشهد مال',
category: 'mall', category: 'mall',
address: 'خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال', address: 'خیابان اخوان ثالث، کنار ورودی پارکینگ مشهد مال',
@@ -171,6 +209,7 @@ export const machines = [
*/ */
const testPoint = (n, lat, lon) => ({ const testPoint = (n, lat, lon) => ({
id: `test-${n}`, id: `test-${n}`,
model: 'rm-35',
name: strings.places.testPoint(n.toLocaleString('fa-IR')), name: strings.places.testPoint(n.toLocaleString('fa-IR')),
category: 'test', category: 'test',
test: true, test: true,
+3
View File
@@ -123,6 +123,9 @@ export function createState() {
purchases: [], purchases: [],
xp: 0, xp: 0,
missionsDone: [], missionsDone: [],
// Current mission and step (see game/missions).
missionIndex: 0,
missionStep: 0,
minutes: START_MINUTES, minutes: START_MINUTES,
busy: false, busy: false,
}; };
-89
View File
@@ -94,92 +94,3 @@ export function rankFor(xp) {
progress: next ? (xp - RANKS[index].xp) / (next.xp - RANKS[index].xp) : 1, 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;
+85
View File
@@ -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 };
}
+52
View File
@@ -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,
}))
: [],
}));
}
+67
View File
@@ -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.<id>, steps.<type>).
//
// 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;
+40
View File
@@ -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;
},
};
}
+2
View File
@@ -88,6 +88,8 @@ export function renderMachineList(context) {
*/ */
export function selectMachine(context, id, center = false) { export function selectMachine(context, id, center = false) {
context.selected = id; 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), const s = context.stationById(id),
def = s.def; def = s.def;
if (center) { if (center) {
+118
View File
@@ -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 });
}
+8 -14
View File
@@ -2,7 +2,9 @@ import * as THREE from 'three';
import { routeBetween } from '../world/routing.js'; import { routeBetween } from '../world/routing.js';
import { FACTORY } from '../../constants/locations.js'; import { FACTORY } from '../../constants/locations.js';
import { isOpen, formatClock, fa } from '../economy/gameState.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 { kmh } from '../systems/vehicle.js';
import { nearestRide } from './garage.js'; import { nearestRide } from './garage.js';
import { drawMinimap } from '../world/mapRender.js'; import { drawMinimap } from '../world/mapRender.js';
@@ -122,10 +124,9 @@ export function updateRoute(context, dt) {
* Objective station, player position and camera bearing. * Objective station, player position and camera bearing.
*/ */
function updateObjectiveChip(context, { objective, pos, headingDeg }) { 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 current step.
// A route the player picked on the map shows the place; otherwise show the mission itself. const ownRoute = context.waypoint && context.waypoint !== stepTarget(context.state);
const ownRoute = context.waypoint && context.waypoint !== mission?.target; const title = ownRoute ? objective?.def.name : currentStepText(context.state);
const title = ownRoute ? objective?.def.name : (mission?.title ?? objective?.def.name);
context.ui.patch('objective', { hidden: !title }); context.ui.patch('objective', { hidden: !title });
if (!title) return; if (!title) return;
context.ui.patch('objective-title', { text: title }); context.ui.patch('objective-title', { text: title });
@@ -157,15 +158,7 @@ export function refreshHUD(context) {
context.ui.style('xp-bar', { context.ui.style('xp-bar', {
width: `${Math.round(rank.progress * 100)}%`, width: `${Math.round(rank.progress * 100)}%`,
}); });
const active = activeMission(context.state); context.ui.patch('mission-list', { items: missionListItems(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.publishGame(context.state); context.ui.publishGame(context.state);
context.ui.patch('speed-stat', { context.ui.patch('speed-stat', {
hidden: context.mode !== 'drive', hidden: context.mode !== 'drive',
@@ -245,6 +238,7 @@ export function updateHUD(context) {
}; };
const headingDeg = bearing(forward.x, forward.z); const headingDeg = bearing(forward.x, forward.z);
const objective = context.objectiveStation(); const objective = context.objectiveStation();
context.objectiveArrow.update(objective, context.elapsed);
updateObjectiveChip(context, { objective, pos, headingDeg }); updateObjectiveChip(context, { objective, pos, headingDeg });
// The minimap is optional (floating menu); skip drawing it while it is hidden. // The minimap is optional (floating menu); skip drawing it while it is hidden.
if (context.showMinimap) if (context.showMinimap)
+4 -31
View File
@@ -17,14 +17,9 @@ import {
swipePayment, swipePayment,
cancelPayment, cancelPayment,
} from '../economy/payment.js'; } from '../economy/payment.js';
import { import { awardsFor, rankFor } from '../economy/scoring.js';
awardsFor,
rankFor,
completeMissions,
activeMission,
MISSIONS,
} from '../economy/scoring.js';
import { strings } from '../../i18n/fa.js'; import { strings } from '../../i18n/fa.js';
import { MISSIONS } from '../missions/missions.js';
/** /**
* Runs the open showroom operation for this subsystem. * Runs the open showroom operation for this subsystem.
* @param {*} context - context. * @param {*} context - context.
@@ -266,15 +261,7 @@ export function reward(context, record, product) {
3200, 3200,
); );
context.say('child', strings.dialogue.childGotIt(product.icon, product.name)); context.say('child', strings.dialogue.childGotIt(product.icon, product.name));
const done = completeMissions(context.state); const { mission } = context.reportMissionEvent({ type: 'buy', machineId: record.machineId });
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);
}
if (rankFor(context.state.xp).index > rankBefore) if (rankFor(context.state.xp).index > rankBefore)
context.setTimeout( context.setTimeout(
() => () =>
@@ -283,22 +270,8 @@ export function reward(context, record, product) {
rankFor(context.state.xp).name, rankFor(context.state.xp).name,
strings.rewards.findMore, 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.refreshHUD();
context.checkEnd(); context.checkEnd();
} }
+2
View File
@@ -78,6 +78,7 @@ export function frame(context) {
context.ui.read('map-dialog').open || context.ui.read('map-dialog').open ||
context.ui.read('end').open || context.ui.read('end').open ||
context.ui.read('showroom').open || context.ui.read('showroom').open ||
context.ui.read('machine-card').open ||
document.hidden; document.hidden;
const steps = Math.max(1, Math.ceil(dt / (1 / 30))), const steps = Math.max(1, Math.ceil(dt / (1 / 30))),
step = dt / steps; step = dt / steps;
@@ -89,6 +90,7 @@ export function frame(context) {
else context.updateWalk(step); else context.updateWalk(step);
context.updatePayment(step); context.updatePayment(step);
context.updateDelivery(step); context.updateDelivery(step);
context.updateMissions(step);
context.traffic.update(step, trafficObstacles(context), context.player); context.traffic.update(step, trafficObstacles(context), context.player);
} }
context.updateRoute(dt); context.updateRoute(dt);
+10
View File
@@ -30,6 +30,12 @@ import {
openMap, openMap,
} from './mapInteraction.js'; } from './mapInteraction.js';
import { setRealMode, setArMode, askRealMode, updateReal } from './realMode.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 { resize, updateCamera, frame } from './renderLoop.js';
import { updateTalks, greet, updateGuide, updateHints } from './worldEncounters.js'; import { updateTalks, greet, updateGuide, updateHints } from './worldEncounters.js';
import { import {
@@ -105,6 +111,10 @@ const RUNTIME_METHODS = {
selectMachine, selectMachine,
openMap, openMap,
startGame, startGame,
reportMissionEvent,
updateMissions,
openMachineCard,
orderFromCard,
restartGame, restartGame,
backToMenu, backToMenu,
offerOffline, offerOffline,
+10 -3
View File
@@ -17,7 +17,7 @@ import {
} from '../world/routing.js'; } from '../world/routing.js';
import { machines, CITY_START, FACTORY } from '../../constants/locations.js'; import { machines, CITY_START, FACTORY } from '../../constants/locations.js';
import { isOpen, formatClock } from '../economy/gameState.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 { createFamily } from '../models/characters.js';
import { createFactory } from '../models/factory.js'; import { createFactory } from '../models/factory.js';
import { createTraffic } from '../systems/traffic.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 { formatDistance, toLocal } from '../../utils/geo.js';
import { strings } from '../../i18n/fa.js'; import { strings } from '../../i18n/fa.js';
import { COMPANY } from '../../config/company.js'; import { COMPANY } from '../../config/company.js';
import { createObjectiveArrow } from '../models/objectiveArrow.js';
// Brand colours for materials and canvas drawings. // Brand colours for materials and canvas drawings.
const BRAND = COMPANY.colors; const BRAND = COMPANY.colors;
@@ -436,6 +437,7 @@ export async function initializeSpawnCar(context) {
context.ui.read('map-dialog').open || context.ui.read('map-dialog').open ||
context.ui.read('end').open || context.ui.read('end').open ||
context.ui.read('showroom').open || context.ui.read('showroom').open ||
context.ui.read('machine-card').open ||
!context.ui.read('landing').hidden; !context.ui.read('landing').hidden;
// Physical key codes, so the controls also work with a Persian keyboard layout. // Physical key codes, so the controls also work with a Persian keyboard layout.
context.keyName = (e) => context.keyName = (e) =>
@@ -737,6 +739,8 @@ export async function registerPreferences(context) {
context.ui.on('continue', () => context.ui.close('end')); context.ui.on('continue', () => context.ui.close('end'));
context.ui.on('restart', context.restartGame); context.ui.on('restart', context.restartGame);
context.ui.on('back-to-menu', context.backToMenu); 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. // The minimap is off by default to keep the screen clear; the floating menu toggles it.
try { try {
context.showMinimap = localStorage.getItem('minimap') === 'on'; context.showMinimap = localStorage.getItem('minimap') === 'on';
@@ -759,8 +763,8 @@ export async function registerPreferences(context) {
// ---------- Objective, route and HUD ---------- // ---------- Objective, route and HUD ----------
context.objectiveStation = () => { context.objectiveStation = () => {
if (context.waypoint) return context.stationById(context.waypoint); if (context.waypoint) return context.stationById(context.waypoint);
const mission = activeMission(context.state); const target = stepTarget(context.state);
if (mission?.target) return context.stationById(mission.target); if (target) return context.stationById(target);
// Open-ended missions point to the nearest place not bought from yet. // Open-ended missions point to the nearest place not bought from yet.
const visited = new Set(context.state.purchases.map((p) => p.machineId)); const visited = new Set(context.state.purchases.map((p) => p.machineId));
return ( return (
@@ -793,6 +797,9 @@ export async function initializeRouteMaterial(context) {
}); });
context.routeMesh = null; context.routeMesh = null;
context.routeTarget = null; context.routeTarget = null;
// Arrow hovering over the current objective machine.
context.objectiveArrow = createObjectiveArrow();
context.scene.add(context.objectiveArrow.group);
context.routeFrom = { context.routeFrom = {
x: 0, x: 0,
z: 0, z: 0,
+38 -7
View File
@@ -353,6 +353,8 @@ export const strings = {
childGotIt: (icon, product) => `${icon} ${product} گرفتم! مرسی بابا.`, childGotIt: (icon, product) => `${icon} ${product} گرفتم! مرسی بابا.`,
/** @type {(mission: string) => string} */ /** @type {(mission: string) => string} */
nextMission: (mission) => `مأموریت بعدی: ${mission}`, nextMission: (mission) => `مأموریت بعدی: ${mission}`,
/** @type {(step: string) => string} */
nextStep: (step) => `خب! حالا: ${step}`,
/** @type {(machine: string, distance: string) => string} */ /** @type {(machine: string, distance: string) => string} */
routeSet: (machine, distance) => routeSet: (machine, distance) =>
`مسیر تا ${machine} رو گذاشتم؛ ${distance}. دنبال خط طلایی بریم.`, `مسیر تا ${machine} رو گذاشتم؛ ${distance}. دنبال خط طلایی بریم.`,
@@ -364,15 +366,44 @@ export const strings = {
combo: 'ترکیب نوشیدنی و خوراکی', combo: 'ترکیب نوشیدنی و خوراکی',
allCategories: 'همهٔ انواع مکان', allCategories: 'همهٔ انواع مکان',
}, },
// Mission titles, keyed by mission id (game/missions/missions.js).
missions: { missions: {
factory: 'در دفتر فنی مهندسی کارخانه از دستگاه وندینگ خرید کن', firstBuy: 'اولین خرید در کارخانه',
first: 'با ماشین تا بوستان ملت برو و اولین خرید شهری را انجام بده', parkGate: 'دستگاه ورودی بوستان ملت',
school: 'قبل از تعطیلی مدرسه (۱۳:۳۰) از دستگاه مدرسه خرید کن', parkLake: 'بازدید از دستگاه کنار دریاچه',
campus: 'به دانشگاه فردوسی برو و یک خوراکی بخر', parkSports: 'سفارش دستگاه برای زمین ورزشی',
metro: 'از دستگاه ایستگاه مترو آزادی خرید کن', parkTour: 'گشت دستگاه‌های بوستان',
mall: 'با ماشین تا مشهد مال برو و خرید کن', explorer: 'کاشف شهر',
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: { snacks: {
juice: 'آب‌میوه', juice: 'آب‌میوه',
water: 'آب معدنی', water: 'آب معدنی',
+2
View File
@@ -7,6 +7,7 @@ import { InstallDialog } from './InstallDialog.jsx';
import { UpdateNotice } from './UpdateNotice.jsx'; import { UpdateNotice } from './UpdateNotice.jsx';
import { EndDialog } from './EndDialog.jsx'; import { EndDialog } from './EndDialog.jsx';
import { GpsFallbackDialog } from './GpsFallbackDialog.jsx'; import { GpsFallbackDialog } from './GpsFallbackDialog.jsx';
import { MachineCard } from './MachineCard.jsx';
/** Composes independently managed game dialogs. */ /** Composes independently managed game dialogs. */
export function GameDialogs(props) { export function GameDialogs(props) {
return ( return (
@@ -20,6 +21,7 @@ export function GameDialogs(props) {
<UpdateNotice {...props} /> <UpdateNotice {...props} />
<EndDialog {...props} /> <EndDialog {...props} />
<GpsFallbackDialog {...props} /> <GpsFallbackDialog {...props} />
<MachineCard {...props} />
</> </>
); );
} }
+65
View File
@@ -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 (
<GameNode
as="dialog"
id="machine-card"
aria-labelledby="machine-card-title"
className="info-sheet"
model={{ open: card.open }}
onClose={() => actions.emit('machine-card-close')}
>
<h2 id="machine-card-title" className="mb-3 text-lg font-bold">
{model?.name ?? text.title}
</h2>
<dl className="mb-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<dt className="opacity-70">{text.installedAt}</dt>
<dd>{card.site}</dd>
<dt className="opacity-70">{text.price}</dt>
<dd className="text-gold">{model && formatRial(model.priceNumeric)}</dd>
</dl>
<p className="opacity-90">{model?.description}</p>
<ul className="my-3 list-inside list-disc">
{model?.highlights.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<div className="mt-4 flex flex-wrap gap-3">
<button
type="button"
id="machine-order"
className="start-option start-option-primary"
disabled={card.ordered}
onClick={() => actions.emit('machine-order')}
>
{card.ordered ? text.ordered : text.order}
</button>
<a className="start-chip" href={`tel:${COMPANY.contact.phone}`}>
{text.contact}
</a>
<button
type="button"
className="start-chip"
onClick={() => actions.emit('machine-card-close')}
>
{text.close}
</button>
</div>
</GameNode>
);
}
+42
View File
@@ -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(<MachineCard nodes={nodes} actions={actions} />);
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();
});
});
+28 -6
View File
@@ -1,6 +1,33 @@
import { formatHetoman } from '@/game/economy/gameState.js'; import { formatHetoman } from '@/game/economy/gameState.js';
import { strings } from '@/i18n/fa.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 (
<li className={state}>
{item.title}
{item.steps?.length > 0 && (
<ol className="mission-steps">
{item.steps.map((step) => (
<li key={step.text} className={step.done ? 'done' : ''}>
{step.text}
</li>
))}
</ol>
)}
</li>
);
}
/** @typedef {{id:string,model?:object,onAction:function}} ViewListsProps */ /** @typedef {{id:string,model?:object,onAction:function}} ViewListsProps */
/** Renders structured game lists as JSX; strings are never interpreted as HTML. /** Renders structured game lists as JSX; strings are never interpreted as HTML.
* @param {ViewListsProps} props - List identifier, snapshot, and command callback. * @param {ViewListsProps} props - List identifier, snapshot, and command callback.
@@ -26,12 +53,7 @@ export function ViewLists({ id, model = {}, onAction }) {
</small> </small>
</button> </button>
)); ));
if (id === 'mission-list') if (id === 'mission-list') return items.map((item) => <MissionItem key={item.id} item={item} />);
return items.map((item) => (
<li key={item.id} className={item.done ? 'done' : item.active ? 'active' : ''}>
{item.title}
</li>
));
if (id === 'end-stats') if (id === 'end-stats')
return items.map(([label, value]) => ( return items.map(([label, value]) => (
<li key={label}> <li key={label}>
+12
View File
@@ -449,6 +449,18 @@ export const gameTheme = {
'.mission-list li.hidden': { '.mission-list li.hidden': {
display: 'none', 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': { '#waypoint': {
transform: 'translate(-50%, -100%)', transform: 'translate(-50%, -100%)',
display: 'grid', display: 'grid',
+97
View File
@@ -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));
});
+2 -24
View File
@@ -1,13 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import { awardsFor, rankFor, POINTS, RANKS } from '../src/game/economy/scoring.js';
awardsFor,
rankFor,
completeMissions,
activeMission,
POINTS,
RANKS,
} from '../src/game/economy/scoring.js';
import { createState, purchase } from '../src/game/economy/gameState.js'; import { createState, purchase } from '../src/game/economy/gameState.js';
import { machineById } from '../src/constants/locations.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)); 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(0).name, RANKS[0].name);
assert.equal(rankFor(RANKS[1].xp).index, 1); assert.equal(rankFor(RANKS[1].xp).index, 1);
assert.equal(rankFor(1e6).progress, 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');
}); });