sindicate 0.10.0 → 0.12.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.
@@ -17,7 +17,43 @@
17
17
  // paper map open, and the coaches, the law and the outlaws can all wait. It also means the map is
18
18
  // not a thing you have to read while being shot at.
19
19
  import { WORLD_SIZE, allPOIs, allBuildings } from './cartograph.js';
20
- import { getQualityName, setQualityName, PRESETS } from '../core/quality.js';
20
+
21
+ // WORLD-MAP CONTENT is GAME data (setWorldMapContent in engineSetup):
22
+ // title/iconDir/horseIcon — the sheet's dress
23
+ // legend [{cat,label}] + ink {cat:col} + kindLabel {kind:label} — the marker taxonomy
24
+ // labelZoomRule(p, zoom) → bool — may this label print at this zoom (western: stage stops
25
+ // hide under 2.2×; return true/undefined to print)
26
+ // tabs [{id, label, render(el, game, api)}] — hub tabs after MAP; render may return
27
+ // { padRows: [{el, act?, cycle?}] } for pad row-nav, else up/down scrolls the panel
28
+ // discovery { known(p), version() } — POIs filter through known(); bump version to redraw
29
+ // layersUnder / layersOver [(ctx, d, api) => void] — extra paint (fog, home stamps);
30
+ // under = below buildings, over = above markers, below the player arrow
31
+ // onPick(target, api) → bool — click interception (fast-travel popups); target is
32
+ // { p } for a marker hit or { x, z } for open ground; return true = handled
33
+ // waypoint { get(game), set(game, wp), route(game) } — the pin/route adapter; default
34
+ // is the engine contract (game.waypoint / game.route / ui.recomputeRoute)
35
+ let WM = {
36
+ title: 'WORLD MAP',
37
+ iconDir: '/assets/ui/map', horseIcon: '/assets/ui/icon_horse.png',
38
+ legend: [], ink: {}, inkDefault: '#3a2a16', kindLabel: {},
39
+ labelZoomRule: null,
40
+ tabs: [],
41
+ discovery: null,
42
+ layersUnder: [], layersOver: [],
43
+ onPick: null,
44
+ drawPOIs: null, // FULL marker-pass override (ctx, d, api) — marker style is game
45
+ // identity; the api's addHit/w2s/filters keep hit-testing engine-side
46
+ hoverLabel: null, // (h) => { name, kind } — mystery masking, custom kind copy
47
+ routeColors: null, // (rt) => { line, casing } — the route's ink
48
+ fogLayer: null, // { px, version(), draw(ctx, px) } — WORLD-ANCHORED canvas between
49
+ // chart and overlay (discovery fog); redrawn only when version moves
50
+ waypoint: {
51
+ get: (g) => g.waypoint,
52
+ set: (g, w) => { g.waypoint = w; g.ui?.recomputeRoute?.(); },
53
+ route: (g) => g.route,
54
+ },
55
+ };
56
+ export function setWorldMapContent(c = {}) { WM = { ...WM, ...c, waypoint: { ...WM.waypoint, ...(c.waypoint ?? {}) } }; }
21
57
 
22
58
  const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
23
59
  // THE CHART BITMAP STAYS AT 1600, AND THAT IS A DELIBERATE REFUSAL.
@@ -36,7 +72,7 @@ const ZOOM_MAX = 16; // 16× ⇒ ~270 m across the frame: close enoug
36
72
  // (dusty and plain — the Military set's chrome and the Fantasy set's gold filigree both read
37
73
  // wrong on a western), except horse.png, which is the Fantasy pack's — no other pack ships a
38
74
  // horse, and a horse is what a stage stop is.
39
- const ICON = (n) => `/assets/ui/map/${n}.png`;
75
+ const ICON = (n) => `${WM.iconDir}/${n}.png`;
40
76
  const _imgs = new Map();
