sindicate 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ // GTA-style road routing. Build a GRAPH from the RN.roads polylines once (roads are static —
2
+ // the graph is cached on first use / the loading screen, never rebuilt), then A* a path from
3
+ // the player to a map waypoint so the minimap and the big map can draw a route that follows
4
+ // the actual roads. The ROUTE portion is entirely on roads (the user's rule); only two short
5
+ // off-road STUBS leave the net — you to the nearest road, and the road to the exact pin.
6
+ //
7
+ // Why a real graph and not fable's vertex-weld: western's roads MEET in three ways —
8
+ // 1. a shared vertex (the east FORK; every branch mouth the county author landed on a trunk
9
+ // vertex at 0.00 m),
10
+ // 2. a proper CROSSING (the trackside service road crosses the north road at x=0, mid-segment
11
+ // on both — no shared vertex there),
12
+ // 3. a near-touch T (a mouth a few metres off a trunk).
13
+ // nodeAt() welds (1); the pairwise MEET pass below splits both segments at (2)/(3) and threads a
14
+ // shared node through the join, so the whole net is one connected graph. And route() snaps each
15
+ // endpoint to the nearest point ON AN EDGE (mid-segment), not merely the nearest vertex, so you
16
+ // join the road where you actually reach it, not at the next survey peg 30 m up the line.
17
+ //
18
+ // Headless-safe: imports only RN.roads + roadDistance (pure data/math). scripts/verify-roadgraph.mjs
19
+ // exercises this exact module offline.
20
+ // the road polylines + distance oracle are GAME data: setRoadNetwork({ roads, roadDistance })
21
+ let RN = { roads: [], roadDistance: () => 1e9 };
22
+ export function setRoadNetwork(n) { RN = { ...RN, ...n }; }
23
+
24
+ const WELD = 3; // coincident road vertices within 3 m are one junction node
25
+ const TOUCH = 6; // two roads passing within 6 m MEET (a crossing or a mouth on a trunk)
26
+ const STUB_MIN = 4; // an off-road stub shorter than this is just standing on the road — drop it
27
+ const FAR = 600; // an endpoint farther than this from ANY road has no sensible road route
28
+ const CRUISE = 9; // m/s, a steady canter (game/horse.js gaitFor) — for the ETA readout
29
+
30
+ // ── geometry helpers (no allocation in the hot path) ─────────────────────────────────────────
31
+ // closest point on segment a→b to (px,pz); returns param t∈[0,1] and the foot's coords + sq dist.
32
+ function segClosest(px, pz, ax, az, bx, bz) {
33
+ const dx = bx - ax, dz = bz - az;
34
+ const L2 = dx * dx + dz * dz;
35
+ const t = L2 ? Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / L2)) : 0;
36
+ const x = ax + dx * t, z = az + dz * t;
37
+ const ex = px - x, ez = pz - z;
38
+ return { t, x, z, d2: ex * ex + ez * ez };
39
+ }
40
+ // proper 2D intersection of segments a→b and c→d, or null if parallel / not crossing within both.
41
+ function segInt(ax, az, bx, bz, cx, cz, dx, dz) {
42
+ const rx = bx - ax, rz = bz - az, sx = dx - cx, sz = dz - cz;
43
+ const den = rx * sz - rz * sx;
44
+ if (Math.abs(den) < 1e-9) return null; // parallel — near-touch pass handles it
45
+ const t = ((cx - ax) * sz - (cz - az) * sx) / den;
46
+ const u = ((cx - ax) * rz - (cz - az) * rx) / den;
47
+ if (t < 0 || t > 1 || u < 0 || u > 1) return null;
48
+ return { t, u, x: ax + rx * t, z: az + rz * t };
49
+ }
50
+
51
+ // ── the graph ────────────────────────────────────────────────────────────────────────────────
52
+ let G = null; // { nodes:[{x,z,edges:[{to,d}]}], edges:[{a,b,ax,az,bx,bz,len}] }
53
+ // A* scratch, sized to node-count + 2 synthetic endpoints, allocated ONCE and refilled per route.
54
+ let _g, _f, _came, _open;
55
+
56
+ function build() {
57
+ const nodes = [];
58
+ const grid = new Map(); // spatial hash for welding coincident points
59
+ const key = (gx, gz) => gx + ',' + gz;
60
+ function nodeAt(x, z) {
61
+ const gx = Math.round(x / WELD), gz = Math.round(z / WELD);
62
+ for (let i = -1; i <= 1; i++) for (let j = -1; j <= 1; j++) {
63
+ const arr = grid.get(key(gx + i, gz + j));
64
+ if (arr) for (const id of arr) { const n = nodes[id]; if ((n.x - x) ** 2 + (n.z - z) ** 2 < WELD * WELD) return id; }
65
+ }
66
+ const id = nodes.length; nodes.push({ x, z, edges: [] });
67
+ const k = key(gx, gz); let a = grid.get(k); if (!a) grid.set(k, (a = [])); a.push(id);
68
+ return id;
69
+ }
70
+
71
+ // Flatten RN.roads into segments (each remembers its road, so we never MEET a road with itself),
72
+ // then find every place two DIFFERENT roads meet and record a shared point on both segments.
73
+ const segs = []; // { ri, ax,az,bx,bz, meets:[{t,x,z}] }
74
+ RN.roads.forEach((road, ri) => {
75
+ for (let i = 0; i < road.length - 1; i++)
76
+ segs.push({ ri, ax: road[i].x, az: road[i].z, bx: road[i + 1].x, bz: road[i + 1].z, meets: [] });
77
+ });
78
+ for (let i = 0; i < segs.length; i++) {
79
+ const s = segs[i];
80
+ const s0x = Math.min(s.ax, s.bx) - TOUCH, s1x = Math.max(s.ax, s.bx) + TOUCH;
81
+ const s0z = Math.min(s.az, s.bz) - TOUCH, s1z = Math.max(s.az, s.bz) + TOUCH;
82
+ for (let j = i + 1; j < segs.length; j++) {
83
+ const t = segs[j];
84
+ if (t.ri === s.ri) continue; // a road meets OTHER roads, not itself
85
+ if (Math.max(t.ax, t.bx) < s0x || Math.min(t.ax, t.bx) > s1x ||
86
+ Math.max(t.az, t.bz) < s0z || Math.min(t.az, t.bz) > s1z) continue; // AABB reject
87
+ const X = segInt(s.ax, s.az, s.bx, s.bz, t.ax, t.az, t.bx, t.bz);
88
+ if (X) { // proper crossing — the SAME point on both
89
+ s.meets.push({ t: X.t, x: X.x, z: X.z });
90
+ t.meets.push({ t: X.u, x: X.x, z: X.z });
91
+ continue;
92
+ }
93
+ // near-touch: the closest of the four endpoint→segment approaches. If within TOUCH, the
94
+ // point on the TRUNK (the projection) is the junction; both segments get it (the mouth gets
95
+ // it at its own endpoint param, so a ≤TOUCH connector edge threads the join).
96
+ const cands = [
97
+ { on: t, m: segClosest(s.ax, s.az, t.ax, t.az, t.bx, t.bz), et: 0, es: s },
98
+ { on: t, m: segClosest(s.bx, s.bz, t.ax, t.az, t.bx, t.bz), et: 1, es: s },
99
+ { on: s, m: segClosest(t.ax, t.az, s.ax, s.az, s.bx, s.bz), et: 0, es: t },
100
+ { on: s, m: segClosest(t.bx, t.bz, s.ax, s.az, s.bx, s.bz), et: 1, es: t },
101
+ ];
102
+ let best = null;
103
+ for (const c of cands) if (!best || c.m.d2 < best.m.d2) best = c;
104
+ if (best.m.d2 < TOUCH * TOUCH) {
105
+ best.on.meets.push({ t: best.m.t, x: best.m.x, z: best.m.z }); // on the trunk
106
+ best.es.meets.push({ t: best.et, x: best.m.x, z: best.m.z }); // welded at the mouth
107
+ }
108
+ }
109
+ }
110
+
111
+ // Turn each segment into a node chain: endpoint A, its meets in order along the segment, endpoint
112
+ // B. Shared meet coords resolve (via nodeAt) to one id, so the two roads connect there.
113
+ for (const s of segs) {
114
+ const pts = [{ t: 0, x: s.ax, z: s.az }, ...s.meets, { t: 1, x: s.bx, z: s.bz }];
115
+ pts.sort((p, q) => p.t - q.t);
116
+ let prev = -1;
117
+ for (const p of pts) {
118
+ const id = nodeAt(p.x, p.z);
119
+ if (prev >= 0 && prev !== id) {
120
+ const a = nodes[prev], b = nodes[id];
121
+ const d = Math.hypot(a.x - b.x, a.z - b.z);
122
+ if (d > 1e-6) { a.edges.push({ to: id, d }); b.edges.push({ to: prev, d }); }
123
+ }
124
+ prev = id;
125
+ }
126
+ }
127
+
128
+ // A flat edge list for snapping (a point can join a road mid-segment, not just at a node).
129
+ const edges = [];
130
+ for (let a = 0; a < nodes.length; a++)
131
+ for (const e of nodes[a].edges)
132
+ if (a < e.to) {
133
+ const n = nodes[e.to];
134
+ edges.push({ a, b: e.to, ax: nodes[a].x, az: nodes[a].z, bx: n.x, bz: n.z, len: e.d });
135
+ }
136
+
137
+ G = { nodes, edges };
138
+ const cap = nodes.length + 2; // + SRC and DST synthetic endpoints
139
+ _g = new Float64Array(cap); _f = new Float64Array(cap);
140
+ _came = new Int32Array(cap); _open = new Uint8Array(cap);
141
+ return G;
142
+ }
143
+
144
+ // Build (or return) the road graph. Call on the loading screen to keep the first route cheap.
145
+ export function ensureRoadGraph() { return G || build(); }
146
+
147
+ // Nearest point ON THE ROAD NETWORK to (x,z): which edge, the foot's coords, and the distance.
148
+ function nearestEdge(x, z) {
149
+ let bi = -1, bt = 0, bx = 0, bz = 0, bd2 = Infinity;
150
+ for (let i = 0; i < G.edges.length; i++) {
151
+ const e = G.edges[i];
152
+ const c = segClosest(x, z, e.ax, e.az, e.bx, e.bz);
153
+ if (c.d2 < bd2) { bd2 = c.d2; bi = i; bt = c.t; bx = c.x; bz = c.z; }
154
+ }
155
+ return { ei: bi, t: bt, x: bx, z: bz, dist: Math.sqrt(bd2) };
156
+ }
157
+ // Public snap helper (endpoint diagnostics / a future "you are here" dot on the road).
158
+ export function nearestRoadPoint(x, z) {
159
+ if (!ensureRoadGraph().edges.length) return null;
160
+ const s = nearestEdge(x, z);
161
+ return { x: s.x, z: s.z, dist: s.dist };
162
+ }
163
+
164
+ const segLen = (pts) => { let l = 0; for (let i = 1; i < pts.length; i++) l += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].z - pts[i - 1].z); return l; };
165
+
166
+ // ROUTE. Snap both ends onto the nearest ROAD EDGE, A* between the snap points over the graph, and
167
+ // return the path as world points [from]→[snap-on]→road nodes→[snap-off]→[to]. Robust: an island
168
+ // waypoint, start==end, or a disconnected pair fall back to a straight dashed line — never a throw.
169
+ // Returns { reachable, points:[{x,z}], segments:[{pts,style}], lengthM, roadLengthM, directM, detour, etaS }.
170
+ export function route(fx, fz, tx, tz) {
171
+ ensureRoadGraph();
172
+ const directM = Math.hypot(tx - fx, tz - fz);
173
+ const straight = () => ({
174
+ reachable: false, points: [{ x: fx, z: fz }, { x: tx, z: tz }],
175
+ segments: [{ pts: [{ x: fx, z: fz }, { x: tx, z: tz }], style: 'dash' }],
176
+ lengthM: directM, roadLengthM: 0, directM, detour: 1, etaS: directM / CRUISE,
177
+ });
178
+ if (!G.edges.length || directM < 1e-3) return straight();
179
+
180
+ const S = nearestEdge(fx, fz), T = nearestEdge(tx, tz);
181
+ // Deep wilderness: an end too far from any road, or a hop-to-road longer than the whole trip,
182
+ // makes a road route nonsense — draw the honest crow-flies line instead.
183
+ if (S.dist > FAR || T.dist > FAR || S.dist + T.dist > directM) return straight();
184
+
185
+ const N = G.nodes.length, SRC = N, DST = N + 1, INF = Infinity;
186
+ const eS = G.edges[S.ei], eT = G.edges[T.ei];
187
+ const dist = (px, pz, qx, qz) => Math.hypot(px - qx, pz - qz);
188
+ const dSu = dist(S.x, S.z, eS.ax, eS.az), dSv = dist(S.x, S.z, eS.bx, eS.bz);
189
+ const dTp = dist(T.x, T.z, eT.ax, eT.az), dTq = dist(T.x, T.z, eT.bx, eT.bz);
190
+ const sameEdge = S.ei === T.ei;
191
+ const posX = (i) => i === SRC ? S.x : i === DST ? T.x : G.nodes[i].x;
192
+ const posZ = (i) => i === SRC ? S.z : i === DST ? T.z : G.nodes[i].z;
193
+ const h = (i) => Math.hypot(posX(i) - T.x, posZ(i) - T.z);
194
+
195
+ _g.fill(INF, 0, N + 2); _f.fill(INF, 0, N + 2); _came.fill(-1, 0, N + 2); _open.fill(0, 0, N + 2);
196
+ _g[SRC] = 0; _f[SRC] = h(SRC); _open[SRC] = 1;
197
+ let openCount = 1;
198
+ // synthetic adjacency: SRC → the two ends of its edge (and straight to DST if on the same edge);
199
+ // reaching either end of the target edge → DST. Base nodes use their own edge lists.
200
+ const relax = (cur, to, w) => {
201
+ const t = _g[cur] + w;
202
+ if (t < _g[to]) { _came[to] = cur; _g[to] = t; _f[to] = t + h(to); if (!_open[to]) { _open[to] = 1; openCount++; } }
203
+ };
204
+ while (openCount) {
205
+ let cur = -1, cf = INF;
206
+ for (let i = 0; i < N + 2; i++) if (_open[i] && _f[i] < cf) { cf = _f[i]; cur = i; }
207
+ if (cur < 0) break;
208
+ if (cur === DST) break;
209
+ _open[cur] = 0; openCount--;
210
+ if (cur === SRC) {
211
+ relax(SRC, eS.a, dSu); relax(SRC, eS.b, dSv);
212
+ if (sameEdge) relax(SRC, DST, dist(S.x, S.z, T.x, T.z));
213
+ } else {
214
+ for (const e of G.nodes[cur].edges) relax(cur, e.to, e.d);
215
+ if (cur === eT.a) relax(cur, DST, dTp);
216
+ if (cur === eT.b) relax(cur, DST, dTq);
217
+ }
218
+ }
219
+ if (_came[DST] < 0) return straight(); // graph disconnected between the two ends
220
+
221
+ // Reconstruct SRC→DST. SRC is the snap-on point, DST the snap-off point, base ids are road nodes.
222
+ const chain = [];
223
+ for (let c = DST; c >= 0; c = _came[c]) chain.push(c);
224
+ chain.reverse();
225
+ const road = chain.map((i) => ({ x: posX(i), z: posZ(i) })); // [snap-on, road…, snap-off]
226
+
227
+ const points = [];
228
+ const push = (p) => { const l = points[points.length - 1]; if (!l || Math.hypot(l.x - p.x, l.z - p.z) > 1e-6) points.push(p); };
229
+ push({ x: fx, z: fz });
230
+ for (const p of road) push(p);
231
+ push({ x: tx, z: tz });
232
+
233
+ const segments = [];
234
+ const onFirst = road[0], onLast = road[road.length - 1];
235
+ if (Math.hypot(onFirst.x - fx, onFirst.z - fz) > STUB_MIN) segments.push({ pts: [{ x: fx, z: fz }, onFirst], style: 'dash' });
236
+ segments.push({ pts: road, style: 'solid' });
237
+ if (Math.hypot(onLast.x - tx, onLast.z - tz) > STUB_MIN) segments.push({ pts: [onLast, { x: tx, z: tz }], style: 'dash' });
238
+
239
+ const roadLengthM = segLen(road);
240
+ const lengthM = segments.reduce((l, s) => l + segLen(s.pts), 0);
241
+ // A road route that more than triples the crow-flies trip isn't helping — give the straight line.
242
+ if (lengthM > directM * 3 + 1) return straight();
243
+ return { reachable: true, points, segments, lengthM, roadLengthM, directM, detour: lengthM / Math.max(1e-6, directM), etaS: lengthM / CRUISE };
244
+ }
245
+
246
+ // Alias so a fable-derived drawer can call routeTo() unchanged.
247
+ export const routeTo = route;
248
+
249
+ // Distance from a point to the drawn route — the deviation check (re-route when the player strays
250
+ // off the suggested road). Nearest approach to any segment of the route.
251
+ export function distToRoute(r, x, z) {
252
+ if (!r?.segments) return Infinity;
253
+ let best = Infinity;
254
+ for (const seg of r.segments)
255
+ for (let i = 1; i < seg.pts.length; i++) {
256
+ const a = seg.pts[i - 1], b = seg.pts[i];
257
+ const c = segClosest(x, z, a.x, a.z, b.x, b.z);
258
+ if (c.d2 < best) best = c.d2;
259
+ }
260
+ return Math.sqrt(best);
261
+ }
262
+
263
+ // Re-export so callers verifying "is this point on a road" don't reach past this module.
264
+ export const roadDistance = (...a) => RN.roadDistance(...a);