Start at the factory, add PWA install and GPS real-world mode

The game now begins inside the Negin Sazan Pishgam Radmehr Atousa
vending-machine factory on the Mashhad–Chenaran road, and the world
extends ~20 km along the real driving route to Park Mellat and
Ferdowsi University.

- Factory compound: production hall with assembly line, office, yard,
  gate and company signs; first mission at its half-price demo machine
- Map data merged from four OSM extracts (factory zone, route corridor,
  region main roads, city zone), clipped to a 15 x 18 km region
- City meshes chunked and culled by distance; traffic recycled around
  the player; minimap and city map drawn from cached tiles + overview
- The Robot Market robot no longer walks with the family: it comes out
  of a machine when they arrive, greets them with expression portraits
  and goes back inside when they leave
- PWA: manifest, icons from the robot avatar, offline service worker
  generated at build time, install button / iOS steps, update prompt;
  Vazirmatn font bundled instead of Google Fonts
- Real-world mode: safety notice, GPS + compass tracking with accuracy
  smoothing, 20-40 m arrival radius, speed lock and in-person bonus

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 08:56:06 +03:30
co-authored by Claude Opus 5.5
parent e2a1b5fba1
commit 0939dac9cd
39 changed files with 1111 additions and 228 deletions
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {smoothFix, arrivalRadius, insideWorld} from '../src/realworld.js';
test('GPS fixes blend by accuracy and a stale estimate is replaced', () => {
const first = smoothFix(null, {x: 0, z: 0, accuracy: 10, time: 0});
assert.deepEqual([first.x, first.z], [0, 0]);
const equal = smoothFix(first, {x: 10, z: 0, accuracy: 10, time: 1000});
assert.ok(Math.abs(equal.x - 5) < 1e-9, 'equal accuracy meets halfway');
assert.ok(equal.accuracy < 10, 'two fixes are better than one');
const poor = smoothFix(first, {x: 50, z: 0, accuracy: 60, time: 1000});
assert.ok(poor.x < 3, 'a poor fix barely moves a good estimate');
const stale = smoothFix(first, {x: 99, z: 0, accuracy: 30, time: 60000});
assert.equal(stale.x, 99);
});
test('Arrival radius widens with poor GPS and bounds check the world', () => {
assert.equal(arrivalRadius(5), 20);
assert.equal(arrivalRadius(20), 30);
assert.equal(arrivalRadius(100), 40);
const bounds = {minX: -10, minZ: -10, maxX: 10, maxZ: 10};
assert.ok(insideWorld(bounds, 0, 0));
assert.equal(insideWorld(bounds, 11, 0), false);
});
+6 -4
View File
@@ -16,6 +16,7 @@ test('Discovery, combo and all-category bonuses are paid once', () => {
assert.equal(total(buy(park, 'juice')), POINTS.purchase, 'same type: no combo');
assert.equal(total(buy(park, 'cake')), POINTS.purchase + POINTS.combo);
assert.equal(total(buy(park, 'water')), POINTS.purchase, 'combo only once');
buy(machineById('factory'), 'water');
buy(machineById('school-rahil'), 'water');
buy(machineById('ferdowsi-university'), 'water');
buy(machineById('azadi-metro'), 'water');
@@ -29,11 +30,12 @@ test('Ranks and missions advance with progress', () => {
assert.equal(rankFor(RANKS[1].xp).index, 1);
assert.equal(rankFor(1e6).progress, 1);
const state = createState();
assert.equal(activeMission(state).id, 'first');
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');
const done = completeMissions(state);
assert.deepEqual(done.map(m => m.id), ['first']);
assert.equal(state.balance, 500 - 15 + 30);
assert.deepEqual(completeMissions(state).map(m => m.id), ['first']);
assert.deepEqual(completeMissions(state), [], 'rewards are not repeated');
assert.equal(activeMission(state).id, 'school');
});
+6 -4
View File
@@ -5,7 +5,7 @@ import {toLocal, toLatLon, bearing, ORIGIN} from '../src/geo.js';
import {createCollisionWorld, resolveCircle, blocked, clearFraction} from '../src/collision.js';
import {buildGraph, routeBetween, nearestSegment, buildSegmentIndex} from '../src/routing.js';
import {createCar, stepCar} from '../src/vehicle.js';
import {machines} from '../src/locations.js';
import {machines, machineById, FACTORY} from '../src/locations.js';
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url)));
@@ -27,10 +27,12 @@ test('Circles are pushed out of footprints and segments stop at walls', () => {
assert.ok(Math.abs(clearFraction(world, -10, 5, 10, 5) - .5) < .06);
});
test('Real map: every machine is reachable on foot and by car from the start', () => {
test('Real map: the factory connects by car to the city, and every city machine is reachable', () => {
const walk = buildGraph(data.roads), drive = buildGraph(data.roads, {drivable: true});
const start = machines[0].anchor;
for (const m of machines) {
const toCity = routeBetween(drive, FACTORY.center.x, FACTORY.center.z, -445.8, -275.7);
assert.ok(toCity && toCity.length > 18000 && toCity.length < 23000, `factory → Park Mellat is ~20 km (${toCity?.length})`);
const start = machineById('mellat-park').anchor;
for (const m of machines.filter(m => m.anchor)) {
const onFoot = routeBetween(walk, start.x, start.z, m.anchor.x, m.anchor.z);
assert.ok(onFoot, `walk route to ${m.id}`);
assert.ok(onFoot.length >= Math.hypot(m.anchor.x - start.x, m.anchor.z - start.z) - 1);