41
77
  function iconImg(name, onReady) {
42
78
  let img = _imgs.get(name);
@@ -68,11 +104,7 @@ function inkIcon(name, col, px, onReady) {
68
104
 
69
105
  // Ink by category — the one colour cue on an otherwise monochrome sheet. Outposts are the places
70
106
  // with people in them who care who you are, and they are red.
71
- const INK = { Settlement: '#3a2a16', Outpost: '#7d3123', Stage: '#3c5860', Camp: '#b5642a', Shop: '#4a6b2e' };
72
- const KIND_LABEL = {
73
- town: 'Town', fort: 'Army fort', mission: 'Mission', camp: 'Camp', mine: 'Diggings',
74
- stop: 'Stage stop', ranch: 'Ranch', farm: 'Farm', outpost: 'Outpost', shop: 'Shop',
75
- };
107
+
76
108
 
77
109
  export class WorldMap {
78
110
  // `getChart` hands back the prebuilt chart bitmap (ui.prebuildChart) — the map never renders
@@ -96,26 +128,22 @@ export class WorldMap {
96
128
  flex-direction:column; gap:10px; background:radial-gradient(circle at 50% 42%, rgba(24,17,10,0.86), rgba(8,6,4,0.96));
97
129
  font-family:Copperplate,'Copperplate Gothic Bold',Palatino,'Book Antiqua',Georgia,serif; pointer-events:auto; user-select:none;`;
98
130
  root.innerHTML = `
99
- <div style="color:#e8c98a; font-size:21px; letter-spacing:7px; text-shadow:0 2px 6px #000; font-weight:700;">DUSTWATER COUNTY</div>
100
- <div id="wm-tabs" style="display:flex; gap:0; border-bottom:1px solid rgba(122,99,54,0.4);">
131
+ <div style="color:#e8c98a; font-size:21px; letter-spacing:7px; text-shadow:0 2px 6px #000; font-weight:700;">${WM.title}</div>
132
+ <div id="wm-tabs" style="display:flex; gap:0; border-bottom:1px solid rgba(122,99,54,0.4); ${WM.tabs.length ? '' : 'display:none;'}">
101
133
  <button data-tab="map" style="${TAB}">MAP</button>
102
- <button data-tab="journal" style="${TAB}">JOURNAL</button>
103
- <button data-tab="system" style="${TAB}">SYSTEM</button>
134
+ ${WM.tabs.map((t) => `<button data-tab="${t.id}" style="${TAB}">${t.label}</button>`).join('')}
104
135
  </div>
105
136
  <div id="wm-mapview" style="display:flex; flex-direction:column; align-items:center; gap:10px;">
106
- <div id="wm-legend" style="display:flex; gap:6px;">
137
+ <div id="wm-legend" style="display:${WM.legend.length ? 'flex' : 'none'}; gap:6px;">
107
138
  <button data-cat="" style="${LBTN}">All</button>
108
- <button data-cat="Settlement" style="${LBTN}">Settlements</button>
109
- <button data-cat="Outpost" style="${LBTN}">Outposts</button>
110
- <button data-cat="Stage" style="${LBTN}">Stage line</button>
111
- <button data-cat="Camp" style="${LBTN}">Campfires</button>
112
- <button data-cat="Shop" style="${LBTN}">Shops</button>
139
+ ${WM.legend.map((l) => `<button data-cat="${l.cat}" style="${LBTN}">${l.label}</button>`).join('')}
113
140
  </div>
114
141
  <div id="wm-frame" style="position:relative; width:min(74vh,74vw); height:min(74vh,74vw); overflow:hidden;
115
142
  border:3px solid #6d5730; border-radius:4px; box-shadow:0 0 44px rgba(0,0,0,0.85), inset 0 0 70px rgba(0,0,0,0.45);
116
143
  background:#1a1611; cursor:grab;">
117
144
  <div id="wm-pan" style="position:absolute; inset:0; transform-origin:0 0;">
118
145
  <canvas id="wm-canvas" style="position:absolute; inset:0; width:100%; height:100%;"></canvas>
146
+ ${WM.fogLayer ? '<canvas id="wm-fog" style="position:absolute; inset:0; width:100%; height:100%; pointer-events:none;"></canvas>' : ''}
119
147
  </div>
120
148
  <canvas id="wm-overlay" style="position:absolute; inset:0; width:100%; height:100%; pointer-events:none;"></canvas>
121
149
  <div id="wm-target" style="position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:30px; height:30px;
@@ -135,12 +163,9 @@ export class WorldMap {
135
163
  <div style="color:#c0ad82; font-size:12.5px; letter-spacing:0.5px; opacity:0.85;">
136
164
  wheel zooms &middot; drag pans &middot; click sets your waypoint &middot; right-click clears it &middot; [M] closes</div>
137
165
  </div>
138
- <div id="wm-journal" style="display:none; width:min(74vh,74vw); height:min(70vh,70vw); overflow-y:auto;
139
- border:3px solid #6d5730; border-radius:4px; background:rgba(20,15,9,0.55); box-shadow:0 0 44px rgba(0,0,0,0.85);
140
- padding:24px 30px; color:#e6d6ad; text-align:left;"></div>
141
- <div id="wm-system" style="display:none; width:min(74vh,74vw); height:min(70vh,70vw);
166
+ ${WM.tabs.map((t) => `<div id="wm-tab-${t.id}" style="display:none; width:min(74vh,74vw); height:min(70vh,70vw); overflow-y:auto;
142
167
  border:3px solid #6d5730; border-radius:4px; background:rgba(20,15,9,0.55); box-shadow:0 0 44px rgba(0,0,0,0.85);
143
- padding:30px 34px; color:#e6d6ad; text-align:left; display:none; flex-direction:column; gap:22px;"></div>`;
168
+ padding:24px 30px; color:#e6d6ad; text-align:left;"></div>`).join('')}`;
144
169
  document.body.appendChild(root);
145
170
 
146
171
  this.root = root;
@@ -148,6 +173,8 @@ export class WorldMap {
148
173
  this.pan = root.querySelector('#wm-pan');
149
174
  this.canvas = root.querySelector('#wm-canvas');
150
175
  this.canvas.width = this.canvas.height = CHART_PX;
176
+ this.fog = root.querySelector('#wm-fog');
177
+ if (this.fog) { this.fog.width = this.fog.height = WM.fogLayer.px ?? CHART_PX; this._fogVer = -1; }
151
178
  this.overlay = root.querySelector('#wm-overlay');
152
179
 
153
180
  // the hover card — name, what the place is, and how far you have to ride
@@ -171,8 +198,7 @@ export class WorldMap {
171
198
 
172
199
  // HUB TABS — MAP · JOURNAL (SYSTEM lands in Phase 3). The map is the pause screen already.
173
200
  this.mapview = root.querySelector('#wm-mapview');
174
- this.journal = root.querySelector('#wm-journal');
175
- this.system = root.querySelector('#wm-system');
201
+ this.panels = Object.fromEntries(WM.tabs.map((t) => [t.id, root.querySelector(`#wm-tab-${t.id}`)]));
176
202
  this.target = root.querySelector('#wm-target'); // the pad's centre reticle (where Square drops the pin)
177
203
  this.tab = 'map';
178
204
  root.querySelectorAll('#wm-tabs button').forEach((b) =>
@@ -184,56 +210,42 @@ export class WorldMap {
184
210
  _showTab(tab) {
185
211
  this.tab = tab;
186
212
  this.mapview.style.display = tab === 'map' ? 'flex' : 'none';
187
- this.journal.style.display = tab === 'journal' ? 'block' : 'none';
188
- this.system.style.display = tab === 'system' ? 'flex' : 'none';
213
+ for (const [id, el] of Object.entries(this.panels)) el.style.display = id === tab ? 'block' : 'none';
189
214
  if (this.target && tab !== 'map') this.target.style.display = 'none'; // reticle is chart-only (pad shows it)
190
215
  this.root.querySelectorAll('#wm-tabs button').forEach((b) => {
191
216
  const on = b.dataset.tab === tab;
192
217
  b.style.color = on ? '#e8c98a' : '#9a855a';
193
218
  b.style.borderBottomColor = on ? '#e8c98a' : 'transparent';
194
219
  });
195
- if (tab === 'journal') this._renderJournal();
196
- if (tab === 'system') this._renderSystem();
197
- }
198
-
199
- // SYSTEM tab the map is the game's hub. A vertical list of ITEMS: up/down moves between them,
200
- // Cross fires Save/Load/Quit, and LEFT/RIGHT changes the value on a setting row (Graphics).
201
- _renderSystem() {
202
- const m = this.game.missions, el = this.system;
203
- const ROW = `display:flex; align-items:center; justify-content:space-between; padding:14px 20px; border:1px solid #6d5730;
204
- border-radius:4px; background:linear-gradient(180deg,#3f2d14,#281b0c); color:#e8c98a; font-family:inherit; font-size:16px;
205
- letter-spacing:2px; cursor:pointer; pointer-events:auto; transition:filter .12s;`;
206
- el.innerHTML = `
207
- <div style="color:#e8c98a; font-size:13px; letter-spacing:3px; font-weight:700; opacity:0.85;">SYSTEM</div>
208
- <div id="wm-r-save" style="${ROW}">Save Game</div>
209
- <div id="wm-r-load" style="${ROW}${m?.hasSave?.() ? '' : ' opacity:0.4;'}">Load Game</div>
210
- <div id="wm-r-gfx" style="${ROW}"><span>Graphics</span><span>&#8249;&nbsp; <span id="wm-gfxval" style="color:#f4e6bf; display:inline-block; min-width:74px; text-align:center;"></span> &nbsp;&#8250;</span></div>
211
- <div style="flex:1;"></div>
212
- <div id="wm-r-quit" style="${ROW} border-color:#7a3f2c; color:#dfb090;">Quit to Title</div>`;
213
- const gfxVal = el.querySelector('#wm-gfxval');
214
- const setGfx = () => { gfxVal.textContent = PRESETS[getQualityName()].label; };
215
- const cycleGfx = (dir) => { const o = ['low', 'medium', 'high']; setQualityName(o[(o.indexOf(getQualityName()) + dir + 3) % 3]); setGfx(); };
216
- setGfx();
217
- this._sysRows = [
218
- { el: el.querySelector('#wm-r-save'), act: () => m?.saveGame?.() },
219
- { el: el.querySelector('#wm-r-load'), act: () => { if (m?.hasSave?.() && m.loadGame()) this.toggle(); } },
220
- { el: el.querySelector('#wm-r-gfx'), cycle: cycleGfx },
221
- { el: el.querySelector('#wm-r-quit'), act: () => location.reload() },
222
- ];
223
- // mouse still works: click a row to fire it (the Graphics row cycles forward)
224
- for (const r of this._sysRows) r.el.onclick = (e) => { e.stopPropagation(); (r.act || (() => r.cycle(1)))(); };
225
- this._padSel = 0; this._paintSysSel();
220
+ const def = WM.tabs.find((t) => t.id === tab);
221
+ this._padRows = null; this._padSel = 0;
222
+ if (def) {
223
+ const out = def.render?.(this.panels[tab], this.game, this._api());
224
+ if (out?.padRows) { this._padRows = out.padRows; this._paintSysSel(); }
225
+ }
226
+ }
227
+
228
+ // the helpers game callbacks paint with one w2s, one stamp, so nothing can drift
229
+ _api() {
230
+ return {
231
+ game: this.game, map: this, zoom: this.zoom,
232
+ w2s: (x, z) => this._w2s(x, z), s2w: (sx, sy) => this._s2w(sx, sy),
233
+ stamp: (ctx, X, Y, r, p, redraw) => this._stamp(ctx, X, Y, r, p, redraw),
234
+ inkIcon, redraw: () => this._draw(), close: () => this.toggle(),
235
+ setWaypoint: (w) => this._setWaypoint(w),
236
+ addHit: (h) => this._hit.push(h), filters: this.filters, frame: this.frame,
237
+ };
226
238
  }
227
239
 
228
240
  // ── GAMEPAD in the hub. The game's own pad mapping is suppressed while a panel is up (input.js),
229
241
  // so the map drives itself: L1/R1 swap tabs, D-pad/stick navigates the current tab, Cross confirms,
230
242
  // Circle (or the map button) closes.
231
243
  _cycleTab(dir) {
232
- const tabs = ['map', 'journal', 'system'];
244
+ const tabs = ['map', ...WM.tabs.map((t) => t.id)];
233
245
  this._showTab(tabs[(tabs.indexOf(this.tab) + dir + tabs.length) % tabs.length]);
234
246
  }
235
247
  _paintSysSel() {
236
- (this._sysRows || []).forEach((r, i) => { r.el.style.outline = i === this._padSel ? '2px solid #e8c98a' : 'none'; r.el.style.outlineOffset = '2px'; });
248
+ (this._padRows || []).forEach((r, i) => { r.el.style.outline = i === this._padSel ? '2px solid #e8c98a' : 'none'; r.el.style.outlineOffset = '2px'; });
237
249
  }
238
250
  _startPadNav() {
239
251
  cancelAnimationFrame(this._padRAF);
@@ -262,19 +274,17 @@ export class WorldMap {
262
274
  if (edge('a')) this._zoomAt(c, c, 1.4); // Cross zooms IN about the reticle
263
275
  if (edge('tri')) this._zoomAt(c, c, 1 / 1.4); // Triangle zooms out
264
276
  if (edge('sq')) this._clickAt(c, c); // Square drops the waypoint at the reticle
265
- } else if (this.tab === 'system') { // up/down moves rows · left/right changes a value · Cross fires
266
- const rows = this._sysRows || [];
267
- if (rows.length) {
268
- if (edge('up')) { this._padSel = (this._padSel + rows.length - 1) % rows.length; this._paintSysSel(); }
269
- if (edge('down')) { this._padSel = (this._padSel + 1) % rows.length; this._paintSysSel(); }
270
- const r = rows[this._padSel];
271
- if (edge('left') && r?.cycle) r.cycle(-1);
272
- if (edge('right') && r?.cycle) r.cycle(1);
273
- if (edge('a') && r?.act) r.act();
274
- }
275
- } else if (this.tab === 'journal') { // scroll (held)
276
- if (b(12) || ly < -0.4) this.journal.scrollTop -= 16;
277
- if (b(13) || ly > 0.4) this.journal.scrollTop += 16;
277
+ } else if (this._padRows?.length) { // a rows tab: up/down moves · left/right cycles · Cross fires
278
+ const rows = this._padRows;
279
+ if (edge('up')) { this._padSel = (this._padSel + rows.length - 1) % rows.length; this._paintSysSel(); }
280
+ if (edge('down')) { this._padSel = (this._padSel + 1) % rows.length; this._paintSysSel(); }
281
+ const r = rows[this._padSel];
282
+ if (edge('left') && r?.cycle) r.cycle(-1);
283
+ if (edge('right') && r?.cycle) r.cycle(1);
284
+ if (edge('a') && r?.act) r.act();
285
+ } else if (this.panels[this.tab]) { // a scroll tab (held)
286
+ if (b(12) || ly < -0.4) this.panels[this.tab].scrollTop -= 16;
287
+ if (b(13) || ly > 0.4) this.panels[this.tab].scrollTop += 16;
278
288
  }
279
289
  if (edge('back') || edge('pad')) { this.toggle(); return; }
280
290
  }
@@ -285,66 +295,6 @@ export class WorldMap {
285
295
  this._padRAF = requestAnimationFrame(poll);
286
296
  }
287
297
 
288
- // The journal reads straight from the mission engine (definitions + progress). Active job with its
289
- // current objective highlighted (and n/N on kill/collect), then the finished ones.
290
- _renderJournal() {
291
- const m = this.game.missions;
292
- const esc = (s) => String(s ?? '').replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
293
- if (!m) { this.journal.innerHTML = `<div style="opacity:0.7;">No journal yet.</div>`; return; }
294
- const name = (id) => esc(m.npcs?.[id]?.name || id);
295
- const prog = m.progress;
296
- const active = m.defs.find((d) => d.id === prog.active);
297
- const completed = m.defs.filter((d) => prog.missions[d.id]?.done);
298
- const H = (s) => `<div style="color:#e8c98a; font-size:13px; letter-spacing:3px; font-weight:700; margin:6px 0 12px; opacity:0.85;">${s}</div>`;
299
- let html = '';
300
- if (active) {
301
- const rec = prog.missions[active.id] || { stage: 0, counters: {} };
302
- html += H('CURRENT JOB');
303
- html += `<div style="margin-bottom:20px;">
304
- <div style="color:#f4e6bf; font-size:23px; letter-spacing:2px; margin-bottom:4px;">${esc(active.title)}</div>
305
- <div style="color:#b9a678; font-size:13px; margin-bottom:11px;">Given by ${name(active.giver)}</div>
306
- <div style="color:#d8c79c; font-style:italic; font-size:14px; line-height:1.55; margin-bottom:16px;">${esc(active.summary)}</div>
307
- <div style="display:flex; flex-direction:column; gap:9px;">`;
308
- // The checklist stops at the CURRENT beat — a journal that prints the mission's whole future
309
- // is a spoiler, and with branching it printed the OTHER fork's stages too. Behind you: only
310
- // the stages your path actually walked (skipIf/onlyIf-mismatched ones never happened —
311
- // m._condOk); ahead of you: a single quiet ellipsis.
312
- active.stages.forEach((s, i) => {
313
- const done = i < rec.stage, cur = i === rec.stage;
314
- if (!done && !cur) return;
315
- if (done && m._condOk && !m._condOk(s)) return; // the fork not taken
316
- let label = esc(s.desc || s.id);
317
- if ((s.type === 'kill' || s.type === 'collect') && s.count) {
318
- const c = done ? s.count : (rec.counters[s.id] || 0);
319
- label += ` <span style="opacity:0.7;">(${c}/${s.count})</span>`;
320
- }
321
- const mark = done ? '&#10003;' : '&#9656;';
322
- const col = done ? '#83a06c' : '#f4e6bf';
323
- html += `<div style="color:${col}; font-size:15.5px; font-weight:${cur ? '700' : '400'}; ${done ? 'opacity:0.7;' : ''}">
324
- <span style="display:inline-block; width:22px;">${mark}</span>${label}</div>`;
325
- });
326
- if (rec.stage < active.stages.length - 1) html += `<div style="color:#8a7c5c; font-size:15px;"><span style="display:inline-block; width:22px;">&#8226;</span>&#8230;</div>`;
327
- html += `</div></div>`;
328
- // THE STANDING — how the county reads you (missions.stat): Bracken's trust in his deputy and
329
- // the town's regard, once the star's been taken. Signed, so a debt reads as one.
330
- if (prog.flags?.badge) {
331
- const sgn = (n) => (n > 0 ? '+' : '') + n;
332
- const trust = m.stat?.('trust') ?? 0, rep = m.stat?.('reputation') ?? 0;
333
- html += `<div style="color:#b9a678; font-size:13.5px; margin:-6px 0 18px; letter-spacing:0.5px;">
334
- Bracken's trust <b style="color:${trust < 0 ? '#c98a6a' : '#d8c79c'}">${sgn(trust)}</b>
335
- &nbsp;&middot;&nbsp; the town's regard <b style="color:${rep < 0 ? '#c98a6a' : '#d8c79c'}">${sgn(rep)}</b></div>`;
336
- }
337
- } else {
338
- html += `<div style="color:#b9a678; font-style:italic; font-size:15px; margin:8px 0 20px;">No job in hand. Ask around town — someone always needs a gun.</div>`;
339
- }
340
- if (completed.length) {
341
- html += H('DONE');
342
- for (const d of completed) html += `<div style="display:flex; justify-content:space-between; padding:8px 0; border-top:1px solid rgba(122,99,54,0.25); color:#a99a72; font-size:14.5px;">
343
- <span>${esc(d.title)}</span>${d.reward?.money ? `<span style="color:#83a06c;">$${d.reward.money}</span>` : ''}</div>`;
344
- }
345
- this.journal.innerHTML = html;
346
- }
347
-
348
298
  _wireEvents() {
349
299
  const f = this.frame;
350
300
  f.addEventListener('wheel', (e) => {
@@ -453,16 +403,19 @@ export class WorldMap {
453
403
  // they must keep reading it — the map is where you SAY where you are going; the HUD is what gets
454
404
  // you there once the map is shut.
455
405
  _setWaypoint(w) {
456
- this.game.waypoint = w;
457
- // Re-plan the road route NOW, before we repaint: the world is paused behind the map, so ui.js's
406
+ // Re-plan the route NOW, before we repaint: the world is paused behind the map, so the
458
407
  // movement-gated re-plan would not fire, and the route would lag a click behind the pin.
459
- this.game.ui?.recomputeRoute?.();
408
+ WM.waypoint.set(this.game, w);
460
409
  this._draw();
461
410
  }
462
411
  // CLICK-SNAP: a click on (or near) a marker means the PLACE, not the pixel you happened to hit.
463
412
  // Without it, "waypoint the fort" puts you 30 m into the scrub beside the fort.
464
413
  _clickAt(sx, sy) {
465
414
  const hit = this._hitAt(sx, sy, 10);
415
+ if (WM.onPick) {
416
+ const [x, z] = this._s2w(sx, sy);
417
+ if (WM.onPick(hit ? { p: hit.p, masked: hit.masked, sx, sy } : { x, z, sx, sy }, this._api())) return;
418
+ }
466
419
  if (hit) { this._setWaypoint({ x: hit.p.x, z: hit.p.z, name: hit.p.name }); return; }
467
420
  const [x, z] = this._s2w(sx, sy);
468
421
  this._setWaypoint({ x, z });
@@ -484,8 +437,9 @@ export class WorldMap {
484
437
  if (!h) { this.card.style.display = 'none'; return; }
485
438
  const pl = this.game.player?.position;
486
439
  const dist = pl ? `${Math.round(Math.hypot(h.p.x - pl.x, h.p.z - pl.z))} m` : '';
487
- this.card.innerHTML = `<b style="color:#f0d89e;">${h.p.name}</b><br>` +
488
- `<span style="opacity:0.8;">${KIND_LABEL[h.p.kind] ?? h.p.kind}${dist ? ' &middot; ' + dist : ''}</span>`;
440
+ const lab = WM.hoverLabel?.(h) ?? { name: h.p.name, kind: WM.kindLabel[h.p.kind] ?? h.p.kind };
441
+ this.card.innerHTML = `<b style="color:#f0d89e;">${lab.name}</b><br>` +
442
+ `<span style="opacity:0.8;">${lab.kind}${dist ? ' &middot; ' + dist : ''}</span>`;
489
443
  this.card.style.display = '';
490
444
  const d = this._disp();
491
445
  this.card.style.left = `${Math.min(sx + 14, d - 160)}px`;
@@ -509,6 +463,16 @@ export class WorldMap {
509
463
  this.built = true;
510
464
  }
511
465
 
466
+ // the discovery fog repaints ONLY when its version moves — the grain pass is O(px²)
467
+ _drawFogIfStale() {
468
+ const f = WM.fogLayer;
469
+ if (!f || !this.fog) return;
470
+ const v = f.version?.() ?? 0;
471
+ if (v === this._fogVer) return;
472
+ this._fogVer = v;
473
+ f.draw(this.fog.getContext('2d'), f.px ?? CHART_PX);
474
+ }
475
+
512
476
  // Everything below is drawn in SCREEN pixels. Nothing here multiplies a size by this.zoom —
513
477
  // that is the invariant that keeps the map readable at 1× and at 8×.
514
478
  _draw() {
@@ -523,12 +487,14 @@ export class WorldMap {
523
487
  const redraw = () => this._draw(); // a late icon load repaints the overlay
524
488
  this._hit = [];
525
489
 
490
+ const api = this._api();
491
+ for (const fn of WM.layersUnder) fn(ctx, d, api);
526
492
  this._drawBuildings(ctx, d);
527
493
  this._drawRoute(ctx, d);
528
494
  this._drawPOIs(ctx, d, redraw);
529
495
  this._drawObjectives(ctx, d, redraw);
530
496
  this._drawWaypoint(ctx, d, redraw);
531
- this._drawHome(ctx, d, redraw);
497
+ for (const fn of WM.layersOver) fn(ctx, d, api);
532
498
  this._drawHorse(ctx, d);
533
499
  this._drawPlayer(ctx, d);
534
500
  this._drawScale(ctx, d);
@@ -572,17 +538,18 @@ export class WorldMap {
572
538
  // network, DASHED for the two short stubs off it (you→road, road→pin). If the router has no plan yet,
573
539
  // or the pin is nowhere near a road, it falls back to an honest crow-flies dashed line.
574
540
  _drawRoute(ctx, d) {
575
- const wp = this.game.waypoint;
541
+ const wp = WM.waypoint.get(this.game);
576
542
  const pl = this.game.player?.position;
577
543
  if (!wp || !pl) return;
578
- const rt = this.game.route;
544
+ const rt = WM.waypoint.route(this.game);
579
545
  const segs = rt?.segments?.length
580
546
  ? rt.segments
581
547
  : [{ pts: [{ x: pl.x, z: pl.z }, { x: wp.x, z: wp.z }], style: 'dash' }];
582
- const line = rt && rt.reachable ? '#c2401f' : '#a86a3c'; // rust when it's a real road route; faded when crow-flies
548
+ const cols = WM.routeColors?.(rt) ?? { line: rt && rt.reachable ? '#c2401f' : '#a86a3c', casing: 'rgba(245,232,198,0.85)' };
549
+ const line = cols.line;
583
550
  ctx.lineCap = 'round'; ctx.lineJoin = 'round';
584
551
  for (let pass = 0; pass < 2; pass++) {
585
- ctx.strokeStyle = pass ? line : 'rgba(245,232,198,0.85)'; // cream casing, so it reads on ink AND on paper
552
+ ctx.strokeStyle = pass ? line : cols.casing; // casing under the line, so it reads on ink AND on paper
586
553
  ctx.lineWidth = pass ? 2.6 : 5;
587
554
  for (const seg of segs) {
588
555
  if (!seg.pts || seg.pts.length < 2) continue;
@@ -599,8 +566,10 @@ export class WorldMap {
599
566
  }
600
567
 
601
568
  _drawPOIs(ctx, d, redraw) {
569
+ if (WM.drawPOIs) { WM.drawPOIs(ctx, d, this._api()); return; }
602
570
  const placed = []; // label rects — the small ones yield to the big ones
603
- const shown = allPOIs().filter((p) => !this.filters.size || this.filters.has(p.category));
571
+ const known = WM.discovery?.known;
572
+ const shown = allPOIs().filter((p) => (!this.filters.size || this.filters.has(p.category)) && (!known || known(p)));
604
573
  // big places first, so their labels reserve their ground before the stage stops ask for any
605
574
  const order = [...shown].sort((a, b) => (b.big ? 1 : 0) - (a.big ? 1 : 0));
606
575
  const stamps = [];
@@ -633,7 +602,7 @@ export class WorldMap {
633
602
  // a dot tells you something is there and nothing about what it is, and this county has a fort, a
634
603
  // mission, a camp, a quarry and a stage line, which are five different reasons to ride somewhere.
635
604
  _stamp(ctx, X, Y, r, p, redraw) {
636
- const ink = INK[p.category] ?? '#3a2a16';
605
+ const ink = WM.ink[p.category] ?? WM.inkDefault;
637
606
  ctx.save();
638
607
  ctx.shadowColor = 'rgba(30,20,8,0.55)'; ctx.shadowBlur = 5; ctx.shadowOffsetY = 1;
639
608
  ctx.beginPath(); ctx.arc(X, Y, r, 0, 7);
@@ -656,7 +625,7 @@ export class WorldMap {
656
625
  // themselves from there. (The first cut suppressed every non-"big" place, which meant every
657
626
  // farm, ranch, shack and the cemetery went unnamed at county zoom — the zoom you actually plan
658
627
  // a ride at. The label-collision pass below already handles crowding; it does not need help.)
659
- if (p.category === 'Stage' && this.zoom < 2.2) return;
628
+ if (WM.labelZoomRule && WM.labelZoomRule(p, this.zoom) === false) return;
660
629
  const fs = p.big ? 13.5 : 11;
661
630
  ctx.font = `${p.big ? 700 : 600} ${fs}px Georgia, 'Palatino Linotype', serif`;
662
631
  ctx.textAlign = 'center'; ctx.textBaseline = 'top';
@@ -675,7 +644,7 @@ export class WorldMap {
675
644
  }
676
645
 
677
646
  _drawWaypoint(ctx, d, redraw) {
678
- const wp = this.game.waypoint;
647
+ const wp = WM.waypoint.get(this.game);
679
648
  if (!wp) return;
680
649
  const [X, Y] = this._w2s(wp.x, wp.z);
681
650
  const s = 24;
@@ -718,19 +687,6 @@ export class WorldMap {
718
687
  }
719
688
  }
720
689
 
721
- // THE HALLOWAY PLACE — once the deed is yours, home gets its own stamp on the paper (the static
722
- // POI list is built before the campaign can sell you a house, so it rides the live layer).
723
- _drawHome(ctx, d, redraw) {
724
- if (!this.game.missions?.flag?.('house_owned')) return;
725
- const [X, Y] = this._w2s(-15.58, -79.79);
726
- if (X < -30 || Y < -30 || X > d + 30 || Y > d + 30) return;
727
- this._stamp(ctx, X, Y, 11, { category: 'Settlement', icon: 'home', big: false, name: 'The Halloway Place' }, redraw);
728
- ctx.font = '600 11px Georgia, serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
729
- ctx.lineWidth = 3.2; ctx.strokeStyle = 'rgba(243,232,203,0.92)';
730
- ctx.strokeText('The Halloway Place', X, Y + 14);
731
- ctx.fillStyle = '#3d2e1a'; ctx.fillText('The Halloway Place', X, Y + 14);
732
- }
733
-
734
690
  // YOUR HORSE on the paper — a green dot so you can find where you left it. Not while you're in the
735
691
  // saddle (it sits under your own arrow), and culled when it's off the drawn frame.
736
692
  _drawHorse(ctx, d) {
@@ -740,7 +696,7 @@ export class WorldMap {
740
696
  if (X < -30 || Y < -30 || X > d + 30 || Y > d + 30) return;
741
697
  ctx.save();
742
698
  ctx.shadowColor = 'rgba(30,20,8,0.5)'; ctx.shadowBlur = 4;
743
- const img = (this._horseImg ??= Object.assign(new Image(), { src: '/assets/ui/icon_horse.png' }));
699
+ const img = (this._horseImg ??= Object.assign(new Image(), { src: WM.horseIcon }));
744
700
  if (img.complete && img.naturalWidth) {
745
701
  const s = 30;
746
702
  ctx.drawImage(img, X - s / 2, Y - s / 2, s, s);
@@ -805,9 +761,10 @@ export class WorldMap {
805
761
  tick() {
806
762
  const p = this.game.player;
807
763
  if (!p?.position) return;
808
- const k = `${p.position.x.toFixed(1)}|${p.position.z.toFixed(1)}|${(p.heading ?? 0).toFixed(2)}`;
764
+ const k = `${p.position.x.toFixed(1)}|${p.position.z.toFixed(1)}|${(p.heading ?? 0).toFixed(2)}|${WM.discovery?.version?.() ?? 0}`;
809
765
  if (k === this._playerKey) return;
810
766
  this._playerKey = k;
767
+ if (this.visible) this._drawFogIfStale();
811
768
  this._draw();
812
769
  }
813
770
 
@@ -816,6 +773,7 @@ export class WorldMap {
816
773
  this.root.style.display = this.visible ? 'flex' : 'none';
817
774
  if (!this.visible) { this.card.style.display = 'none'; this._hover = null; if (this.target) this.target.style.display = 'none'; return; }
818
775
  if (!this.built) this._render();
776
+ this._drawFogIfStale();
819
777
  this._showTab(this.tab); // restore the last tab + refresh the journal from live mission state
820
778
  this._startPadNav(); // the pad drives the hub now (L1/R1 tabs · D-pad navigates · Circle closes)
821
779
  // THE MOUSE HAS TO COME BACK. The game holds pointer lock, and under lock there is no cursor
@@ -205,6 +205,74 @@ function editorShotsPlugin({ shotsDir, mapsDir }) {
205
205
  };
206
206
  }
207
207
 
208
+ // THE ENGINE DEV GUI (/__sindicate/) — served by the dev server, talks to the RUNNING
209
+ // game through the game bus. v1: live tunables (core/tune.js groups render as sliders;
210
+ // edits apply in the game the same frame). Panels for add-ons/packs mount here next.
211
+ export function guiPlugin() {
212
+ const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Sindicate</title><style>
213
+ body{margin:0;font:14px/1.5 -apple-system,sans-serif;background:#14141f;color:#e8e2d0;padding:28px}
214
+ h1{font-size:18px;letter-spacing:0.14em;color:#e8c66a;margin:0 0 4px} .sub{color:#8d8875;font-size:12px;margin-bottom:22px}
215
+ .grp{background:#1c1c2b;border:1px solid #2c2c40;border-radius:10px;padding:16px 18px;margin-bottom:14px;max-width:520px}
216
+ .grp h2{font-size:13px;letter-spacing:0.1em;text-transform:uppercase;color:#e8c66a;margin:0 0 10px}
217
+ .row{display:grid;grid-template-columns:130px 1fr 64px;gap:12px;align-items:center;margin:7px 0}
218
+ .row label{color:#b8b09a;font-size:12px} .row output{text-align:right;font-variant-numeric:tabular-nums;color:#f5ead0}
219
+ input[type=range]{width:100%;accent-color:#e8c66a}
220
+ .file{color:#6d6858;font-size:11px;margin-top:8px} .status{color:#8d8875;font-size:12px;margin-top:18px}
221
+ </style></head><body>
222
+ <h1>SINDICATE</h1><div class="sub">dev panel — tunables apply LIVE in the running game</div>
223
+ <div id="groups"></div><div class="status" id="status">connecting to the game bus…</div>
224
+ <script>
225
+ const cmd = (js) => fetch('/__game/cmd', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ js }) }).then((r) => r.json());
226
+ const take = async (id) => { for (let i = 0; i < 40; i++) { const r = await fetch('/__game/take?id=' + id).then((x) => x.json()); if (!r.pending) return r; await new Promise((res) => setTimeout(res, 250)); } return { err: 'timeout' }; };
227
+ const bus = async (js) => { const { id, err } = await cmd(js); if (err) throw new Error(err); const r = await take(id); if (!r.ok) throw new Error(r.err ?? 'bus error'); return r.value; };
228
+ let sig = '', dragging = false;
229
+ const rows = new Map();
230
+ addEventListener('pointerup', () => { dragging = false; });
231
+ async function refresh() {
232
+ try {
233
+ const r = await bus('return { who: location.pathname + location.search, title: document.title, groups: window.__sindTune ? window.__sindTune.list() : [] }');
234
+ const groups = r.groups, tab = r.title + ' — ' + r.who;
235
+ const s = JSON.stringify(groups.map((g) => [g.name, Object.keys(g.spec)]));
236
+ document.getElementById('status').textContent = (groups.length ? 'live — ' + groups.length + ' group(s)' : 'connected — no tunables registered') + ' · answering tab: ' + tab;
237
+ if (s === sig) {
238
+ if (!dragging) for (const g of groups) for (const [k, v] of Object.entries(g.spec)) {
239
+ const row = rows.get(g.name + '.' + k);
240
+ if (row && String(row.inp.value) !== String(v.value)) { row.inp.value = v.value; row.out.textContent = v.value; }
241
+ }
242
+ return;
243
+ }
244
+ sig = s; rows.clear();
245
+ const root = document.getElementById('groups'); root.innerHTML = '';
246
+ for (const g of groups) {
247
+ const div = document.createElement('div'); div.className = 'grp';
248
+ div.innerHTML = '<h2>' + g.name + '</h2>';
249
+ for (const [k, v] of Object.entries(g.spec)) {
250
+ const row = document.createElement('div'); row.className = 'row';
251
+ row.innerHTML = '<label>' + k + '</label><input type="range" min="' + (v.min ?? 0) + '" max="' + (v.max ?? 1) + '" step="' + (v.step ?? 0.01) + '" value="' + v.value + '"><output>' + v.value + '</output>';
252
+ const inp = row.querySelector('input'), out = row.querySelector('output');
253
+ rows.set(g.name + '.' + k, { inp, out });
254
+ inp.onpointerdown = () => { dragging = true; };
255
+ inp.oninput = () => { out.textContent = inp.value; bus('return window.__sindTune.set(' + JSON.stringify(g.name) + ',' + JSON.stringify(k) + ',' + inp.value + ')').catch(() => {}); };
256
+ div.appendChild(row);
257
+ }
258
+ if (g.file) { const f = document.createElement('div'); f.className = 'file'; f.textContent = 'persists to: ' + g.file; div.appendChild(f); }
259
+ root.appendChild(div);
260
+ }
261
+ } catch (e) { document.getElementById('status').textContent = 'game bus unreachable — is a game tab open? (' + e.message + ')'; }
262
+ }
263
+ refresh(); setInterval(refresh, 3000);
264
+ </script></body></html>`;
265
+ return {
266
+ name: 'sindicate-gui',
267
+ apply: 'serve',
268
+ configureServer(server) {
269
+ server.middlewares.use('/__sindicate', (req, res) => {
270
+ res.setHeader('content-type', 'text/html'); res.end(PAGE);
271
+ });
272
+ },
273
+ };
274
+ }
275
+
208
276
  export function sindicateDevTools({
209
277
  sourceWrites = [],
210
278
  assetLists = [],
@@ -225,6 +293,7 @@ export function sindicateDevTools({
225
293
  ...assetLists.map(assetListPlugin),
226
294
  ...stores.map(binStorePlugin),
227
295
  perfSinkPlugin(perfSink),
296
+ guiPlugin(),
228
297
  busPlugin({ name: 'game', prefix: '/__game', originGuard: true }),
229
298
  busPlugin({ name: 'editor', prefix: '/__editor' }),
230
299
  editorShotsPlugin({ shotsDir: editorShotsDir, mapsDir }),