From 71ae4ffa9b2bc6275e86cd1187a61b9731b702d2 Mon Sep 17 00:00:00 2001 From: "hamed@B" Date: Sat, 31 Oct 2026 13:09:05 +0300 Subject: [PATCH] feat: About Us boards, Mr Mohammadi guide and products-hall orders - Information boards in the factory yard (about, contact, address) painted from the company config; E opens the same sections the start screen shows. - Mr Mohammadi welcomes the family at his desk on every visit with the company story; meeting him is a mission step. - Products hall gets a purchase-request button and a sales contact link. - New missions: About Us tour (read, meet, read) and products tour (view, ask, request). The About and Products start modes lead with their tour. - Factory points share the station shape so the arrow, route and chip point at them. Co-Authored-By: Claude Opus 5.5 --- src/constants/factoryPoints.js | 28 ++++ src/game/economy/gameState.js | 4 +- src/game/missions/missionProgress.js | 9 +- src/game/missions/missionText.js | 13 +- src/game/missions/missions.js | 48 +++++++ src/game/models/aboutBoards.js | 166 +++++++++++++++++++++++ src/game/models/factory.js | 4 + src/game/models/showroom.js | 27 +--- src/game/runtime/factoryPoints.js | 75 ++++++++++ src/game/runtime/mapInteraction.js | 4 +- src/game/runtime/missionRuntime.js | 20 ++- src/game/runtime/navigationHud.js | 5 + src/game/runtime/purchaseFlow.js | 7 + src/game/runtime/renderLoop.js | 10 +- src/game/runtime/runtimeBindings.js | 7 + src/game/runtime/runtimeLifecycle.js | 3 +- src/game/runtime/runtimeSetup.js | 16 ++- src/game/runtime/startModes.js | 8 +- src/game/runtime/worldEncounters.js | 48 +++++-- src/game/world/factoryFrame.js | 35 +++++ src/i18n/fa.js | 38 +++++- src/pages/game/AboutBoardSheet.jsx | 19 +++ src/pages/game/AboutBoardSheet.test.jsx | 33 +++++ src/pages/game/GameDialogs.jsx | 2 + src/pages/game/ShowroomDialog.jsx | 15 ++ src/pages/game/ShowroomDialog.test.jsx | 22 +++ src/pages/game/startScreen/InfoSheet.jsx | 9 +- tests/missions.test.js | 60 +++++++- 28 files changed, 667 insertions(+), 68 deletions(-) create mode 100644 src/constants/factoryPoints.js create mode 100644 src/game/models/aboutBoards.js create mode 100644 src/game/runtime/factoryPoints.js create mode 100644 src/game/world/factoryFrame.js create mode 100644 src/pages/game/AboutBoardSheet.jsx create mode 100644 src/pages/game/AboutBoardSheet.test.jsx diff --git a/src/constants/factoryPoints.js b/src/constants/factoryPoints.js new file mode 100644 index 0000000..3362187 --- /dev/null +++ b/src/constants/factoryPoints.js @@ -0,0 +1,28 @@ +// Places in the factory that missions can point at besides vending machines: information boards +// about the company, the manager's desk and the products hall. Positions use the factory's local +// frame (u along the driveway towards the gate, v across it; see game/models/factory.js). + +// Boards stand in a row in the yard, left of the product samples, facing the family's start spot. +// `topic` is the company information section the board shows (see startScreen/InfoSectionContent). +export const ABOUT_BOARDS = [ + { id: 'board-about', topic: 'about', u: -32, v: -15 }, + { id: 'board-contact', topic: 'contact', u: -27, v: -15 }, + { id: 'board-address', topic: 'address', u: -22, v: -15 }, +]; + +// Mr Mohammadi's desk in the management room, where he sits once he has arrived. +export const MANAGER_POINT = { id: 'mohammadi', u: 45, v: -40.7 }; + +// The products hall: the row of machine samples next to the boards. +export const SHOWROOM_POINT_ID = 'showroom'; + +export const FACTORY_POINT_IDS = [ + ...ABOUT_BOARDS.map((board) => board.id), + MANAGER_POINT.id, + SHOWROOM_POINT_ID, +]; + +// How close (metres from the board front) the player must stand to read a board. +export const BOARD_REACH = 3; +// How close to the manager counts as meeting him; matches his talk radius in the factory. +export const MEET_RADIUS = 3.4; diff --git a/src/game/economy/gameState.js b/src/game/economy/gameState.js index 01cfeac..1143174 100644 --- a/src/game/economy/gameState.js +++ b/src/game/economy/gameState.js @@ -1,4 +1,5 @@ import { strings } from '../../i18n/fa.js'; +import { missionOrder } from '../missions/missions.js'; // Money is counted in whole hetoman: 10 hetoman = 100,000 rial. export const INITIAL_BALANCE = 500; export const START_MINUTES = 8 * 60 + 30; @@ -123,7 +124,8 @@ export function createState() { purchases: [], xp: 0, missionsDone: [], - // Current mission and step (see game/missions). + // Mission order for the start mode, and the current mission and step (see game/missions). + missionOrder: missionOrder(), missionIndex: 0, missionStep: 0, minutes: START_MINUTES, diff --git a/src/game/missions/missionProgress.js b/src/game/missions/missionProgress.js index febd72d..5a1a35d 100644 --- a/src/game/missions/missionProgress.js +++ b/src/game/missions/missionProgress.js @@ -1,4 +1,4 @@ -import { MISSIONS } from './missions.js'; +import { missionById } 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. @@ -10,6 +10,11 @@ const EVENT_FOR_STEP = { inspect: 'inspect', order: 'order', buy: 'buy', + read: 'read', + meet: 'meet', + view: 'view', + ask: 'ask', + request: 'request', }; /** @@ -17,7 +22,7 @@ const EVENT_FOR_STEP = { * @param {object} state - Game state. * @returns {object | null} Mission, or null when all are done. */ -export const currentMission = (state) => MISSIONS[state.missionIndex] ?? null; +export const currentMission = (state) => missionById(state.missionOrder[state.missionIndex]); /** * The step the player is working on. diff --git a/src/game/missions/missionText.js b/src/game/missions/missionText.js index 09b9a5a..f3570ff 100644 --- a/src/game/missions/missionText.js +++ b/src/game/missions/missionText.js @@ -1,7 +1,7 @@ import { machineById } from '../../constants/locations.js'; import { strings } from '../../i18n/fa.js'; import { fa } from '../economy/gameState.js'; -import { MISSIONS } from './missions.js'; +import { missionById } from './missions.js'; import { cityPlacesVisited, currentStep } from './missionProgress.js'; /** @@ -16,9 +16,16 @@ export function stepText(step, state) { fa(Math.min(step.count, cityPlacesVisited(state.purchases))), fa(step.count), ); - return strings.steps[step.type](machineById(step.target)?.name ?? ''); + return strings.steps[step.type](targetName(step.target)); } +/** + * Display name of a step target: a machine site or a factory point. + * @param {string} [target] - Machine or factory point id. + * @returns {string} Name, or an empty string for steps without a target. + */ +export const targetName = (target) => machineById(target)?.name ?? strings.points[target] ?? ''; + /** * The current instruction, or a note that everything is done. * @param {object} state - Game state. @@ -36,7 +43,7 @@ export function currentStepText(state) { * @returns {object[]} List items for ViewLists. */ export function missionListItems(state) { - return MISSIONS.map((mission, index) => ({ + return state.missionOrder.map(missionById).map((mission, index) => ({ id: mission.id, title: strings.missions[mission.id], done: state.missionsDone.includes(mission.id), diff --git a/src/game/missions/missions.js b/src/game/missions/missions.js index 0fbcd7e..29963b4 100644 --- a/src/game/missions/missions.js +++ b/src/game/missions/missions.js @@ -10,6 +10,14 @@ // - 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). +// - read: read the information board `target` in the factory yard. +// - meet: meet Mr Mohammadi (`target`) at his desk; he tells the company story. +// - view: open the products hall (`target` is 'showroom'). +// - ask: ask the robot in the products hall about a model's price or features. +// - request: send a purchase request for a model from the products hall. +// +// Targets are machine ids (constants/locations.js) or factory point ids +// (constants/factoryPoints.js). export const MISSIONS = [ { @@ -20,6 +28,24 @@ export const MISSIONS = [ ], reward: { xp: 100, hetoman: 20 }, }, + { + id: 'aboutTour', + steps: [ + { type: 'read', target: 'board-about' }, + { type: 'meet', target: 'mohammadi' }, + { type: 'read', target: 'board-contact' }, + ], + reward: { xp: 150, hetoman: 20 }, + }, + { + id: 'productsTour', + steps: [ + { type: 'view', target: 'showroom' }, + { type: 'ask', target: 'showroom' }, + { type: 'request', target: 'showroom' }, + ], + reward: { xp: 150, hetoman: 20 }, + }, { id: 'parkGate', steps: [ @@ -65,3 +91,25 @@ export const MISSIONS = [ export const ARRIVE_RADIUS = 12; export const INSPECT_RADIUS = 6; export const INSPECT_SECONDS = 3; + +// The mission each start mode leads with; the others follow in list order. +const LEAD_MISSION = { about: 'aboutTour', products: 'productsTour' }; + +/** + * Mission ids in the order a start mode plays them: its lead mission first, then the rest. + * @param {string} [mode] - Start mode; unknown or missing modes use the list order. + * @returns {string[]} Mission ids. + */ +export function missionOrder(mode) { + const ids = MISSIONS.map((mission) => mission.id); + const lead = LEAD_MISSION[mode]; + if (!lead) return ids; + return [lead, ...ids.filter((id) => id !== lead)]; +} + +/** + * Finds a mission by id. + * @param {string} id - Mission id. + * @returns {object | null} The mission, or null. + */ +export const missionById = (id) => MISSIONS.find((mission) => mission.id === id) ?? null; diff --git a/src/game/models/aboutBoards.js b/src/game/models/aboutBoards.js new file mode 100644 index 0000000..59419c3 --- /dev/null +++ b/src/game/models/aboutBoards.js @@ -0,0 +1,166 @@ +import * as THREE from 'three'; +import { COMPANY } from '../../config/company.js'; +import { ABOUT_BOARDS, BOARD_REACH } from '../../constants/factoryPoints.js'; +import { strings } from '../../i18n/fa.js'; +import { FACTORY_ROTATION, factoryFootprint, factoryToWorld } from '../world/factoryFrame.js'; +import { textTexture } from './textures.js'; + +// Free-standing information boards in the factory yard: history and values, contact details and +// the address. The text is painted from the company config, so it updates with real content. + +const BRAND = COMPANY.colors; +// Board face in metres, and the canvas painted on it (same aspect ratio). +const BOARD_WIDTH = 3; +const BOARD_HEIGHT = 1.9; +const CANVAS_WIDTH = 900; +const CANVAS_HEIGHT = 570; +// Height of the board's centre, so it reads at eye level from a few metres away. +const BOARD_CENTRE_Y = 1.75; +const PADDING = 40; +const TITLE_BAND = 110; +const BODY_LINE = 52; +const FONT = 'Vazirmatn, Tahoma, sans-serif'; +// How far in front of the board the player stands to read it (local +v). +const FRONT_DISTANCE = 2; + +/** + * The text lines a board shows for its topic. + * @param {string} topic - Information section: 'about', 'contact' or 'address'. + * @returns {string[]} Paragraphs; long ones are wrapped when painted. + */ +export function boardLines(topic) { + const { contact, about } = COMPANY; + const labels = strings.landing; + if (topic === 'about') + return [about.history, ...about.values.map((value) => `${strings.boards.bullet} ${value}`)]; + if (topic === 'contact') + return [ + `${labels.phone}: ${contact.phone}`, + `${labels.email}: ${contact.email}`, + `${labels.website}: ${contact.website.replace(/^https?:\/\//, '')}`, + ]; + return [labels.fullAddress(contact.address, contact.city)]; +} + +/** + * Splits a paragraph into lines that fit a width on a canvas. + * @param {CanvasRenderingContext2D} c - Canvas context with the font already set. + * @param {string} text - Paragraph. + * @param {number} maxWidth - Line width in pixels. + * @returns {string[]} Lines. + */ +function wrapLines(c, text, maxWidth) { + const lines = []; + let line = ''; + for (const word of text.split(' ')) { + const next = line ? `${line} ${word}` : word; + if (line && c.measureText(next).width > maxWidth) { + lines.push(line); + line = word; + } else line = next; + } + if (line) lines.push(line); + return lines; +} + +/** + * Paints a board: a brand-coloured title band, the wrapped text and a hint at the bottom. + * @param {string} topic - Information section. + * @returns {THREE.CanvasTexture} Board texture. + */ +function boardTexture(topic) { + return textTexture(CANVAS_WIDTH, CANVAS_HEIGHT, (c, w, h) => { + c.fillStyle = BRAND.surfaceRaised; + c.fillRect(0, 0, w, h); + c.fillStyle = BRAND.primary; + c.fillRect(0, 0, w, TITLE_BAND); + c.direction = 'rtl'; + c.textAlign = 'right'; + c.fillStyle = BRAND.onPrimary; + c.font = `bold 64px ${FONT}`; + c.fillText(strings.boards.titles[topic], w - PADDING, 80); + c.fillStyle = BRAND.text; + c.font = `36px ${FONT}`; + const lines = boardLines(topic).flatMap((text) => wrapLines(c, text, w - PADDING * 2)); + lines.forEach((line, i) => c.fillText(line, w - PADDING, TITLE_BAND + 70 + i * BODY_LINE)); + c.fillStyle = BRAND.primaryLighter; + c.font = `28px ${FONT}`; + c.fillText(strings.boards.readMore, w - PADDING, h - 30); + }); +} + +/** + * Builds the board mesh: two posts and a framed face. + * @param {string} topic - Information section. + * @returns {THREE.Group} Board model, facing local +Z. + */ +function boardModel(topic) { + const group = new THREE.Group(); + const steel = new THREE.MeshStandardMaterial({ color: BRAND.primaryDarker, roughness: 0.5 }); + const top = BOARD_CENTRE_Y + BOARD_HEIGHT / 2; + for (const side of [-1, 1]) { + const post = new THREE.Mesh(new THREE.BoxGeometry(0.12, top, 0.12), steel); + post.position.set((side * (BOARD_WIDTH + 0.1)) / 2, top / 2, 0); + group.add(post); + } + const frame = new THREE.Mesh( + new THREE.BoxGeometry(BOARD_WIDTH + 0.16, BOARD_HEIGHT + 0.16, 0.08), + steel, + ); + frame.position.y = BOARD_CENTRE_Y; + const face = new THREE.Mesh( + new THREE.PlaneGeometry(BOARD_WIDTH, BOARD_HEIGHT), + new THREE.MeshBasicMaterial({ map: boardTexture(topic) }), + ); + face.position.set(0, BOARD_CENTRE_Y, 0.05); + group.add(frame, face); + group.traverse((mesh) => (mesh.castShadow = true)); + return group; +} + +/** + * Places one board in the yard. + * @param {THREE.Scene} scene - Scene to add it to. + * @param {{id: string, topic: string, u: number, v: number}} board - Board definition. + * @returns {object} Board with world position, reading spot, collision outline and objective info. + */ +function placeBoard(scene, { id, topic, u, v }) { + const model = boardModel(topic); + const position = factoryToWorld(u, v); + model.position.set(position.x, 0, position.z); + model.rotation.y = FACTORY_ROTATION; + model.name = id; + scene.add(model); + return { + id, + topic, + x: position.x, + z: position.z, + front: factoryToWorld(u, v + FRONT_DISTANCE), + footprint: factoryFootprint({ u, v, halfU: BOARD_WIDTH / 2 + 0.1, halfV: 0.15 }), + // Same shape as a machine station's `def`, so boards can be mission objectives. + def: { id, name: strings.points[id], poi: true }, + }; +} + +/** + * Builds the information boards in the factory yard. + * @param {THREE.Scene} scene - Scene to add them to. + * @returns {{boards: object[], nearest: function}} Boards and a lookup for the one in reach. + */ +export function createAboutBoards(scene) { + const boards = ABOUT_BOARDS.map((board) => placeBoard(scene, board)); + return { + boards, + /** + * The board the player can read from where they stand. + * @param {{x: number, z: number}} player - Player position. + * @returns {object | null} Closest board within reach, or null. + */ + nearest(player) { + const distance = (board) => Math.hypot(board.front.x - player.x, board.front.z - player.z); + const inReach = boards.filter((board) => distance(board) < BOARD_REACH); + return inReach.sort((a, b) => distance(a) - distance(b))[0] ?? null; + }, + }; +} diff --git a/src/game/models/factory.js b/src/game/models/factory.js index 201ce04..ffb2bb5 100644 --- a/src/game/models/factory.js +++ b/src/game/models/factory.js @@ -5,6 +5,7 @@ import { createLuxuryCar } from './luxuryCars.js'; import { MANAGER_CAR_BRAND, VEHICLE_BLACK } from '../../constants/vehicles.js'; import { createPerson, createCrew, nameTag } from './staff.js'; import { FACTORY } from '../../constants/locations.js'; +import { MANAGER_POINT } from '../../constants/factoryPoints.js'; import { textTexture } from './textures.js'; import { strings } from '../../i18n/fa.js'; import { COMPANY } from '../../config/company.js'; @@ -1640,6 +1641,9 @@ function factoryBuildArrival(build) { sat: false, }; build.talks.push({ + // He welcomes the family on every visit; meeting him is an About Us mission step. + id: MANAGER_POINT.id, + repeat: true, speaker: 'mohammadi', label: strings.factory.manager.name, radius: 3.4, diff --git a/src/game/models/showroom.js b/src/game/models/showroom.js index 1ed4daa..abd6609 100644 --- a/src/game/models/showroom.js +++ b/src/game/models/showroom.js @@ -1,19 +1,7 @@ -import { FACTORY } from '../../constants/locations.js'; import { SHOWROOM_CATALOG, SHOWROOM_REACH } from '../../constants/showroomCatalog.js'; import { createVendingMachine } from './vending.js'; import { createFamily } from './characters.js'; - -/** Projects factory-local coordinates into the shared world metre system. - * @param {number} u - Position along the factory axis. - * @param {number} v - Position normal to the factory axis. - * @returns {{x:number,z:number}} World position. - */ -function showroomPosition(u, v) { - return { - x: FACTORY.center.x + FACTORY.axis.x * u - FACTORY.axis.z * v, - z: FACTORY.center.z + FACTORY.axis.z * u + FACTORY.axis.x * v, - }; -} +import { FACTORY_ROTATION, factoryFootprint, factoryToWorld } from '../world/factoryFrame.js'; /** Creates one display-only sample, with a footprint and interaction position. * @param {{scene:object,model:object,index:number}} options - Sample placement context. @@ -27,19 +15,14 @@ function createSample({ scene, model, index }) { channels: model.channels, products: ['water', 'juice', 'biscuit', 'chocolate'], }); - const position = showroomPosition(u, v), - front = showroomPosition(u, v + 2); + const position = factoryToWorld(u, v), + front = factoryToWorld(u, v + 2); machine.group.name = `showroom-${model.id}`; machine.group.position.set(position.x, 0, position.z); - machine.group.rotation.y = Math.atan2(-FACTORY.axis.z, FACTORY.axis.x); + machine.group.rotation.y = FACTORY_ROTATION; machine.beacon.visible = machine.ring.visible = false; scene.add(machine.group); - const footprint = [ - [-1, -0.6], - [1, -0.6], - [1, 0.6], - [-1, 0.6], - ].flatMap(([x, z]) => Object.values(showroomPosition(u + x, v + z))); + const footprint = factoryFootprint({ u, v, halfU: 1, halfV: 0.6 }); return { model, machine, diff --git a/src/game/runtime/factoryPoints.js b/src/game/runtime/factoryPoints.js new file mode 100644 index 0000000..3a2e622 --- /dev/null +++ b/src/game/runtime/factoryPoints.js @@ -0,0 +1,75 @@ +import { MANAGER_POINT, SHOWROOM_POINT_ID } from '../../constants/factoryPoints.js'; +import { PRODUCTS_SAMPLE } from '../../constants/startModes.js'; +import { strings } from '../../i18n/fa.js'; +import { createAboutBoards } from '../models/aboutBoards.js'; +import { addPolygon } from '../world/collision.js'; +import { factoryToWorld } from '../world/factoryFrame.js'; + +// Factory places that missions can point at besides machines (boards, the manager's desk and the +// products hall), and reading the information boards. Every point has the same shape as a machine +// station ({x, z, front, def}) so the objective arrow, route and chip treat them alike. + +/** + * Builds a point that is not a board. + * @param {string} id - Point id. + * @param {{x: number, z: number}} at - Where the arrow hangs. + * @param {{x: number, z: number}} front - Where the route leads. + * @returns {object} Objective point. + */ +const point = (id, at, front) => ({ + x: at.x, + z: at.z, + front, + def: { id, name: strings.points[id], poi: true }, +}); + +/** + * Adds the information boards and registers every factory point. Runs after the factory and + * showroom are built. + * @param {object} context - Game context. + */ +export async function initializeFactoryPoints(context) { + context.aboutBoards = createAboutBoards(context.scene); + for (const board of context.aboutBoards.boards) addPolygon(context.collision, board.footprint); + const desk = factoryToWorld(MANAGER_POINT.u, MANAGER_POINT.v); + const sample = context.showroom.samples[PRODUCTS_SAMPLE]; + context.factoryPoints = new Map([ + ...context.aboutBoards.boards.map((board) => [board.id, board]), + [MANAGER_POINT.id, point(MANAGER_POINT.id, desk, desk)], + [SHOWROOM_POINT_ID, point(SHOWROOM_POINT_ID, sample.position, sample.front)], + ]); + context.ui.on('about-board-close', () => context.ui.close('about-board')); +} + +/** + * Finds a mission target: a machine station or a factory point. + * @param {object} context - Game context. + * @param {string} id - Machine or point id. + * @returns {object | null} Station or point, or null when unknown. + */ +export function pointById(context, id) { + return context.stationById(id) ?? context.factoryPoints.get(id) ?? null; +} + +/** + * The information board the walking player can read, if any. + * @param {object} context - Game context. + * @returns {object | null} Board in reach, or null. + */ +export function nearBoard(context) { + if (context.real.on || context.mode !== 'walk') return null; + return context.aboutBoards.nearest(context.player); +} + +/** + * Opens the full text of a board in a sheet and counts it as read. + * @param {object} context - Game context. + * @param {object} board - Board from createAboutBoards. + */ +export function openBoard(context, board) { + context.ui.patch('about-board', { topic: board.topic }); + context.keys.clear(); + document.exitPointerLock?.(); + context.ui.open('about-board'); + context.reportMissionEvent({ type: 'read', machineId: board.id }); +} diff --git a/src/game/runtime/mapInteraction.js b/src/game/runtime/mapInteraction.js index f341a55..705ab69 100644 --- a/src/game/runtime/mapInteraction.js +++ b/src/game/runtime/mapInteraction.js @@ -136,7 +136,9 @@ export function openMap(context) { mpp: 2.2, }); context.ui.open('map-dialog'); - context.selected ??= context.objectiveStation()?.def.id ?? null; + // Factory points (boards, the manager) are not on the machine map, so they are not selected. + const objective = context.objectiveStation(); + context.selected ??= objective && !objective.def.poi ? objective.def.id : null; if (context.selected) context.selectMachine(context.selected); context.renderMachineList(); context.renderMap(); diff --git a/src/game/runtime/missionRuntime.js b/src/game/runtime/missionRuntime.js index 46f0efb..1691574 100644 --- a/src/game/runtime/missionRuntime.js +++ b/src/game/runtime/missionRuntime.js @@ -1,5 +1,7 @@ +import { SHOWROOM_POINT_ID } from '../../constants/factoryPoints.js'; import { machineById } from '../../constants/locations.js'; import { strings } from '../../i18n/fa.js'; +import { showroomModelById } from '../../utils/showroomAnswers.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'; @@ -62,7 +64,7 @@ function announceMission(context, mission) { * @returns {number} Metres, or Infinity for an unknown machine. */ function distanceTo(context, machineId) { - const station = context.stationById(machineId); + const station = context.pointById(machineId); if (!station) return Infinity; return Math.hypot(station.front.x - context.player.x, station.front.z - context.player.z); } @@ -116,3 +118,19 @@ export function orderFromCard(context) { context.say('robot', strings.machineCard.orderThanks, 'happy'); reportMissionEvent(context, { type: 'order', machineId }); } + +/** + * Handles the purchase request button in the products hall: thanks the player and records the + * model as requested. + * @param {object} context - Game context. + */ +export function orderFromShowroom(context) { + const { open, modelId, ordered = [] } = context.ui.read('showroom'); + const model = showroomModelById(modelId); + if (!open || !model) return; + const thanks = strings.showroom.orderThanks(model.name); + context.ui.patch('showroom', { ordered: [...new Set([...ordered, model.id])] }); + context.ui.patch('showroom-response', { text: strings.showroom.robotSays(thanks) }); + context.say('robot', thanks, 'happy'); + reportMissionEvent(context, { type: 'request', machineId: SHOWROOM_POINT_ID }); +} diff --git a/src/game/runtime/navigationHud.js b/src/game/runtime/navigationHud.js index cef7168..a39681c 100644 --- a/src/game/runtime/navigationHud.js +++ b/src/game/runtime/navigationHud.js @@ -178,6 +178,11 @@ export function updatePrompt(context) { }); return; } + const board = context.nearBoard(); + if (board && !context.state.busy && context.payment.phase === 'idle') { + context.ui.patch('prompt', { text: strings.prompts.board(board.def.name), hidden: false }); + return; + } let text = ''; const station = context.nearStation(); const ride = context.mode === 'walk' ? nearestRide(context) : null; diff --git a/src/game/runtime/purchaseFlow.js b/src/game/runtime/purchaseFlow.js index 3f55340..ef8deac 100644 --- a/src/game/runtime/purchaseFlow.js +++ b/src/game/runtime/purchaseFlow.js @@ -20,6 +20,7 @@ import { import { awardsFor, rankFor } from '../economy/scoring.js'; import { strings } from '../../i18n/fa.js'; import { MISSIONS } from '../missions/missions.js'; +import { SHOWROOM_POINT_ID } from '../../constants/factoryPoints.js'; /** * Runs the open showroom operation for this subsystem. * @param {*} context - context. @@ -41,6 +42,7 @@ export function openShowroom(context) { context.keys.clear(); document.exitPointerLock?.(); context.ui.open('showroom'); + context.reportMissionEvent({ type: 'view', machineId: SHOWROOM_POINT_ID }); } /** * Runs the interact operation for this subsystem. @@ -48,6 +50,11 @@ export function openShowroom(context) { * @returns {*} Operation result, when applicable. */ export function interact(context) { + const board = context.nearBoard(); + if (board && !context.state.busy && context.payment.phase === 'idle') { + context.openBoard(board); + return; + } const station = context.nearStation(); if (!station || context.state.busy || context.payment.phase !== 'idle' || context.ended) return; if (context.real.on && (context.real.fix?.speed ?? 0) > MAX_WALKING_SPEED) { diff --git a/src/game/runtime/renderLoop.js b/src/game/runtime/renderLoop.js index c24ca38..6870d1a 100644 --- a/src/game/runtime/renderLoop.js +++ b/src/game/runtime/renderLoop.js @@ -72,14 +72,8 @@ export function frame(context) { context.last = now; context.elapsed += dt; const playing = context.ui.read('landing').hidden; - const paused = - !playing || - context.ui.read('shop').open || - context.ui.read('map-dialog').open || - context.ui.read('end').open || - context.ui.read('showroom').open || - context.ui.read('machine-card').open || - document.hidden; + // Any open dialog (or the start screen) pauses the world. + const paused = context.anyDialogOpen() || document.hidden; const steps = Math.max(1, Math.ceil(dt / (1 / 30))), step = dt / steps; if (!paused) { diff --git a/src/game/runtime/runtimeBindings.js b/src/game/runtime/runtimeBindings.js index 0c21f70..716f19e 100644 --- a/src/game/runtime/runtimeBindings.js +++ b/src/game/runtime/runtimeBindings.js @@ -35,9 +35,11 @@ import { updateMissions, openMachineCard, orderFromCard, + orderFromShowroom, } from './missionRuntime.js'; import { resize, updateCamera, frame } from './renderLoop.js'; import { updateTalks, greet, updateGuide, updateHints } from './worldEncounters.js'; +import { initializeFactoryPoints, pointById, nearBoard, openBoard } from './factoryPoints.js'; import { initializeTimers, initializeRenderer, @@ -115,6 +117,10 @@ const RUNTIME_METHODS = { updateMissions, openMachineCard, orderFromCard, + orderFromShowroom, + pointById, + nearBoard, + openBoard, restartGame, backToMenu, offerOffline, @@ -142,6 +148,7 @@ export const RUNTIME_INITIALIZERS = [ initializeLightingAndWorldFiles, initializeCollisionAndRoads, initializeFactory, + initializeFactoryPoints, initializeStations, initializeStationById, initializeClosestStation, diff --git a/src/game/runtime/runtimeLifecycle.js b/src/game/runtime/runtimeLifecycle.js index a5b114b..5b8b033 100644 --- a/src/game/runtime/runtimeLifecycle.js +++ b/src/game/runtime/runtimeLifecycle.js @@ -116,7 +116,8 @@ export function reset(context) { s.model.delivery.visible = s.model.card.visible = false; s.model.flap.rotation.x = 0; } - for (const id of ['shop', 'map-dialog', 'end', 'showroom']) context.ui.close(id); + for (const id of ['shop', 'map-dialog', 'end', 'showroom', 'machine-card', 'about-board']) + context.ui.close(id); context.keys.clear(); context.refreshHUD(); } diff --git a/src/game/runtime/runtimeSetup.js b/src/game/runtime/runtimeSetup.js index bc6b9d0..8fc64ee 100644 --- a/src/game/runtime/runtimeSetup.js +++ b/src/game/runtime/runtimeSetup.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 { SHOWROOM_POINT_ID } from '../../constants/factoryPoints.js'; import { createObjectiveArrow } from '../models/objectiveArrow.js'; // Brand colours for materials and canvas drawings. @@ -438,6 +439,7 @@ export async function initializeSpawnCar(context) { context.ui.read('end').open || context.ui.read('showroom').open || context.ui.read('machine-card').open || + context.ui.read('about-board').open || !context.ui.read('landing').hidden; // Physical key codes, so the controls also work with a Persian keyboard layout. context.keyName = (e) => @@ -705,7 +707,9 @@ export async function registerPurchaseControls(context) { text: strings.showroom.robotSays(answer), }); context.say('robot', answer, question.topic === 'price' ? 'happy' : 'wink'); + context.reportMissionEvent({ type: 'ask', machineId: SHOWROOM_POINT_ID }); }); + context.ui.on('showroom-order', context.orderFromShowroom); context.ui.on('swipe', context.swipe); // On phones the prompt itself is the button: tap it to buy, swipe the card, or get in/out of the car. context.ui.on('prompt', () => { @@ -713,6 +717,11 @@ export async function registerPurchaseControls(context) { context.openShowroom(); return; } + const board = context.nearBoard(); + if (board) { + context.openBoard(board); + return; + } if (context.payment.phase === 'ready') context.swipe(); else if (context.nearStation()) context.interact(); else context.toggleCar(); @@ -762,9 +771,9 @@ export async function registerPreferences(context) { // ---------- Objective, route and HUD ---------- context.objectiveStation = () => { - if (context.waypoint) return context.stationById(context.waypoint); + if (context.waypoint) return context.pointById(context.waypoint); const target = stepTarget(context.state); - if (target) return context.stationById(target); + if (target) return context.pointById(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 ( @@ -1185,6 +1194,9 @@ export async function exposeDebugTools(context) { return context.cam; }, factory: context.factory, + get points() { + return context.factoryPoints; + }, get car() { return context.car; }, diff --git a/src/game/runtime/startModes.js b/src/game/runtime/startModes.js index 8dc90ce..27d80ae 100644 --- a/src/game/runtime/startModes.js +++ b/src/game/runtime/startModes.js @@ -7,6 +7,7 @@ import { START_MODES, } from '../../constants/startModes.js'; import { strings } from '../../i18n/fa.js'; +import { missionOrder } from '../missions/missions.js'; import { resolveCircle } from '../world/collision.js'; import { parkFamilyCar } from './garage.js'; @@ -83,11 +84,13 @@ function introduce(context, mode) { } /** - * Places the player for a mode, without touching the interface. + * Sets up a fresh game for a mode, without touching the interface: where the player stands and + * which mission comes first. * @param {object} context - Game context. * @param {string} mode - Start mode. */ function placeForMode(context, mode) { + context.state.missionOrder = missionOrder(mode); if (mode === 'offline') placeAtPark(context); if (mode === 'products') placeAtShowroom(context); } @@ -127,7 +130,8 @@ export function restartGame(context) { */ export function backToMenu(context) { context.setRealMode(false); - for (const id of ['shop', 'map-dialog', 'end', 'showroom', 'gps-fallback']) context.ui.close(id); + const dialogs = ['shop', 'map-dialog', 'end', 'showroom', 'machine-card', 'about-board']; + for (const id of [...dialogs, 'gps-fallback']) context.ui.close(id); document.exitPointerLock?.(); context.reset(); context.ui.patch('hud', { hidden: true }); diff --git a/src/game/runtime/worldEncounters.js b/src/game/runtime/worldEncounters.js index fb4e034..f5980c2 100644 --- a/src/game/runtime/worldEncounters.js +++ b/src/game/runtime/worldEncounters.js @@ -3,23 +3,43 @@ import { FACTORY } from '../../constants/locations.js'; import { isOpen } from '../economy/gameState.js'; import { strings } from '../../i18n/fa.js'; /** - * Updates talks from the active simulation. - * @param {*} context - context. - * @returns {*} Operation result, when applicable. + * Distance from the player to someone who talks. + * @param {object} context - Game context. + * @param {object} talk - Factory talk. + * @returns {number} Metres. */ -export // People in the factory say their lines once, when the family walks up to them. -function updateTalks(context) { - if (context.mode !== 'walk' || context.real.on || context.lines.length > 2) return; +function talkDistance(context, talk) { + const p = talk.pos(); + return Math.hypot(p.x - context.player.x, p.z - context.player.z); +} + +/** + * Lets people who repeat their lines (the manager) talk again once the family has walked away. + * @param {object} context - Game context. + */ +function rearmTalks(context) { for (const t of context.factory.talks) { - if (t.done || (t.ready && !t.ready())) continue; - const p = t.pos(); - if (Math.hypot(p.x - context.player.x, p.z - context.player.z) > t.radius) continue; - t.done = true; - for (const text of t.lines) context.say(t.speaker, text); - return; + if (t.repeat && t.done && talkDistance(context, t) > t.radius * 2) t.done = false; } } +/** + * People in the factory say their lines when the family walks up to them. A talk with an `id` + * (the manager) also counts as meeting that person for the missions. + * @param {object} context - Game context. + */ +export function updateTalks(context) { + if (context.mode !== 'walk' || context.real.on || context.lines.length > 2) return; + rearmTalks(context); + const talk = context.factory.talks.find( + (t) => !t.done && (!t.ready || t.ready()) && talkDistance(context, t) <= t.radius, + ); + if (!talk) return; + talk.done = true; + for (const text of talk.lines) context.say(talk.speaker, text); + if (talk.id) context.reportMissionEvent({ type: 'meet', machineId: talk.id }); +} + // ---------- Robot guide ---------- // When the family walks up to a machine the robot pops out of it, greets them and explains what to // do; when they leave it goes back inside. It is never a walking companion. @@ -130,7 +150,9 @@ export function updateGuide(context, dt) { */ export function updateHints(context) { const target = context.objectiveStation(); - if (target && !isOpen(target.def, context.state.minutes) && !context.hints.closed) { + // Only machines have opening hours; factory points are always open. + const closed = target?.def.hours && !isOpen(target.def, context.state.minutes); + if (closed && !context.hints.closed) { context.hints.closed = true; context.say( 'robot', diff --git a/src/game/world/factoryFrame.js b/src/game/world/factoryFrame.js new file mode 100644 index 0000000..3988d0e --- /dev/null +++ b/src/game/world/factoryFrame.js @@ -0,0 +1,35 @@ +import { FACTORY } from '../../constants/locations.js'; + +// The factory's local frame: u runs along the driveway towards the gate, v across it. Things placed +// in the yard outside the factory model (product samples, information boards) use it too. + +/** + * Projects factory-local coordinates into world metres. + * @param {number} u - Position along the factory axis. + * @param {number} v - Position across the factory axis. + * @returns {{x: number, z: number}} World position. + */ +export function factoryToWorld(u, v) { + return { + x: FACTORY.center.x + FACTORY.axis.x * u - FACTORY.axis.z * v, + z: FACTORY.center.z + FACTORY.axis.z * u + FACTORY.axis.x * v, + }; +} + +/** + * Collision outline of a rectangle in the factory frame. + * @param {{u: number, v: number, halfU: number, halfV: number}} box - Centre and half sizes. + * @returns {number[]} Flat world ring [x0, z0, x1, z1, …] for the collision world. + */ +export function factoryFootprint({ u, v, halfU, halfV }) { + return [ + [-halfU, -halfV], + [halfU, -halfV], + [halfU, halfV], + [-halfU, halfV], + ].flatMap(([du, dv]) => Object.values(factoryToWorld(u + du, v + dv))); +} + +// Rotation (Three rotation.y) that turns a model's local +X along the factory axis, so its local +// +Z faces +v. +export const FACTORY_ROTATION = Math.atan2(-FACTORY.axis.z, FACTORY.axis.x); diff --git a/src/i18n/fa.js b/src/i18n/fa.js index 6a6ab84..7a9252e 100644 --- a/src/i18n/fa.js +++ b/src/i18n/fa.js @@ -172,6 +172,8 @@ export const strings = { /** @type {(vehicle: string) => string} */ getIn: (vehicle) => `F · سوار ${vehicle} شو`, getOutNearMachine: 'F · پیاده شو؛ دستگاه نزدیک است', + /** @type {(board: string) => string} */ + board: (board) => `E · خواندن ${board}`, }, vehicles: { bmw: 'بی‌ام‌و', @@ -301,6 +303,12 @@ export const strings = { greeting: 'روبات: سلام! دربارهٔ قیمت و امکانات این مدل از من بپرس.', allModels: 'روبات: سلام! قیمت و امکانات هر سه مدل را می‌توانم توضیح بدهم.', whatToKnow: 'روبات: دربارهٔ این مدل چی می‌خواهی بدانی؟', + order: 'ثبت درخواست خرید', + ordered: 'درخواست ثبت شد ✓', + contact: 'تماس با فروش', + /** @type {(model: string) => string} */ + orderThanks: (model) => + `درخواست خرید ${model} ثبت شد؛ کارشناس فروش به‌زودی با شما تماس می‌گیرد.`, /** @type {(answer: string) => string} */ robotSays: (answer) => `روبات: ${answer}`, noImage: 'نمای سه‌بعدی این مدل داخل کارخانه موجود است؛ فایل تصویر هنوز اضافه نشده.', @@ -330,7 +338,7 @@ export const strings = { `به ${company} خوش اومدین. بچه‌های همین دفتر نرم‌افزار دستگاه‌ها رو می‌نویسن.`, robotFirstBuy: 'اول از همین دستگاه یه چیزی بخرین؛ کلید E رو بزنین.', robotGoToPark: - 'ماشینتون جلوی همین ساختمونه. با F سوار شین و برین بوستان ملت؛ من توی همهٔ دستگاه‌ها منتظرتونم!', + 'بعدش تابلوهای حیاط رو بخونین و با آقای محمدی آشنا شین؛ بعد با ماشین برین بوستان ملت. من توی همهٔ دستگاه‌ها منتظرتونم!', /** @type {(hours: string) => string} */ robotClosed: (hours) => `سلام! متأسفم، این دستگاه الان بسته است. ساعت کاری: ${hours}.`, /** @type {(machine: string) => string} */ @@ -369,6 +377,8 @@ export const strings = { // Mission titles, keyed by mission id (game/missions/missions.js). missions: { firstBuy: 'اولین خرید در کارخانه', + aboutTour: `آشنایی با ${COMPANY.shortName}`, + productsTour: 'نمایشگاه محصولات', parkGate: 'دستگاه ورودی بوستان ملت', parkLake: 'بازدید از دستگاه کنار دریاچه', parkSports: 'سفارش دستگاه برای زمین ورزشی', @@ -389,8 +399,29 @@ export const strings = { buy: (place) => `از ${place} خرید کن`, /** @type {(done: string, count: string) => string} */ places: (done, count) => `از ${count} مکان شهر خرید کن (${done}/${count})`, + /** @type {(board: string) => string} */ + read: (board) => `${board} را بخوان`, + /** @type {(person: string) => string} */ + meet: (person) => `در دفتر مدیریت با ${person} آشنا شو`, + view: () => 'به نمایشگاه دستگاه‌ها برو و مدل‌ها را ببین', + ask: () => 'قیمت یا امکانات یک مدل را از روبات بپرس', + request: () => 'درخواست خرید یک دستگاه را ثبت کن', allDone: 'همهٔ مأموریت‌ها انجام شد', }, + // Names of the factory places missions point at (constants/factoryPoints.js). + points: { + 'board-about': 'تابلوی دربارهٔ ما', + 'board-contact': 'تابلوی تماس با ما', + 'board-address': 'تابلوی آدرس کارخانه', + mohammadi: 'آقای محمدی', + showroom: 'نمایشگاه دستگاه‌ها', + }, + // Information boards in the factory yard. + boards: { + titles: { about: 'دربارهٔ ما', contact: 'تماس با ما', address: 'آدرس کارخانه' }, + readMore: 'برای خواندن کامل، نزدیک شو و E را بزن', + bullet: '•', + }, machineCard: { title: 'مشخصات دستگاه', installedAt: 'محل نصب', @@ -502,10 +533,13 @@ export const strings = { label: 'واحد حسابداری', lines: ['سلام! اینجا فروش همهٔ دستگاه‌ها ثبت می‌شه. هر خریدی بکنین، فاکتورش این‌جا میاد!'], }, + // Mr Mohammadi's welcome; he says it each time the family comes to his desk. managerLines: [ `سلام، خوش اومدین! من محمدی‌ام، مدیر ${COMPANY.shortName}.`, + COMPANY.about.history, + `چیزهایی که برامون مهمه: ${COMPANY.about.values.join('، ')}.`, 'امروز چهل نفر توی سالن تولید کار می‌کنن: خم‌کاری، برش، رنگ، یخچال‌سازی و مونتاژ.', - 'یه سر به دفتر فنی بزنین و از دستگاهش خرید کنین؛ بعد برین سراغ دستگاه‌های شهر!', + 'مدل‌های دستگاه رو توی نمایشگاه حیاط ببینین؛ روبات اونجا قیمت‌ها رو می‌گه.', ], truck: 'بابا ببین! کامیون داره دستگاه‌های بسته‌بندی‌شده رو بار می‌زنه که ببره شهر!', // One comment per production area, spoken the first time the family walks in. diff --git a/src/pages/game/AboutBoardSheet.jsx b/src/pages/game/AboutBoardSheet.jsx new file mode 100644 index 0000000..7f1cdef --- /dev/null +++ b/src/pages/game/AboutBoardSheet.jsx @@ -0,0 +1,19 @@ +import { InfoSheet } from './startScreen/InfoSheet.jsx'; + +/** @typedef {{nodes: object, actions: object}} AboutBoardSheetProps */ +/** + * Full text of the information board the player is reading in the factory yard. Reuses the start + * screen's company sections, so boards and the landing page always say the same thing. + * @param {AboutBoardSheetProps} props - Interface snapshots and actions. + * @returns {import('react').ReactElement} Sheet. + */ +export function AboutBoardSheet({ nodes, actions }) { + const board = nodes['about-board'] ?? {}; + return ( + actions.emit('about-board-close')} + /> + ); +} diff --git a/src/pages/game/AboutBoardSheet.test.jsx b/src/pages/game/AboutBoardSheet.test.jsx new file mode 100644 index 0000000..f65b603 --- /dev/null +++ b/src/pages/game/AboutBoardSheet.test.jsx @@ -0,0 +1,33 @@ +import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { COMPANY } from '@/config/company.js'; +import { strings } from '@/i18n/fa.js'; +import { AboutBoardSheet } from './AboutBoardSheet.jsx'; + +afterEach(cleanup); +// jsdom has no modal dialogs; open and close them by toggling the attribute. +beforeAll(() => { + HTMLDialogElement.prototype.showModal = function showModal() { + this.setAttribute('open', ''); + }; + HTMLDialogElement.prototype.close = function close() { + this.removeAttribute('open'); + }; +}); + +describe('AboutBoardSheet', () => { + it('shows the board topic in full and reports closing', () => { + const actions = { emit: vi.fn() }; + const nodes = { 'about-board': { open: true, topic: 'contact' } }; + render(); + expect(screen.getByRole('heading', { name: strings.landing.sections.contact })).toBeVisible(); + expect(screen.getByText(COMPANY.contact.email)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: strings.landing.close })); + expect(actions.emit).toHaveBeenCalledWith('about-board-close'); + }); + + it('renders nothing inside while closed', () => { + render(); + expect(screen.queryByText(COMPANY.contact.email)).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/game/GameDialogs.jsx b/src/pages/game/GameDialogs.jsx index ada88db..4563e5a 100644 --- a/src/pages/game/GameDialogs.jsx +++ b/src/pages/game/GameDialogs.jsx @@ -8,6 +8,7 @@ import { UpdateNotice } from './UpdateNotice.jsx'; import { EndDialog } from './EndDialog.jsx'; import { GpsFallbackDialog } from './GpsFallbackDialog.jsx'; import { MachineCard } from './MachineCard.jsx'; +import { AboutBoardSheet } from './AboutBoardSheet.jsx'; /** Composes independently managed game dialogs. */ export function GameDialogs(props) { return ( @@ -22,6 +23,7 @@ export function GameDialogs(props) { + ); } diff --git a/src/pages/game/ShowroomDialog.jsx b/src/pages/game/ShowroomDialog.jsx index 862cbc6..6eab846 100644 --- a/src/pages/game/ShowroomDialog.jsx +++ b/src/pages/game/ShowroomDialog.jsx @@ -1,6 +1,7 @@ import { GameNode } from '@/components/shared/GameNode.jsx'; import { SHOWROOM_CATALOG } from '@/constants/showroomCatalog.js'; import { robotFace } from '@/config/assets.js'; +import { COMPANY } from '@/config/company.js'; import { showroomModelById, formatRial } from '@/utils/showroomAnswers.js'; import { ShowroomImage } from './ShowroomImage.jsx'; import { strings } from '@/i18n/fa.js'; @@ -13,6 +14,7 @@ import { strings } from '@/i18n/fa.js'; export function ShowroomDialog({ nodes, actions }) { const model = showroomModelById(nodes.showroom?.modelId); if (!model) return null; + const isOrdered = nodes.showroom.ordered?.includes(model.id) ?? false; return ( +
+ + + {strings.showroom.contact} + +
{ @@ -17,4 +18,25 @@ describe('showroom robot questions', () => { fireEvent.click(screen.getByRole('button', { name: '۶۰ کانال', hidden: true })); expect(actions.emit).toHaveBeenCalledWith('showroom-select', 'rm-60'); }); + + it('sends a purchase request for the selected model once', () => { + const actions = { emit: vi.fn(), close: vi.fn() }; + const { rerender } = render( + , + ); + fireEvent.click(screen.getByRole('button', { name: strings.showroom.order, hidden: true })); + expect(actions.emit).toHaveBeenCalledWith('showroom-order'); + rerender( + , + ); + expect( + screen.getByRole('button', { name: strings.showroom.ordered, hidden: true }), + ).toBeDisabled(); + expect( + screen.getByRole('link', { name: strings.showroom.contact, hidden: true }), + ).toHaveAttribute('href', expect.stringMatching(/^tel:/)); + }); }); diff --git a/src/pages/game/startScreen/InfoSheet.jsx b/src/pages/game/startScreen/InfoSheet.jsx index 49d080a..6079878 100644 --- a/src/pages/game/startScreen/InfoSheet.jsx +++ b/src/pages/game/startScreen/InfoSheet.jsx @@ -2,18 +2,19 @@ import { Sheet } from '@/components/shared/Sheet.jsx'; import { strings } from '@/i18n/fa.js'; import { SECTION_BODIES } from './InfoSectionContent.jsx'; -/** @typedef {{section: string | null, onClose: function}} InfoSheetProps */ +/** @typedef {{section: string | null, onClose: function, id?: string}} InfoSheetProps */ /** * Shows one company information section in a sheet. - * @param {InfoSheetProps} props - Open section key, or null when closed, and the close callback. + * @param {InfoSheetProps} props - Open section key, or null when closed, the close callback and + * an optional element id (for a second sheet on the same page). * @returns {import('react').ReactElement} Sheet. */ -export function InfoSheet({ section, onClose }) { +export function InfoSheet({ section, onClose, id = 'info-sheet' }) { const Body = SECTION_BODIES[section]; const title = section === 'about' ? strings.landing.history : strings.landing.sections[section]; return ( { +test('Every mission step points at a real machine or factory place 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}`); + if (!step.target) continue; + const known = machineById(step.target) || FACTORY_POINT_IDS.includes(step.target); + assert.ok(known, `target ${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'); + // The About Us and products tours stay in the factory; out in the city, the park leads. + const cityMissions = MISSIONS.filter((m) => + m.steps.every((s) => !FACTORY_POINT_IDS.includes(s.target)), + ); + assert.ok(parkMissions.length > cityMissions.length / 2, 'most city missions are in the park'); }); test('The first mission is: go to the factory, then buy there', () => { @@ -54,12 +63,12 @@ test('The first mission is: go to the factory, then buy there', () => { 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'); + assert.equal(stepTarget(state), 'board-about', 'then the About Us tour, still in the factory'); }); test('Park missions chain locate, goto, inspect, order and buy steps', () => { const state = createState(); - state.missionIndex = 1; + state.missionIndex = state.missionOrder.indexOf('parkGate'); const steps = [ ['locate', 'mellat-park'], ['arrive', 'mellat-park'], @@ -95,3 +104,42 @@ test('The explorer mission counts different city machines and shows progress', ( const items = missionListItems(state); assert.ok(items.every((item) => !item.active && item.steps.length === 0)); }); + +test('Each start mode leads with its own mission and keeps all the others', () => { + assert.equal(missionOrder('offline')[0], 'firstBuy'); + assert.equal(missionOrder('real')[0], 'firstBuy'); + assert.equal(missionOrder('about')[0], 'aboutTour'); + assert.equal(missionOrder('products')[0], 'productsTour'); + const all = MISSIONS.map((m) => m.id).sort(); + for (const mode of ['real', 'offline', 'about', 'products', undefined]) + assert.deepEqual([...missionOrder(mode)].sort(), all); +}); + +test('The About Us tour: read the boards and meet Mr Mohammadi', () => { + const state = createState(); + state.missionOrder = missionOrder('about'); + assert.equal(currentStepText(state), strings.steps.read(strings.points['board-about'])); + const read = (id) => applyMissionEvent(state, { type: 'read', machineId: id }); + assert.equal(read('board-contact').stepDone, false, 'boards are read in order'); + assert.equal(read('board-about').stepDone, true); + assert.equal(stepTarget(state), 'mohammadi'); + assert.equal(applyMissionEvent(state, { type: 'meet', machineId: 'mohammadi' }).stepDone, true); + assert.equal(read('board-contact').mission.id, 'aboutTour'); + assert.equal(currentMission(state).id, 'firstBuy', 'the rest follow in list order'); + assert.equal(missionListItems(state)[0].title, strings.missions.aboutTour); +}); + +test('The products tour: open the hall, ask the robot, send a purchase request', () => { + const state = createState(); + state.missionOrder = missionOrder('products'); + for (const type of ['view', 'ask']) applyMissionEvent(state, { type, machineId: 'showroom' }); + const result = applyMissionEvent(state, { type: 'request', machineId: 'showroom' }); + assert.equal(result.mission.id, 'productsTour'); +}); + +test('Boards show the company details from the config', () => { + assert.equal(boardLines('about')[0], COMPANY.about.history); + assert.equal(boardLines('about').length, 1 + COMPANY.about.values.length); + assert.ok(boardLines('contact').some((line) => line.includes(COMPANY.contact.phone))); + assert.ok(boardLines('address')[0].includes(COMPANY.contact.address)); +});