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:
@@ -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