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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+38
-7
@@ -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: 'آب معدنی',
|
||||
|
||||
@@ -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) {
|
||||
<UpdateNotice {...props} />
|
||||
<EndDialog {...props} />
|
||||
<GpsFallbackDialog {...props} />
|
||||
<MachineCard {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<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 */
|
||||
/** 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 }) {
|
||||
</small>
|
||||
</button>
|
||||
));
|
||||
if (id === 'mission-list')
|
||||
return items.map((item) => (
|
||||
<li key={item.id} className={item.done ? 'done' : item.active ? 'active' : ''}>
|
||||
{item.title}
|
||||
</li>
|
||||
));
|
||||
if (id === 'mission-list') return items.map((item) => <MissionItem key={item.id} item={item} />);
|
||||
if (id === 'end-stats')
|
||||
return items.map(([label, value]) => (
|
||||
<li key={label}>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user