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)
|
## نصب روی گوشی (PWA)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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) {
|
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) {
|
||||||
|
|||||||
@@ -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 { 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)
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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: 'آب معدنی',
|
||||||
|
|||||||
@@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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}>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 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');
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user