@imperialbower/bowery 0.0.0-stage → 0.1.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.
package/bowery.js ADDED
@@ -0,0 +1,413 @@
1
+ // <bowery-table>: one poker table control, configured not compiled.
2
+ //
3
+ // Usage:
4
+ // <script type="module" src="bowery/bowery.js"></script>
5
+ // <bowery-table base="bowery/">
6
+ // <script type="application/yaml">mode: replay …</script>
7
+ // <svg …>the fallback picture</svg>
8
+ // </bowery-table>
9
+ //
10
+ // Nothing loads until the element is within a screen of the viewport. The
11
+ // first element instantiates the wasm module; every later one reuses it and
12
+ // builds its own BoweryTable, so tables on a page are independent. The
13
+ // fallback picture stays if the config, the import or the init fails, and a
14
+ // config's problems are printed under it. Every request goes to `base`
15
+ // (default: this module's directory) or to `src`, both on the page's origin.
16
+ // Contract: bower docs/EPIC-20_Bowery.md § <bowery-table>.
17
+
18
+ const HERE = new URL('.', import.meta.url);
19
+ const GLUE = 'bowery_wasm.js';
20
+ const WASM = 'bowery_wasm_bg.wasm';
21
+ const STYLE = 'bowery.css';
22
+
23
+ /** One import and one init per base, shared by every element. */
24
+ const modules = new Map();
25
+
26
+ function loadModule(base) {
27
+ const key = base.href;
28
+ if (!modules.has(key)) {
29
+ modules.set(
30
+ key,
31
+ import(new URL(GLUE, base).href).then(async (glue) => {
32
+ await glue.default({ module_or_path: new URL(WASM, base) });
33
+ return glue;
34
+ }),
35
+ );
36
+ }
37
+ return modules.get(key);
38
+ }
39
+
40
+ /** mdBook's dark themes, or the system's dark preference. */
41
+ function prefersDark() {
42
+ const html = document.documentElement.classList;
43
+ if (html.contains('coal') || html.contains('navy') || html.contains('ayu')) return true;
44
+ if (html.contains('light') || html.contains('rust')) return false;
45
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
46
+ }
47
+
48
+ /** A button; `name` is its accessible name when the label is a symbol. */
49
+ function button(label, onClick, name) {
50
+ const element = document.createElement('button');
51
+ element.type = 'button';
52
+ element.textContent = label;
53
+ if (name) {
54
+ element.setAttribute('aria-label', name);
55
+ element.title = name;
56
+ }
57
+ element.addEventListener('click', onClick);
58
+ return element;
59
+ }
60
+
61
+ class BoweryTableElement extends HTMLElement {
62
+ static get observedAttributes() {
63
+ return ['src'];
64
+ }
65
+
66
+ connectedCallback() {
67
+ if (this._wired) return;
68
+ this._wired = true;
69
+ this._base = new URL(this.getAttribute('base') || HERE.href, document.baseURI);
70
+ if (!this.hasAttribute('tabindex')) this.tabIndex = 0;
71
+ const observer = new IntersectionObserver(
72
+ (entries) => {
73
+ if (entries.some((entry) => entry.isIntersecting)) {
74
+ observer.disconnect();
75
+ this._start();
76
+ }
77
+ },
78
+ { rootMargin: '100% 0px' },
79
+ );
80
+ observer.observe(this);
81
+ }
82
+
83
+ disconnectedCallback() {
84
+ clearTimeout(this._timer);
85
+ this._resize?.disconnect();
86
+ }
87
+
88
+ attributeChangedCallback(name, before, after) {
89
+ if (name === 'src' && this._table && before !== after) this._reload();
90
+ }
91
+
92
+ async _reload() {
93
+ clearTimeout(this._timer);
94
+ this._table?.free?.();
95
+ this._table = null;
96
+ await this._start();
97
+ }
98
+
99
+ async _start() {
100
+ if (location.protocol === 'file:') {
101
+ this._fail('this table needs a served page');
102
+ return;
103
+ }
104
+ let yaml;
105
+ try {
106
+ yaml = await this._config();
107
+ } catch (error) {
108
+ this._fail(`the config did not load: ${error.message}`);
109
+ return;
110
+ }
111
+ let glue;
112
+ try {
113
+ glue = await loadModule(this._base);
114
+ } catch (error) {
115
+ this._fail('the table did not load');
116
+ this._emit('bowery:error', { message: String(error) });
117
+ return;
118
+ }
119
+ try {
120
+ this._table = new glue.BoweryTable(yaml);
121
+ } catch (error) {
122
+ this._problems(String(error));
123
+ return;
124
+ }
125
+ this._version = glue.BoweryTable.version();
126
+ this._mount();
127
+ this._render();
128
+ this._emit('bowery:ready', { version: this._version });
129
+ this._pace();
130
+ }
131
+
132
+ /** The inline YAML, or the file `src` names (same origin only). */
133
+ async _config() {
134
+ const src = this.getAttribute('src');
135
+ if (src) {
136
+ const url = new URL(src, document.baseURI);
137
+ if (url.origin !== location.origin) throw new Error('src must be on this page\'s origin');
138
+ const response = await fetch(url);
139
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
140
+ return response.text();
141
+ }
142
+ const script = this.querySelector('script[type="application/yaml"]');
143
+ if (!script) throw new Error('no <script type="application/yaml"> and no src');
144
+ return script.textContent;
145
+ }
146
+
147
+ /** The fallback stays; a note goes under it. */
148
+ _fail(message) {
149
+ const note = document.createElement('p');
150
+ note.className = 'bowery-note';
151
+ note.textContent = message;
152
+ this.append(note);
153
+ }
154
+
155
+ /** The fallback stays; the config's problems go under it. */
156
+ _problems(json) {
157
+ let lines;
158
+ try {
159
+ lines = JSON.parse(json).map((problem) => (problem.path ? `${problem.path}: ${problem.message}` : problem.message));
160
+ } catch {
161
+ lines = [json];
162
+ }
163
+ const pre = document.createElement('pre');
164
+ pre.className = 'bowery-problems';
165
+ pre.textContent = lines.join('\n');
166
+ this.append(pre);
167
+ this._emit('bowery:error', { problems: lines });
168
+ }
169
+
170
+ _mount() {
171
+ const root = this.shadowRoot || this.attachShadow({ mode: 'open' });
172
+ root.replaceChildren();
173
+ // A custom element is inline until bowery.css arrives; block at once, so
174
+ // the first frame is measured and drawn at the element's real width.
175
+ const block = document.createElement('style');
176
+ block.textContent = ':host { display: block; }';
177
+ const style = document.createElement('link');
178
+ style.rel = 'stylesheet';
179
+ style.href = new URL(STYLE, this._base).href;
180
+ this._stage = document.createElement('div');
181
+ this._stage.className = 'stage';
182
+ this._bar = document.createElement('div');
183
+ this._bar.className = 'bar';
184
+ this._status = document.createElement('div');
185
+ this._status.className = 'status';
186
+ this._status.setAttribute('aria-live', 'polite');
187
+ root.append(block, style, this._stage, this._bar, this._status);
188
+
189
+ this._width = this.clientWidth || 680;
190
+ this._resize = new ResizeObserver((entries) => {
191
+ const width = Math.round(entries[0].contentRect.width);
192
+ if (width > 0 && width !== this._width) {
193
+ this._width = width;
194
+ this._draw();
195
+ }
196
+ });
197
+ this._resize.observe(this);
198
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => this._render());
199
+ new MutationObserver(() => this._render()).observe(document.documentElement, {
200
+ attributes: true,
201
+ attributeFilter: ['class'],
202
+ });
203
+ this.addEventListener('keydown', (event) => this._key(event));
204
+ }
205
+
206
+ _state() {
207
+ return JSON.parse(this._table.state());
208
+ }
209
+
210
+ _draw() {
211
+ const dark = prefersDark();
212
+ this.toggleAttribute('data-dark', dark);
213
+ // The SVG is bowery's own renderer's: every name and caption from the
214
+ // config is XML-escaped there (tests/draw.rs draw__names_are_escaped),
215
+ // and it holds no script.
216
+ this._stage.innerHTML = this._table.svg(this._width, dark);
217
+ }
218
+
219
+ _render() {
220
+ if (!this._table) return;
221
+ const before = this._last;
222
+ const state = this._state();
223
+ this._last = state;
224
+ this._draw();
225
+ this._controls(state);
226
+ this._status.textContent = state.caption;
227
+ this._emit('bowery:moment', { moment: state.moment - 1, moments: state.moments });
228
+ if (state.record === 'off' && before?.record !== 'off') this._emit('bowery:off-record', { since: state.since });
229
+ if (state.hand_over && !before?.hand_over) this._emit('bowery:hand-over', { result: state.result });
230
+ }
231
+
232
+ /** The bar under the table, rebuilt for the state; focus stays put. */
233
+ _controls(state) {
234
+ const focused = this.shadowRoot.activeElement?.dataset?.control;
235
+ const bar = this._bar;
236
+ bar.replaceChildren();
237
+ const add = (element, control) => {
238
+ element.dataset.control = control;
239
+ bar.append(element);
240
+ return element;
241
+ };
242
+ if (state.controls.seek) {
243
+ add(button('⏮', () => this._seek(0), 'first'), 'first');
244
+ add(button('◀', () => this._seek(state.moment - 2), 'back'), 'back');
245
+ add(button('▶', () => this._step(), 'next'), 'next');
246
+ add(button('⏭', () => this._seek(state.moments - 1), 'last'), 'last');
247
+ const readout = document.createElement('span');
248
+ readout.className = 'readout';
249
+ readout.textContent = `${state.moment} / ${state.moments}`;
250
+ add(readout, 'readout');
251
+ }
252
+ if (state.controls.take_seat && !state.hand_over) {
253
+ const select = document.createElement('select');
254
+ select.setAttribute('aria-label', 'seat');
255
+ for (const seat of state.seats.filter((seat) => seat.live)) {
256
+ const option = document.createElement('option');
257
+ option.value = seat.seat;
258
+ option.textContent = seat.name;
259
+ option.selected = seat.seat === state.reader;
260
+ select.append(option);
261
+ }
262
+ add(select, 'seat');
263
+ add(button('Take this seat', () => this._take(Number(select.value))), 'take');
264
+ }
265
+ if (state.controls.back_to_record) add(button('Back to the record', () => this._back()), 'back-to-record');
266
+ // Empty unless the reader can act at this moment.
267
+ if (state.legal.length > 0) this._actions(state, add);
268
+ if (state.controls.next_hand) add(button('Next hand', () => this._next()), 'next-hand');
269
+ if (state.controls.shuffle) add(button('Shuffle', () => this._shuffle()), 'shuffle');
270
+ if (state.controls.view) {
271
+ add(button(`👁 ${state.view}`, () => this._toggleView(state.view)), 'view');
272
+ }
273
+ if (state.controls.layout) {
274
+ add(button(`▦ ${state.layout}`, () => this._toggleLayout(state.layout)), 'layout');
275
+ }
276
+ const again = focused && bar.querySelector(`[data-control="${focused}"]`);
277
+ if (again) again.focus();
278
+ }
279
+
280
+ /** The reader's legal actions as buttons; a size box for bet or raise. */
281
+ _actions(state, add) {
282
+ for (const legal of state.legal) {
283
+ if (legal.action === 'bet' || legal.action === 'raise') {
284
+ const size = document.createElement('input');
285
+ size.type = 'number';
286
+ size.min = legal.min;
287
+ size.max = legal.max;
288
+ size.value = legal.min;
289
+ size.setAttribute('aria-label', `${legal.action} to`);
290
+ add(size, `${legal.action}-size`);
291
+ add(
292
+ button(legal.action === 'bet' ? 'Bet' : 'Raise to', () =>
293
+ this._act({ action: legal.action, amount: Number(size.value) }),
294
+ ),
295
+ legal.action,
296
+ );
297
+ } else {
298
+ const label = {
299
+ fold: 'Fold',
300
+ check: 'Check',
301
+ call: `Call ${legal.amount?.toLocaleString('en-US') ?? ''}`.trim(),
302
+ all_in: 'All in',
303
+ }[legal.action];
304
+ add(button(label, () => this._act({ action: legal.action })), legal.action);
305
+ }
306
+ }
307
+ }
308
+
309
+ _key(event) {
310
+ const state = this._last;
311
+ if (!state?.controls.seek || event.target !== this) return;
312
+ const moves = {
313
+ ArrowLeft: state.moment - 2,
314
+ ArrowRight: state.moment,
315
+ Home: 0,
316
+ End: state.moments - 1,
317
+ };
318
+ if (event.key in moves) {
319
+ event.preventDefault();
320
+ this._seek(moves[event.key]);
321
+ }
322
+ }
323
+
324
+ _seek(index) {
325
+ clearTimeout(this._timer);
326
+ this._table.seek(Math.max(0, index));
327
+ this._render();
328
+ }
329
+
330
+ /** ▶: one moment on; on the record it stops at the reader's turn. */
331
+ _step() {
332
+ this._try(() => this._table.advance());
333
+ this._pace();
334
+ }
335
+
336
+ _take(seat) {
337
+ this._try(() => this._table.take_seat(seat));
338
+ }
339
+
340
+ _back() {
341
+ clearTimeout(this._timer);
342
+ this._table.back_to_record();
343
+ this._render();
344
+ }
345
+
346
+ _act(action) {
347
+ this._try(() => this._table.act(JSON.stringify(action)));
348
+ this._pace();
349
+ }
350
+
351
+ _next() {
352
+ this._try(() => this._table.next_hand());
353
+ this._pace();
354
+ }
355
+
356
+ _shuffle() {
357
+ clearTimeout(this._timer);
358
+ this._try(() => this._table.shuffle((Math.random() * 0xffffffff) >>> 0));
359
+ this._pace();
360
+ }
361
+
362
+ _toggleView(view) {
363
+ this._table.set_view(view === 'all' ? 'seat' : 'all');
364
+ this._render();
365
+ }
366
+
367
+ _toggleLayout(layout) {
368
+ const order = ['ring', 'spotlight', 'ledger'];
369
+ this._table.set_layout(order[(order.indexOf(layout) + 1) % order.length]);
370
+ this._render();
371
+ }
372
+
373
+ _try(move) {
374
+ try {
375
+ move();
376
+ } catch (error) {
377
+ this._status.textContent = String(error);
378
+ this._emit('bowery:error', { message: String(error) });
379
+ }
380
+ this._render();
381
+ }
382
+
383
+ /**
384
+ * Off the record and in play, bots and deals go on by themselves, one
385
+ * moment per `delay_ms`, while the latest moment is on screen and it is
386
+ * not the reader's turn.
387
+ */
388
+ _pace() {
389
+ clearTimeout(this._timer);
390
+ const state = this._state();
391
+ const live = state.mode === 'play' || state.record === 'off';
392
+ if (!live || state.hand_over || state.moment !== state.moments) return;
393
+ this._timer = setTimeout(() => {
394
+ let answer;
395
+ try {
396
+ answer = this._table.advance();
397
+ } catch (error) {
398
+ this._emit('bowery:error', { message: String(error) });
399
+ return;
400
+ }
401
+ this._render();
402
+ if (answer === 'acted' || answer === 'dealt') this._pace();
403
+ }, state.delay_ms);
404
+ }
405
+
406
+ _emit(name, detail) {
407
+ this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
408
+ }
409
+ }
410
+
411
+ if (!customElements.get('bowery-table')) customElements.define('bowery-table', BoweryTableElement);
412
+
413
+ export { BoweryTableElement };