Turn the game into a third-person Mashhad city game
Replace the linear forest walk with an open area of real western Mashhad built from OpenStreetMap data, starting at Park Mellat. A father and child, guided by the Robot Market robot, find vending machines at a park, school, universities, a metro station and a mall, on foot or by car. - Iran province briefing map; only Mashhad is unlocked - Third-person camera, building collision, arcade car and ambient traffic - Minimap and full city map with addresses, opening hours and A* routing - Purchase scoring, missions with cash rewards, ranks and an end screen - Map data build script (OSM + Natural Earth) and new logic tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,44 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {canWalk,movementDirection} from '../src/controls.js';
|
||||
import {products,createState,purchase,INITIAL_BALANCE,formatHetoman} from '../src/game-state.js';
|
||||
import {createFamily} from '../src/characters.js';
|
||||
test('Dialogue pauses walking; arrows up/down only zoom',()=>{
|
||||
assert.equal(canWalk({chapterId:'bridge'}),false);assert.equal(canWalk({chapterId:'fish'}),false);
|
||||
assert.ok(canWalk({chapterId:null}));
|
||||
assert.equal(canWalk({busy:true}),false);assert.equal(canWalk({shopOpen:true}),false);
|
||||
assert.equal(movementDirection(new Set(['arrowup']),new Set()),0);
|
||||
assert.equal(movementDirection(new Set(['arrowright']),new Set()),1);
|
||||
assert.equal(movementDirection(new Set(),new Set([-1])),-1);
|
||||
});
|
||||
test('Toman balance, charges and picnic reserve',()=>{
|
||||
assert.equal(INITIAL_BALANCE,500);assert.match(formatHetoman(15),/هتومان/);
|
||||
for(const p of products){const s=createState();assert.ok(purchase(s,p.id));assert.equal(s.balance,INITIAL_BALANCE-p.cost);}
|
||||
const s={...createState(),balance:40};assert.equal(purchase(s,'channel-22'),null);assert.equal(s.balance,40);assert.ok(purchase(s,'channel-23'));s.busy=false;assert.ok(purchase(s,'channel-24'));assert.equal(s.balance,0);
|
||||
});
|
||||
test('Character transforms stay finite through walk, pickup and sitting',()=>{
|
||||
const f=createFamily();for(const facing of [-Math.PI/2,Math.PI/2,Math.PI])for(const mode of ['walk','shop','sit']){
|
||||
f.update({time:3,gait:2,dt:.016,moving:mode==='walk',facing,shopping:mode==='shop',finished:mode==='sit'});f.group.updateMatrixWorld(true);f.group.traverse(o=>assert.ok(o.matrixWorld.elements.every(Number.isFinite)));
|
||||
}
|
||||
});
|
||||
|
||||
test('Sixty channels, exactly 21–60 purchasable, Hetoman conversion',async()=>{
|
||||
const {channels,productByCode,hetomanToRial}=await import('../src/game-state.js');
|
||||
assert.equal(channels.length,60);assert.equal(products.length,40);
|
||||
assert.equal(hetomanToRial(10),100000);
|
||||
for(let n=1;n<=60;n++){assert.equal(Boolean(productByCode(String(n))),n>=21);}
|
||||
assert.equal(productByCode('۲۲').channel,22);assert.equal(productByCode('٦٠').channel,60);assert.equal(productByCode('61'),null);
|
||||
const s=createState();assert.equal(purchase(s,'channel-20'),null);assert.equal(s.balance,500);
|
||||
});
|
||||
|
||||
test('Robot Market mascot replaces the child and keeps its articulated rig',()=>{
|
||||
const family=createFamily();
|
||||
const robot=family.group.getObjectByName('robot-market-mascot');
|
||||
assert.ok(robot);
|
||||
assert.equal(family.group.children.filter(node=>node.type==='Group').length,2);
|
||||
for(const mode of ['walk','shop','sit']){
|
||||
family.update({time:2,gait:1,dt:.016,moving:mode==='walk',facing:Math.PI/2,shopping:mode==='shop',delivery:mode==='shop',finished:mode==='sit'});
|
||||
family.group.updateMatrixWorld(true);
|
||||
robot.traverse(node=>assert.ok(node.matrixWorld.elements.every(Number.isFinite)));
|
||||
}
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createJourney, advanceJourney, tickJourney} from '../src/journey.js';
|
||||
|
||||
test('Driving requires player input and exit requires F at the entrance', () => {
|
||||
const j=createJourney();tickJourney(j,20);assert.equal(j.phase,'home');
|
||||
assert.ok(advanceJourney(j));tickJourney(j,2,true);assert.equal(j.phase,'boarding');
|
||||
tickJourney(j,2);assert.equal(j.phase,'driving');
|
||||
tickJourney(j,30);assert.equal(j.distance,0);assert.equal(j.phase,'driving');assert.equal(advanceJourney(j),false);
|
||||
for(let i=0;i<200;i++)tickJourney(j,.05,false,{throttle:1});
|
||||
assert.equal(j.phase,'entrance');assert.equal(j.distance,21);assert.equal(j.speed,0);
|
||||
tickJourney(j,20);assert.equal(j.phase,'entrance');assert.ok(advanceJourney(j));assert.equal(j.phase,'walking');
|
||||
assert.equal(advanceJourney(j),false);assert.equal(createJourney().distance,0);
|
||||
});
|
||||
test('Brake, steering bounds and pause work while driving', () => {
|
||||
const j=createJourney();advanceJourney(j);tickJourney(j,2);
|
||||
for(let i=0;i<20;i++)tickJourney(j,.05,false,{throttle:1,steer:1});
|
||||
assert.ok(j.speed>0);assert.ok(j.lane>0 && j.lane<=.75);
|
||||
const snapshot={...j};tickJourney(j,1,true,{throttle:1});assert.deepEqual(j,snapshot);
|
||||
for(let i=0;i<30;i++)tickJourney(j,.05,false,{throttle:-1});
|
||||
assert.equal(j.speed,0);assert.equal(j.phase,'driving');
|
||||
});
|
||||
+43
-11
@@ -1,15 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createState,productByCode} from '../src/game-state.js';
|
||||
import {createPayment,beginPayment,swipePayment,cancelPayment} from '../src/payment.js';
|
||||
test('Payment charges once, only after ready and explicit swipe',()=>{
|
||||
const s=createState(),p=createPayment(),product=productByCode('21');assert.ok(product);
|
||||
assert.ok(beginPayment(s,p,product));assert.equal(s.balance,500);assert.equal(s.basket.length,0);assert.equal(s.busy,true);
|
||||
assert.equal(swipePayment(s,p),false);assert.equal(beginPayment(s,p,product),false);
|
||||
p.phase='ready';assert.ok(swipePayment(s,p));assert.equal(s.balance,500-product.cost);assert.equal(s.basket.length,1);
|
||||
assert.equal(swipePayment(s,p),false);assert.equal(cancelPayment(s,p),false);
|
||||
import {createState, purchaseBlocker, priceAt, canAffordAnything, formatClock} from '../src/game-state.js';
|
||||
import {createPayment, beginPayment, readyPayment, swipePayment, cancelPayment} from '../src/payment.js';
|
||||
import {machineById, machines} from '../src/locations.js';
|
||||
|
||||
const park = machineById('mellat-park'), school = machineById('school-rahil'), uni = machineById('ferdowsi-university');
|
||||
|
||||
test('Payment charges once, only after ready and an explicit swipe', () => {
|
||||
const state = createState(), payment = createPayment();
|
||||
assert.ok(beginPayment(state, payment, park, 'juice'));
|
||||
assert.equal(state.balance, 500);
|
||||
assert.equal(swipePayment(state, payment), false, 'not ready yet');
|
||||
assert.ok(readyPayment(payment));
|
||||
assert.ok(swipePayment(state, payment));
|
||||
assert.equal(state.balance, 465);
|
||||
assert.equal(swipePayment(state, payment), false, 'no double charge');
|
||||
assert.equal(state.purchases.length, 1);
|
||||
assert.equal(beginPayment(state, createPayment(), park, 'water'), false, 'busy until delivery ends');
|
||||
});
|
||||
test('Cancel or invalid payment never changes balance or basket',()=>{
|
||||
const s=createState(),p=createPayment();assert.equal(beginPayment(s,p,productByCode('20')),false);
|
||||
assert.ok(beginPayment(s,p,productByCode('60')));assert.ok(cancelPayment(s,p));assert.equal(s.balance,500);assert.equal(s.basket.length,0);assert.equal(s.busy,false);
|
||||
|
||||
test('Cancel, closed machines, unknown products and low funds never charge', () => {
|
||||
const state = createState(), payment = createPayment();
|
||||
assert.ok(beginPayment(state, payment, park, 'cake'));
|
||||
assert.ok(cancelPayment(state, payment));
|
||||
assert.equal(state.balance, 500);
|
||||
assert.equal(state.busy, false);
|
||||
assert.equal(purchaseBlocker(state, park, 'not-a-product'), 'unknown');
|
||||
assert.equal(purchaseBlocker(state, school, 'nuts'), 'unknown', 'school does not sell nuts');
|
||||
state.minutes = 14 * 60;
|
||||
assert.equal(purchaseBlocker(state, school, 'water'), 'closed');
|
||||
state.minutes = 9 * 60;
|
||||
state.balance = 10;
|
||||
assert.equal(purchaseBlocker(state, park, 'water'), 'funds');
|
||||
assert.equal(beginPayment(state, payment, park, 'water'), false);
|
||||
});
|
||||
|
||||
test('Prices follow machine discounts and the clock is Persian', () => {
|
||||
assert.equal(priceAt(uni, 'nuts'), 52);
|
||||
assert.equal(priceAt(park, 'nuts'), 65);
|
||||
assert.equal(formatClock(8 * 60 + 5), '۰۸:۰۵');
|
||||
const state = createState();
|
||||
state.balance = 5;
|
||||
assert.equal(canAffordAnything(state, machines), false);
|
||||
state.balance = 15;
|
||||
assert.ok(canAffordAnything(state, machines));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {awardsFor, rankFor, completeMissions, activeMission, POINTS, RANKS} from '../src/scoring.js';
|
||||
import {createState, purchase} from '../src/game-state.js';
|
||||
import {machineById} from '../src/locations.js';
|
||||
|
||||
const total = awards => awards.reduce((s, a) => s + a.points, 0);
|
||||
|
||||
test('Discovery, combo and all-category bonuses are paid once', () => {
|
||||
const state = createState(), park = machineById('mellat-park');
|
||||
const buy = (machine, product) => {
|
||||
const before = state.purchases.slice();
|
||||
return awardsFor(before, purchase(state, machine, product));
|
||||
};
|
||||
assert.equal(total(buy(park, 'water')), POINTS.purchase + POINTS.discovery);
|
||||
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('school-rahil'), 'water');
|
||||
buy(machineById('ferdowsi-university'), 'water');
|
||||
buy(machineById('azadi-metro'), 'water');
|
||||
state.minutes = 11 * 60;
|
||||
const last = buy(machineById('mashhad-mall'), 'water');
|
||||
assert.ok(last.some(a => a.points === POINTS.allCategories));
|
||||
});
|
||||
|
||||
test('Ranks and missions advance with progress', () => {
|
||||
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, 'first');
|
||||
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), [], 'rewards are not repeated');
|
||||
assert.equal(activeMission(state).id, 'school');
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createStory,completeChapter,nextChapter,storyReady,routeOffset,discoveries} from '../src/story.js';
|
||||
import {createState,purchase,readyForPicnic,productByCode} from '../src/game-state.js';
|
||||
for(const route of ['sunny'])test(`Complete ${route} story and picnic`,()=>{
|
||||
const story=createStory();assert.equal(nextChapter(story,-8),'morning');
|
||||
for(const [id,x,choice] of [['morning',-8],['rabbit',-3,'watch'],['bridge',-2],['fish',0],['camp',3,'ask'],['courage',7],['wind',12],['paper',14,'recover'],['fork',16,route],[route,19]]){
|
||||
assert.equal(nextChapter(story,x),id);assert.ok(completeChapter(story,id,choice));assert.equal(completeChapter(story,id,choice),false);
|
||||
}
|
||||
assert.ok(storyReady(story));assert.equal(nextChapter(story,22),null);assert.ok(discoveries(story).length>=5);
|
||||
assert.equal(routeOffset(story,16),0);assert.equal(routeOffset(story,21),0);assert.equal(Math.sign(routeOffset(story,18)),route==='sunny'?1:-1);
|
||||
assert.equal(completeChapter(story,'summit','invalid'),false);assert.ok(completeChapter(story,'summit','gold'));assert.equal(story.sky,'gold');
|
||||
const state=createState();assert.ok(purchase(state,productByCode('۲۲').id));state.busy=false;assert.ok(purchase(state,productByCode('۲۴').id));assert.ok(readyForPicnic(state));assert.equal(state.balance,440);
|
||||
});
|
||||
test('No early triggers, unknown choices, or leaked reset state',()=>{
|
||||
const story=createStory();assert.equal(storyReady(story),false);completeChapter(story,'morning');assert.equal(nextChapter(story,-8),null);
|
||||
assert.equal(completeChapter(story,'rabbit','wrong'),false);assert.equal(completeChapter(story,'unknown'),false);
|
||||
assert.deepEqual(discoveries(createStory()),[]);assert.equal(routeOffset(createStory(),18),0);
|
||||
});
|
||||
|
||||
test('Every story action exposes one fixed action instead of choices',async()=>{
|
||||
const {chapters}=await import('../src/story.js');
|
||||
for(const chapter of Object.values(chapters))assert.equal(chapter.choices,undefined);
|
||||
for(const id of ['rabbit','paper','fork','summit','camp']){const s=createStory();assert.equal(completeChapter(s,id,'wrong'),false);assert.ok(completeChapter(s,id,chapters[id].action.value));}
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
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';
|
||||
|
||||
const data = JSON.parse(readFileSync(new URL('../src/data/mellat.json', import.meta.url)));
|
||||
|
||||
test('Projection round-trips and bearings follow the compass', () => {
|
||||
const p = toLocal(36.32092, 59.53744), back = toLatLon(p.x, p.z);
|
||||
assert.ok(Math.abs(back.lat - 36.32092) < 1e-9 && Math.abs(back.lon - 59.53744) < 1e-9);
|
||||
assert.ok(p.z < 0, 'north of the origin is -z');
|
||||
assert.deepEqual(toLocal(ORIGIN.lat, ORIGIN.lon), {x: 0, z: 0});
|
||||
assert.equal(bearing(0, -1), 0);
|
||||
assert.equal(bearing(1, 0), 90);
|
||||
});
|
||||
|
||||
test('Circles are pushed out of footprints and segments stop at walls', () => {
|
||||
const world = createCollisionWorld([[0, 0, 10, 0, 10, 10, 0, 10]]);
|
||||
assert.ok(blocked(world, 5, 5));
|
||||
const out = resolveCircle(world, 9.5, 5, 1);
|
||||
assert.ok(out.hit && out.x >= 10.99);
|
||||
assert.equal(resolveCircle(world, 20, 20, 1).hit, false);
|
||||
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', () => {
|
||||
const walk = buildGraph(data.roads), drive = buildGraph(data.roads, {drivable: true});
|
||||
const start = machines[0].anchor;
|
||||
for (const m of machines) {
|
||||
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);
|
||||
assert.ok(routeBetween(drive, start.x, start.z, m.anchor.x, m.anchor.z), `drive route to ${m.id}`);
|
||||
}
|
||||
const street = nearestSegment(buildSegmentIndex(data.roads), 494, 280, 30, s => s[5] >= 0);
|
||||
assert.equal(data.names[street.name], 'بولوار ملک آباد');
|
||||
});
|
||||
|
||||
test('Car accelerates, steers right with positive input and brakes', () => {
|
||||
const car = createCar(0, 0, 0);
|
||||
for (let i = 0; i < 60; i++) stepCar(car, {throttle: 1}, 1 / 30);
|
||||
assert.ok(car.speed > 10 && car.x > 10 && Math.abs(car.z) < 1e-9);
|
||||
for (let i = 0; i < 30; i++) stepCar(car, {throttle: 1, steer: 1}, 1 / 30);
|
||||
assert.ok(car.z > 0, 'turning right while heading east moves south (+z)');
|
||||
for (let i = 0; i < 90; i++) stepCar(car, {throttle: -1}, 1 / 30);
|
||||
assert.ok(car.speed <= 0);
|
||||
});
|
||||
Reference in New Issue
Block a user