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,515 @@
1
+ // THE SHOP — a man behind a counter, and the satchel wearing a second column.
2
+ //
3
+ // The plan is explicit (PLAN §3): the trade view IS the inventory screen with his stock added on the
4
+ // right and Buy/Sell where Use/Drop was. So this module does NOT author a second grid — it borrows the
5
+ // Satchel's slot renderer (ui.satchel.makeSlot) so a can of peaches on his shelf is drawn by the same
6
+ // code, from the same baked icon, as the can in your satchel. Two callers, one grid.
7
+ //
8
+ // THE VIEW (RDR2's store): YOUR satchel on the LEFT (sell at 40%), HIS STOCK on the RIGHT (buy at
9
+ // list), your money top-right. A cursor lives on one column at a time; the arrow keys cross between
10
+ // them at the edges, Enter trades, and the detail strip under the two columns names the selected
11
+ // good and carries the Buy/Sell button.
12
+ //
13
+ // PRICES come from items.js and nowhere else — buyPrice = list value, sellPrice = value*SELL_MULT
14
+ // floored at $1 — so the shop and the satchel can never disagree on the spread. The spread is the
15
+ // economy: a stolen watch buys at $45 and sells at $18, which is the whole point of the valuables tab.
16
+ //
17
+ // MONEY lives where it always has, in loot.js — loot.spend(n) (false + takes nothing if he's short)
18
+ // is the buy, loot.add(n) is the sell. We keep no second purse.
19
+ //
20
+ // STOCK is data/shops.js, keyed by shop id. Infinity = a bottomless staple; a finite count is a
21
+ // one-off that leaves the shelf when bought (and does not come back this pass). Live counts persist
22
+ // on the instance for the session, so a rifle you buy stays bought.
23
+ //
24
+ // PAUSE + FREEZE, exactly like the satchel: ui.shopOpen joins main.js's pause OR so the world holds,
25
+ // and ui.cinematic goes up so the frozen world doesn't read your WASD as walking — it's grid nav now.
26
+ // Both flags saved/restored so the trade view never strands the dialogue camera's use of cinematic.
27
+ //
28
+ // WIRING (the integrator, not this file):
29
+ // · construct once on the loading screen, after the satchel: game.ui.shop = new Shop(game.ui)
30
+ // · point the dialogue's trade hook at it: ui.dialogue.onTrade = (npc) => ui.shop.open(npc)
31
+ // · add its flag to the pause OR (main.js:763): ... || this.ui.shopOpen
32
+ // · per frame while open: if (ui.shop.isOpen()) ui.shop.handleKey(input)
33
+ // · peel it on Escape first (ui.toggleMenu): if (this.shopOpen) return this.shop.close()
34
+ // the goods catalogue + shop tables are GAME data:
35
+ // setShopContent({ items, buyPrice, sellPrice, shops, getShop, shopIdFor, defaultShop, shopOpen, shopHours })
36
+ let SH = { items: {}, buyPrice: () => 0, sellPrice: () => 0, shops: {}, getShop: () => null,
37
+ shopIdFor: () => null, defaultShop: null, shopOpen: () => true, shopHours: [8, 20] };
38
+ export function setShopContent(c) { SH = { ...SH, ...c }; }
39
+
40
+ // 24h → "8am"/"9pm" for the "we're closed" line.
41
+ const fmtHour = (h) => { const ap = (h % 24) < 12 ? 'am' : 'pm'; let hh = (h % 12); if (hh === 0) hh = 12; return `${hh}${ap}`; };
42
+
43
+ // Geometry — CSS px, resolution-independent. Bigger, fewer slots than the standalone satchel: the shop
44
+ // window shows FEWER, LARGER goods per side so the illustrated icons and text read big (Nick's call).
45
+ const SLOT = 120; // was 72. makeSlot scales its icon to the slot, so a bigger plate = a bigger picture.
46
+ const GAP = 14;
47
+ const COLS = 3; // 3 per side (was 5). 3*120 + 2*14 = 388px of grid, centred in each ~490px column —
48
+ // both the satchel (left) and the shop stock (right) get roomy, legible boxes.
49
+ const BRASS = 'rgba(229,214,75,1)';
50
+ const PANELS = '/assets/ui/panels';
51
+
52
+ export class Shop {
53
+ constructor(ui) {
54
+ this.ui = ui;
55
+ this.game = ui.game;
56
+
57
+ this._shopId = SH.defaultShop;
58
+ this._side = 'buy'; // which column the cursor is on: 'buy' (his) | 'sell' (yours)
59
+ this._i = { buy: 0, sell: 0 }; // cursor index within each column
60
+ this._buyList = []; // [{ id, stock }] — his shelf, in shops.js order (sold-out kept, greyed)
61
+ this._sellList = []; // [{ id, n }] — your satchel, one row per item type (aggregated)
62
+ this._stock = new Map(); // shopId → Map(id → live count). Persists for the session.
63
+ this._justOpened = false; // eat the frame the panel appears on (the confirm that opened it)
64
+ this._prevCinematic = false; // restore, never clobber, the dialogue camera's flag
65
+
66
+ this._injectCSS();
67
+ this._buildDOM();
68
+ }
69
+
70
+ // ── the contract API ────────────────────────────────────────────────────────────────────────────
71
+ isOpen() { return !!this.ui.shopOpen; }
72
+
73
+ // open(shopId, game): shopId is a shops.js key ('general'/'gunsmith'/'doctor'/'saloon'/'stable') OR
74
+ // the NPC straight off the dialogue trade hook, which we resolve to his counter by trade. `game` is
75
+ // accepted for the integrator's convenience; we already hold it.
76
+ open(shop, game) {
77
+ if (game) this.game = game;
78
+ const id = (typeof shop === 'string') ? shop : SH.shopIdFor(shop);
79
+ // OPENING HOURS: a shut counter won't trade. (The saloon barkeep is already gone at these times, so
80
+ // this bites the general store / gunsmith / doctor / stable — whose keepers stay put — come nightfall.)
81
+ const hour = this.game.world?.sky?.hour ?? 12;
82
+ if (!SH.shopOpen(id, hour)) {
83
+ const win = SH.shopHours[id];
84
+ this.ui.toast?.(win ? `${SH.shops[id]?.name ?? 'Closed'} — open ${fmtHour(win[0])} to ${fmtHour(win[1])}.` : `Closed just now.`);
85
+ this.game.audio?.play?.('ui');
86
+ return;
87
+ }
88
+ this._shopId = SH.shops[id] ? id : SH.defaultShop;
89
+
90
+ if (!this.isOpen()) this._prevCinematic = this.ui.cinematic;
91
+ this.ui.shopOpen = true; // joins main.js's pause OR — the world holds
92
+ this.ui.cinematic = true; // freeze the player: WASD is grid nav now, not walking
93
+ this.game.input?.releaseLock?.(); // free the cursor for the pointer-events:auto panel
94
+ this._justOpened = true;
95
+ this._side = 'buy'; // you came to buy — land on his goods
96
+ this._i = { buy: 0, sell: 0 };
97
+
98
+ const s = SH.getShop(this._shopId);
99
+ this._nameEl.textContent = s.name;
100
+ this._buyHEl.textContent = s.name;
101
+ this._rebuild();
102
+ this.root.style.display = 'flex';
103
+ this.game.audio?.play?.('ui');
104
+ }
105
+
106
+ close() {
107
+ if (!this.isOpen()) { this.root.style.display = 'none'; return; }
108
+ this.ui.shopOpen = false;
109
+ this.ui.cinematic = this._prevCinematic; // hand the flag back exactly as we found it
110
+ this.root.style.display = 'none';
111
+ // do NOT re-request the lock — the next canvas click re-acquires it and input.js drops that click
112
+ // so it never lands as a shot (same contract the map, satchel and dialogue all follow).
113
+ }
114
+
115
+ // Once per frame while open. Mouse works without it (native :hover + onclick); this is the
116
+ // keyboard/gamepad layer. combatInputs is frozen by cinematic (player.js:753), so Q/arrows can't
117
+ // leak into weapon-cycle or lock-target; we still consume every key we act on.
118
+ handleKey(input) {
119
+ if (!this.isOpen() || !input) return;
120
+ // pollGamepad(anyPanelOpen) CLEARS the synthesized keys while a panel is open (input.js), so a paused
121
+ // counter must read the pad RAW itself, like blackjack does. Read it every frame so edges are detected
122
+ // and _pd stays primed (so the button still held from OPENING us can't fire an action next frame).
123
+ const pad = this._padEdge();
124
+ if (this._justOpened) { this._justOpened = false; return; } // the confirm that opened us isn't ours
125
+ if (input.hit('Escape') || input.hit('KeyB') || pad.circle) { input.consume('Escape'); input.consume('KeyB'); return this.close(); }
126
+
127
+ // switch column — Q / a shoulder button toggles his shelf ↔ your satchel (arrows/stick also cross at the edges)
128
+ if (input.hit('KeyQ') || pad.l1 || pad.r1) { input.consume('KeyQ'); this._switchSide(this._side === 'buy' ? 'sell' : 'buy'); }
129
+
130
+ // move the cursor — arrows / WASD / D-pad / left stick. Left/Right cross between the columns at the boundary.
131
+ const right = input.hit('ArrowRight') || input.hit('KeyD') || pad.right;
132
+ const left = input.hit('ArrowLeft') || input.hit('KeyA') || pad.left;
133
+ const down = input.hit('ArrowDown') || input.hit('KeyS') || pad.down;
134
+ const up = input.hit('ArrowUp') || input.hit('KeyW') || pad.up;
135
+ if (right || left || down || up) this._move({ right, left, down, up });
136
+
137
+ // trade the selected — Enter / Space / Cross. (KeyE is deliberately NOT a confirm here: it is the world
138
+ // interact key, and the shop must never risk tripping a door behind a paused counter.)
139
+ if (input.hit('Enter') || input.hit('Space') || pad.cross) {
140
+ input.consume('Enter'); input.consume('Space');
141
+ this._transact();
142
+ }
143
+ }
144
+
145
+ // one connected pad, edge-detected against the previous frame (this._pd) so each press acts ONCE.
146
+ // D-pad / left stick → up/down/left/right (dead-zoned); Cross(0) trades · Circle(1) leaves · L1(4)/R1(5)
147
+ // switch side. Returns {} when no pad is present, so keyboard-only play is untouched.
148
+ _padEdge() {
149
+ const pads = typeof navigator !== 'undefined' && navigator.getGamepads ? navigator.getGamepads() : null;
150
+ let gp = null; if (pads) for (const q of pads) if (q && q.connected) { gp = q; break; }
151
+ if (!gp) { this._pd = null; return {}; }
152
+ const b = (i) => !!gp.buttons[i]?.pressed, ax = (i) => gp.axes[i] || 0;
153
+ const st = { cross: b(0), circle: b(1), l1: b(4), r1: b(5),
154
+ up: b(12) || ax(1) < -0.55, down: b(13) || ax(1) > 0.55, left: b(14) || ax(0) < -0.55, right: b(15) || ax(0) > 0.55 };
155
+ const pv = this._pd || {};
156
+ const edge = {}; for (const k in st) edge[k] = st[k] && !pv[k];
157
+ this._pd = st;
158
+ return edge;
159
+ }
160
+
161
+ // ── navigation ────────────────────────────────────────────────────────────────────────────────
162
+ _list(side) { return side === 'buy' ? this._buyList : this._sellList; }
163
+
164
+ _move({ right, left, down, up }) {
165
+ const side = this._side;
166
+ const len = this._list(side).length;
167
+ let i = this._i[side];
168
+ const col = len ? i % COLS : 0;
169
+ let moved = false;
170
+
171
+ if (right) {
172
+ if (col < COLS - 1 && i + 1 < len) { i++; moved = true; }
173
+ else if (side === 'sell') { this._cross('buy', Math.floor(i / COLS), 0); return; } // off the right of your satchel → his shelf
174
+ }
175
+ if (left) {
176
+ if (col > 0) { i--; moved = true; }
177
+ else if (side === 'buy') { this._cross('sell', Math.floor(i / COLS), COLS - 1); return; } // off his left → your satchel
178
+ }
179
+ if (down && i + COLS < len) { i += COLS; moved = true; }
180
+ if (up && i - COLS >= 0) { i -= COLS; moved = true; }
181
+
182
+ if (moved) { this._i[side] = i; this._syncSel(); this.game.audio?.play?.('ui'); }
183
+ }
184
+
185
+ // Jump to the other column, keeping the row and entering at a chosen column, clamped to that list.
186
+ _cross(toSide, row, enterCol) {
187
+ const list = this._list(toSide);
188
+ if (!list.length) return;
189
+ this._side = toSide;
190
+ let idx = row * COLS + enterCol;
191
+ if (idx >= list.length) idx = list.length - 1;
192
+ if (idx < 0) idx = 0;
193
+ this._i[toSide] = idx;
194
+ this._syncSel();
195
+ this.game.audio?.play?.('ui');
196
+ }
197
+
198
+ _switchSide(side) {
199
+ if (!this._list(side).length) return;
200
+ this._side = side;
201
+ this._syncSel();
202
+ this.game.audio?.play?.('ui');
203
+ }
204
+
205
+ // ── trade ─────────────────────────────────────────────────────────────────────────────────────
206
+ _transact() {
207
+ const row = this._list(this._side)[this._i[this._side]];
208
+ if (!row) return;
209
+ if (this._side === 'buy') this._buy(row.id);
210
+ else this._sell(row.id);
211
+ }
212
+
213
+ _buy(id) {
214
+ if (this._curStock(id) <= 0) { this.ui.toast?.(`He's clean out of that.`); this.game.audio?.play?.('ui'); return; }
215
+ const price = SH.buyPrice(id);
216
+ if (!this.game.loot?.spend(price)) { // spend() returns false and takes nothing if he's short
217
+ this.ui.toast?.(`You're short the coin for that.`);
218
+ this.game.audio?.play?.('ui');
219
+ return;
220
+ }
221
+ this.ui.satchel.add(id, 1); // the same add() the loot seam uses
222
+ this._decStock(id);
223
+ this.game.audio?.play?.('coin');
224
+ this.ui.toast?.(`Bought ${SH.items[id].name} — $${price}.`);
225
+ this._rebuild();
226
+ }
227
+
228
+ _sell(id) {
229
+ if (!this.ui.satchel.has(id, 1)) return;
230
+ const price = SH.sellPrice(id);
231
+ this.ui.satchel.remove(id, 1);
232
+ this.game.loot?.add(price); // add() plays the coin chime
233
+ this.ui.toast?.(`Sold ${SH.items[id].name} — $${price}.`);
234
+ this._rebuild();
235
+ }
236
+
237
+ // ── live stock (persists for the session) ───────────────────────────────────────────────────────
238
+ _liveStock(shopId) {
239
+ let m = this._stock.get(shopId);
240
+ if (!m) {
241
+ m = new Map();
242
+ for (const [id, n] of (SH.getShop(shopId)?.stock || [])) m.set(id, n);
243
+ this._stock.set(shopId, m);
244
+ }
245
+ return m;
246
+ }
247
+ _curStock(id) { return this._liveStock(this._shopId).get(id) ?? 0; }
248
+ _decStock(id) {
249
+ const m = this._liveStock(this._shopId);
250
+ const c = m.get(id);
251
+ if (c !== undefined && c !== Infinity) m.set(id, Math.max(0, c - 1));
252
+ }
253
+
254
+ // ── rendering (event-driven: the world is paused, so we rebuild on open / trade only) ──────────
255
+ _rebuild() {
256
+ // his shelf — shops.js order, live counts, sold-out rows kept so the grid doesn't reflow under you
257
+ const live = this._liveStock(this._shopId);
258
+ this._buyList = (SH.getShop(this._shopId)?.stock || [])
259
+ .map(([id]) => ({ id, stock: live.get(id) ?? 0 }))
260
+ .filter((r) => SH.items[r.id]);
261
+
262
+ // your satchel — aggregate the stacks to one row per item type
263
+ const bag = this.game.player?.satchel || [];
264
+ const agg = new Map();
265
+ for (const s of bag) if (SH.items[s.id]) agg.set(s.id, (agg.get(s.id) || 0) + s.n);
266
+ this._sellList = [...agg].map(([id, n]) => ({ id, n }));
267
+
268
+ // clamp cursors; if you've nothing to sell, don't strand the cursor on an empty column
269
+ if (this._i.buy >= this._buyList.length) this._i.buy = Math.max(0, this._buyList.length - 1);
270
+ if (this._i.sell >= this._sellList.length) this._i.sell = Math.max(0, this._sellList.length - 1);
271
+ if (this._side === 'sell' && !this._sellList.length) this._side = 'buy';
272
+
273
+ this._paintGrid(this._sellEl, this._sellList, 'sell', `Nothing to sell.`);
274
+ this._paintGrid(this._buyEl, this._buyList, 'buy', `Counter's bare.`);
275
+ this._syncSel();
276
+ this._syncMoney();
277
+ this._syncFooter();
278
+ }
279
+
280
+ _paintGrid(gridEl, list, side, emptyMsg) {
281
+ gridEl.textContent = '';
282
+ if (!list.length) {
283
+ const e = document.createElement('div');
284
+ e.className = 'shop-empty';
285
+ e.textContent = emptyMsg;
286
+ gridEl.appendChild(e);
287
+ return;
288
+ }
289
+ const money = this.game.loot?.money ?? 0;
290
+ list.forEach((row, i) => {
291
+ const id = row.id;
292
+ const slot = this._makeSlot(id);
293
+ slot.classList.add('shop-slot');
294
+ slot.dataset.side = side;
295
+ slot.dataset.i = String(i);
296
+
297
+ // badge — his shelf shows what's left (∞ for a staple); your satchel shows a count when >1
298
+ const txt = side === 'buy'
299
+ ? (row.stock === Infinity ? '∞' : String(row.stock))
300
+ : (row.n > 1 ? String(row.n) : '');
301
+ if (txt) {
302
+ const b = document.createElement('span');
303
+ b.className = 'shop-badge';
304
+ b.textContent = txt;
305
+ slot.appendChild(b);
306
+ }
307
+ if (side === 'buy' && row.stock <= 0) slot.classList.add('sold-out');
308
+ else if (side === 'buy' && money < SH.buyPrice(id)) slot.classList.add('cant-afford');
309
+
310
+ slot.onclick = () => { this._side = side; this._i[side] = i; this._syncSel(); this.game.audio?.play?.('ui'); };
311
+ slot.ondblclick = () => { this._side = side; this._i[side] = i; this._transact(); };
312
+ gridEl.appendChild(slot);
313
+ });
314
+ }
315
+
316
+ // Borrow the Satchel's slot builder (plate + baked icon, no count badge — we add our own). Falls
317
+ // back to a bare plate only if the satchel module isn't up yet, so the shop is never blank.
318
+ _makeSlot(id) {
319
+ if (this.ui.satchel?.makeSlot) return this.ui.satchel.makeSlot(id, 1, SLOT);
320
+ const slot = document.createElement('div');
321
+ slot.className = 'satchel-slot';
322
+ const ic = document.createElement('i');
323
+ ic.className = 'satchel-ic';
324
+ const css = this.ui.itemIcons?.iconCss?.(id, SLOT - 18) || '';
325
+ if (css) ic.style.cssText = css;
326
+ slot.appendChild(ic);
327
+ return slot;
328
+ }
329
+
330
+ _syncSel() {
331
+ for (const el of this.root.querySelectorAll('.shop-slot.sel')) el.classList.remove('sel');
332
+ const gridEl = this._side === 'buy' ? this._buyEl : this._sellEl;
333
+ const slots = gridEl.querySelectorAll('.shop-slot');
334
+ slots[this._i[this._side]]?.classList.add('sel');
335
+ this._sellCol.classList.toggle('active', this._side === 'sell');
336
+ this._buyCol.classList.toggle('active', this._side === 'buy');
337
+ this._fillDetail();
338
+ }
339
+
340
+ _fillDetail() {
341
+ const d = this.detail;
342
+ const side = this._side;
343
+ const row = this._list(side)[this._i[side]];
344
+ if (!row) { d.innerHTML = `<div class="shop-hint">Nothing to trade at this counter.</div>`; return; }
345
+
346
+ const item = SH.items[row.id];
347
+ const iconCss = this.ui.itemIcons?.iconCss?.(row.id, 84) || '';
348
+ const money = this.game.loot?.money ?? 0;
349
+
350
+ let meta, label, disabled;
351
+ if (side === 'buy') {
352
+ const price = SH.buyPrice(row.id);
353
+ const sold = row.stock <= 0;
354
+ const afford = money >= price;
355
+ const have = this.ui.satchel?.count?.(row.id) ?? 0;
356
+ const stockTxt = row.stock === Infinity ? 'plenty' : (sold ? 'none left' : `${row.stock} left`);
357
+ meta = `<span>His price <b>$${price}</b></span><span>In stock: ${stockTxt}</span><span>You carry ${have}</span>`;
358
+ label = sold ? 'Sold out' : (afford ? `Buy — $${price}` : `Need $${price}`);
359
+ disabled = sold || !afford;
360
+ } else {
361
+ const price = SH.sellPrice(row.id);
362
+ meta = `<span>He pays <b>$${price}</b></span><span>List $${item.value}</span><span>You carry ${row.n}</span>`;
363
+ label = `Sell — $${price}`;
364
+ disabled = row.n <= 0;
365
+ }
366
+
367
+ d.innerHTML = `
368
+ <i class="shop-dicon" style="${iconCss}"></i>
369
+ <div class="shop-dtext">
370
+ <div class="shop-dname">${item.name}</div>
371
+ <div class="shop-dcat">${item.category}</div>
372
+ <div class="shop-dblurb">${item.blurb}</div>
373
+ <div class="shop-dmeta">${meta}</div>
374
+ </div>
375
+ <div class="shop-act">
376
+ <button class="shop-btn ${side}" ${disabled ? 'disabled' : ''}>${label}</button>
377
+ </div>`;
378
+ const btn = d.querySelector('.shop-btn');
379
+ if (btn && !disabled) btn.onclick = () => this._transact();
380
+ }
381
+
382
+ _syncMoney() { this._moneyEl.textContent = `$${this.game.loot?.money ?? 0}`; }
383
+
384
+ _syncFooter() {
385
+ const w = Math.round(this.ui.satchel?.weight?.() ?? 0);
386
+ const cap = this.ui.satchel?.carryCap ?? 150;
387
+ this._wEl.textContent = `${w} / ${cap} lb`;
388
+ this._wEl.classList.toggle('warn', w > cap);
389
+ }
390
+
391
+ // ── DOM + CSS (built once, on the loading screen) ──────────────────────────────────────────────
392
+ _buildDOM() {
393
+ const root = document.createElement('div');
394
+ root.id = 'shop';
395
+ root.innerHTML = `
396
+ <div class="shop-window">
397
+ <div class="shop-head">
398
+ <div class="shop-name">Store</div>
399
+ <div class="shop-money-wrap"><span class="shop-money-lbl">Money</span> <span class="shop-money"></span></div>
400
+ </div>
401
+ <div class="shop-body">
402
+ <div class="shop-col shop-sell">
403
+ <div class="shop-col-h">Your Satchel</div>
404
+ <div class="shop-grid shop-grid-sell"></div>
405
+ </div>
406
+ <div class="shop-div"></div>
407
+ <div class="shop-col shop-buy active">
408
+ <div class="shop-col-h shop-buy-h">Store</div>
409
+ <div class="shop-grid shop-grid-buy"></div>
410
+ </div>
411
+ </div>
412
+ <div class="shop-rule"></div>
413
+ <div class="shop-detail"></div>
414
+ <div class="shop-foot">
415
+ <div class="shop-weight"><span class="shop-flbl">Weight</span> <span class="shop-wval"></span></div>
416
+ <div class="shop-hintbar">Q — switch side &nbsp;·&nbsp; Enter — trade &nbsp;·&nbsp; Esc — leave</div>
417
+ </div>
418
+ </div>`;
419
+ document.body.appendChild(root);
420
+ this.root = root;
421
+ this._nameEl = root.querySelector('.shop-name');
422
+ this._moneyEl = root.querySelector('.shop-money');
423
+ this._sellCol = root.querySelector('.shop-sell');
424
+ this._buyCol = root.querySelector('.shop-buy');
425
+ this._sellEl = root.querySelector('.shop-grid-sell');
426
+ this._buyEl = root.querySelector('.shop-grid-buy');
427
+ this._buyHEl = root.querySelector('.shop-buy-h');
428
+ this.detail = root.querySelector('.shop-detail');
429
+ this._wEl = root.querySelector('.shop-wval');
430
+ }
431
+
432
+ _injectCSS() {
433
+ if (document.getElementById('shop-css')) return;
434
+ const css = document.createElement('style');
435
+ css.id = 'shop-css';
436
+ css.textContent = `
437
+ #shop { position:fixed; inset:0; z-index:63; display:none; align-items:center; justify-content:center;
438
+ pointer-events:auto; user-select:none;
439
+ background:radial-gradient(circle at 50% 44%, rgba(24,17,10,0.86), rgba(6,4,3,0.96));
440
+ font-family:Copperplate,'Copperplate Gothic Bold',Palatino,'Book Antiqua',Georgia,serif; color:#e8d9b0; }
441
+
442
+ #shop .shop-window { display:flex; flex-direction:column; box-sizing:border-box;
443
+ width:min(1060px,94vw); height:min(680px,92vh);
444
+ padding:20px 26px 18px; border:22px solid transparent;
445
+ border-image:url(${PANELS}/panel_frame.png) 40 stretch;
446
+ background:rgba(20,14,8,0.96) padding-box;
447
+ box-shadow:0 0 44px rgba(0,0,0,0.85), inset 0 0 70px rgba(0,0,0,0.5); }
448
+
449
+ /* header: shop name (left), money (right) */
450
+ #shop .shop-head { flex:none; display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; }
451
+ #shop .shop-name { font-size:20px; letter-spacing:6px; font-weight:700; color:#e8c98a; text-transform:uppercase; text-shadow:0 2px 6px #000; }
452
+ #shop .shop-money-lbl { color:#9c8858; text-transform:uppercase; font-size:11px; letter-spacing:2px; }
453
+ #shop .shop-money { color:#f0e0b0; font-weight:700; font-size:18px; letter-spacing:1px; margin-left:6px; }
454
+
455
+ /* two columns */
456
+ #shop .shop-body { flex:1; min-height:0; display:flex; gap:22px; }
457
+ #shop .shop-col { flex:1; min-width:0; min-height:0; display:flex; flex-direction:column; }
458
+ #shop .shop-col-h { flex:none; text-align:center; font-size:12px; letter-spacing:2px; text-transform:uppercase;
459
+ color:#9c8858; padding:2px 0 8px; border-bottom:2px solid transparent; margin-bottom:6px; }
460
+ #shop .shop-col.active .shop-col-h { color:#f0e0b0; border-bottom-color:${BRASS}; text-shadow:0 1px 3px #000; }
461
+ #shop .shop-div { flex:none; width:2px; align-self:stretch;
462
+ background:linear-gradient(180deg, transparent, #6a5630 12%, #6a5630 88%, transparent); opacity:0.7; }
463
+
464
+ #shop .shop-grid { flex:1; min-height:0; overflow-y:auto; overflow-x:hidden;
465
+ display:grid; grid-template-columns:repeat(${COLS}, ${SLOT}px); gap:${GAP}px;
466
+ justify-content:center; align-content:start; padding:6px 2px; }
467
+ #shop .shop-empty { grid-column:1 / -1; color:#8a7a54; font-size:13px; opacity:0.8; padding:22px 6px; text-align:center; }
468
+
469
+ /* slots — the plate + icon come from the satchel's makeSlot (.satchel-slot/.satchel-ic); these
470
+ rules re-scope that plate under #shop, and .shop-* carries the trade-view states on top. */
471
+ #shop .satchel-slot { position:relative; width:${SLOT}px; height:${SLOT}px; box-sizing:border-box;
472
+ cursor:pointer; border:12px solid transparent;
473
+ border-image:url(${PANELS}/slot.png) 60 fill / 12px stretch; }
474
+ #shop .satchel-ic { position:absolute; inset:9px; background-repeat:no-repeat; }
475
+ #shop .shop-slot:hover { box-shadow:inset 0 0 0 999px rgba(229,214,75,0.10), 0 0 0 1px rgba(229,214,75,0.45); }
476
+ #shop .shop-slot.sel { box-shadow:0 0 0 2px ${BRASS}, 0 0 14px rgba(229,214,75,0.55); filter:brightness(1.1); }
477
+ #shop .shop-slot.cant-afford { opacity:0.6; }
478
+ #shop .shop-slot.sold-out { filter:grayscale(1) brightness(0.45); cursor:default; }
479
+ #shop .shop-badge { position:absolute; right:6px; bottom:4px; font:700 16px/1 ui-monospace,monospace;
480
+ color:#f4ecc8; text-shadow:0 1px 2px #000, 0 0 3px #000; padding:1px 3px; }
481
+
482
+ /* the rule + detail strip under the two columns */
483
+ #shop .shop-rule { flex:none; height:8px; margin:10px 0 8px;
484
+ background:url(${PANELS}/rule.png) center/100% 100% no-repeat; opacity:0.85; }
485
+ #shop .shop-detail { flex:none; display:flex; align-items:center; gap:18px; min-height:104px; padding:2px 6px; }
486
+ #shop .shop-dicon { flex:none; width:84px; height:84px; background-repeat:no-repeat;
487
+ filter:drop-shadow(0 3px 6px rgba(0,0,0,0.6)); }
488
+ #shop .shop-dtext { flex:1; min-width:0; }
489
+ #shop .shop-dname { font-size:18px; font-weight:700; color:#f0e0b0; letter-spacing:1px; text-shadow:0 1px 3px #000; }
490
+ #shop .shop-dcat { font-size:11px; letter-spacing:2px; text-transform:uppercase; color:#9c8858; margin-top:1px; }
491
+ #shop .shop-dblurb { font-size:13px; line-height:1.45; color:#c9b892; font-style:italic; margin:6px 0;
492
+ font-family:Palatino,'Book Antiqua',Georgia,serif; max-width:64ch; }
493
+ #shop .shop-dmeta { display:flex; gap:20px; flex-wrap:wrap; font-size:12px; color:#cdbb8e; letter-spacing:0.5px; }
494
+ #shop .shop-dmeta b { color:#e8c87a; }
495
+ #shop .shop-hint { color:#8a7a54; font-size:14px; opacity:0.85; padding:22px 0; text-align:center; width:100%; }
496
+
497
+ #shop .shop-act { flex:none; }
498
+ #shop .shop-btn { cursor:pointer; padding:12px 22px; font:700 15px/1 inherit; letter-spacing:1px;
499
+ text-transform:uppercase; color:#f0e0b0; background:rgba(60,42,20,0.75); border:1px solid #7a5c30;
500
+ border-radius:5px; min-width:158px; text-align:center; }
501
+ #shop .shop-btn.sell { color:#e6d29a; }
502
+ #shop .shop-btn:hover:not([disabled]) { background:rgba(96,68,34,0.9); border-color:#e8c87a; color:#fff4c8; }
503
+ #shop .shop-btn[disabled] { opacity:0.42; cursor:default; }
504
+
505
+ /* footer: carry weight (soft cap, warn only) + the controls hint */
506
+ #shop .shop-foot { flex:none; display:flex; justify-content:space-between; align-items:center;
507
+ font-size:12px; letter-spacing:1px; padding:6px 4px 0; margin-top:6px; color:#9c8858; }
508
+ #shop .shop-flbl { text-transform:uppercase; font-size:11px; letter-spacing:2px; }
509
+ #shop .shop-wval { color:#f0e0b0; font-weight:700; margin-left:6px; }
510
+ #shop .shop-wval.warn { color:#e0a94b; }
511
+ #shop .shop-hintbar { color:#8a7a54; }
512
+ `;
513
+ document.head.appendChild(css);
514
+ }
515
+ }