eojeon-cli 0.0.0-stage → 0.2.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +26 -0
  3. package/README.md +251 -2
  4. package/cli/eojeon.js +104 -0
  5. package/court.config.json +325 -0
  6. package/package.json +60 -4
  7. package/public/app.js +405 -0
  8. package/public/court-types.js +33 -0
  9. package/public/demo.js +100 -0
  10. package/public/fonts/D2Coding-Ver1.4.0-20261003.ttf +0 -0
  11. package/public/fonts/Galmuri11-Bold.woff2 +0 -0
  12. package/public/fonts/Galmuri11.woff2 +0 -0
  13. package/public/fonts/Galmuri7.woff2 +0 -0
  14. package/public/fonts/Galmuri9.woff2 +0 -0
  15. package/public/fonts/LICENSE-D2Coding.txt +97 -0
  16. package/public/fonts/LICENSE-Galmuri.txt +93 -0
  17. package/public/index.html +98 -0
  18. package/public/scene.js +1082 -0
  19. package/public/style.css +319 -0
  20. package/scripts/fix-pty.js +25 -0
  21. package/server/agents.js +78 -0
  22. package/server/hook.js +68 -0
  23. package/server/index.js +541 -0
  24. package/server/sanitize.js +155 -0
  25. package/server/sessions.js +115 -0
  26. package/server/watch.js +96 -0
  27. package/templates/agents/studio/animation-expert.md +29 -0
  28. package/templates/agents/studio/apple-designer.md +28 -0
  29. package/templates/agents/studio/branding-expert.md +29 -0
  30. package/templates/agents/studio/copywriter.md +28 -0
  31. package/templates/agents/studio/data-analyst.md +29 -0
  32. package/templates/agents/studio/marketer.md +28 -0
  33. package/templates/agents/studio/pr-lead.md +28 -0
  34. package/templates/agents/studio/product-designer.md +29 -0
  35. package/templates/agents/studio/sns-manager.md +28 -0
  36. package/templates/agents/studio/uiux-expert.md +31 -0
  37. package/templates/agents/studio/video-producer.md +29 -0
  38. package/templates/agents/yukjo/byeongjo.md +29 -0
  39. package/templates/agents/yukjo/gongjo.md +29 -0
  40. package/templates/agents/yukjo/hojo.md +29 -0
  41. package/templates/agents/yukjo/hyeongjo.md +29 -0
  42. package/templates/agents/yukjo/ijo.md +29 -0
  43. package/templates/agents/yukjo/yejo.md +29 -0
@@ -0,0 +1,1082 @@
1
+ // Pixel court scene: canvas for the drawing, a DOM overlay (same logical
2
+ // coordinates) for text, bubbles, the petition panel and stamps.
3
+ // Everything here is hand-drawn rectangles — no traced photos, no real seals.
4
+
5
+ import { lookupOfficial } from './court-types.js';
6
+
7
+ export const W = 400;
8
+ export const H = 224;
9
+
10
+ const FPS = 8; // sprite frame rate (concept: 8fps, walk 4 frames, brush 2 frames)
11
+ const WALK_SPEED = 64; // logical px per second
12
+ const MAX_BUBBLES = 3; // more than this at once → only the newest show; the rest are in the 사관 일지
13
+ // petition panel sits top-right over the back wall, clear of every desk and the throne
14
+ export const PETITION_BOX = { x: 238, y: 4, w: 158 };
15
+
16
+ const EMBLEMS = {
17
+ scrolls: ['1111', '1001', '1111', '1001'],
18
+ grid: ['1111', '1010', '1111', '1010'],
19
+ abacus: ['1010', '1111', '0101', '1111'],
20
+ flag: ['1110', '1100', '1000', '1000'],
21
+ brush: ['0001', '0010', '0100', '1100'],
22
+ seal: ['1111', '1001', '1001', '1111'],
23
+ scale: ['1001', '1111', '0110', '0110'],
24
+ hoof: ['0110', '1001', '1001', '1001'],
25
+ square: ['1000', '1000', '1000', '1111'],
26
+ beam: ['1111', '0110', '0110', '0110'],
27
+ drum: ['0110', '1111', '1111', '0110'],
28
+ album: ['1110', '1010', '1110', '0000'],
29
+ plain: ['0000', '0110', '0110', '0000'],
30
+ steps: ['0001', '0011', '0111', '1111'],
31
+ shield: ['1111', '1111', '1111', '0110'],
32
+ lens: ['0110', '1001', '0110', '0001'],
33
+ hammer: ['1111', '1111', '0100', '0100'],
34
+ };
35
+
36
+ // seat index -> position. Row-major, nearest the throne first; inner column first.
37
+ const SEATS = {
38
+ east: [[124, 104], [62, 104], [124, 140], [62, 140], [124, 176], [62, 176]],
39
+ west: [[276, 104], [338, 104], [276, 140], [338, 140], [276, 176], [338, 176]],
40
+ };
41
+ const GUEST_SPOTS = [[168, 206], [232, 206], [146, 214], [254, 214], [190, 218], [210, 218], [102, 214], [298, 214]];
42
+ const DOOR = [200, H + 24];
43
+ const THRONE_FRONT = [200, 92];
44
+ const HISTORIAN = [84, 76];
45
+
46
+ function shade(hex, f) {
47
+ const n = parseInt(hex.slice(1), 16);
48
+ const r = Math.max(0, Math.min(255, Math.round(((n >> 16) & 255) * f)));
49
+ const g = Math.max(0, Math.min(255, Math.round(((n >> 8) & 255) * f)));
50
+ const b = Math.max(0, Math.min(255, Math.round((n & 255) * f)));
51
+ return '#' + ((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1);
52
+ }
53
+
54
+ function fmt(tpl, vars) {
55
+ return String(tpl || '').replace(/\{(\w+)\}/g, (_, k) => (vars[k] == null ? '' : String(vars[k])));
56
+ }
57
+
58
+ function el(tag, cls, parent, text) {
59
+ const e = document.createElement(tag);
60
+ if (cls) e.className = cls;
61
+ if (text != null) e.textContent = text;
62
+ if (parent) parent.appendChild(e);
63
+ return e;
64
+ }
65
+
66
+ function elapsedText(ms) {
67
+ const s = Math.max(0, Math.round(ms / 1000));
68
+ if (s < 60) return `${s}초째`;
69
+ const m = Math.floor(s / 60);
70
+ if (m < 60) return `${m}분째`;
71
+ return `${Math.floor(m / 60)}시간 ${m % 60}분째`;
72
+ }
73
+
74
+ class Official {
75
+ constructor(court, { key, def, kind, id }) {
76
+ this.court = court;
77
+ this.key = key; // agent type ('main' for Claude itself)
78
+ this.id = id || key;
79
+ this.kind = kind; // 'main' | 'seat' | 'guest' | 'historian'
80
+ this.title = def.title;
81
+ this.role = def.role || '';
82
+ this.robe = def.robe || 'nok';
83
+ this.emblem = def.emblem || 'plain';
84
+ this.home = [0, 0];
85
+ this.x = DOOR[0];
86
+ this.y = DOOR[1];
87
+ this.path = [];
88
+ this.onArrive = null;
89
+ this.state = 'away'; // away | walking | seated | working | standing
90
+ this.workUntil = 0;
91
+ this.workStart = 0;
92
+ this.carry = false;
93
+ this.blotUntil = 0;
94
+ this.visible = false;
95
+ this.blinkSeed = Math.random() * 4000;
96
+ this.tag = el('div', `tag robe-${this.robe}`, court.overlay, this.title);
97
+ this.bubble = el('div', 'bubble', court.overlay);
98
+ this.bubbleMain = el('div', 'b-main', this.bubble);
99
+ this.bubbleSub = el('div', 'b-sub', this.bubble);
100
+ this.bubbleUntil = 0;
101
+ this.bubbleSticky = false;
102
+ this.tag.title = this.role;
103
+ }
104
+
105
+ place(x, y) {
106
+ this.x = x;
107
+ this.y = y;
108
+ }
109
+
110
+ walk(points, onArrive) {
111
+ this.path = points.map((p) => [...p]);
112
+ this.onArrive = onArrive || null;
113
+ this.state = 'walking';
114
+ this.visible = true;
115
+ if (this.court.reduced) {
116
+ const last = this.path[this.path.length - 1];
117
+ this.x = last[0];
118
+ this.y = last[1];
119
+ this.path = [];
120
+ this.arrive();
121
+ }
122
+ }
123
+
124
+ arrive() {
125
+ const cb = this.onArrive;
126
+ this.onArrive = null;
127
+ this.state = this.atHome() && this.kind !== 'guest' ? 'seated' : 'standing';
128
+ if (cb) cb();
129
+ }
130
+
131
+ atHome() {
132
+ return Math.abs(this.x - this.home[0]) < 1 && Math.abs(this.y - this.home[1]) < 1;
133
+ }
134
+
135
+ say(main, sub = '', ms = 3200, sticky = false) {
136
+ this.bubbleMain.textContent = main;
137
+ this.bubbleSub.textContent = sub;
138
+ this.bubbleSub.style.display = sub ? '' : 'none';
139
+ this.bubbleUntil = performance.now() + ms;
140
+ this.bubbleSticky = sticky;
141
+ this.bubbleAt = performance.now();
142
+ }
143
+
144
+ hush() {
145
+ this.bubbleUntil = 0;
146
+ this.bubbleSticky = false;
147
+ }
148
+
149
+ work(text) {
150
+ this.workStart = performance.now();
151
+ this.workUntil = Infinity;
152
+ this.workText = text;
153
+ if (this.state === 'seated' || this.state === 'standing') this.state = 'working';
154
+ this.say(text, '', 60_000, true);
155
+ }
156
+
157
+ doneWork(ok = true, failText = '') {
158
+ this.workUntil = performance.now() + 700;
159
+ this.bubbleSticky = false;
160
+ if (!ok) {
161
+ this.blotUntil = performance.now() + 4000;
162
+ this.say(failText, '두루마리에 먹물이 번졌사옵니다', 4000);
163
+ } else {
164
+ this.bubbleUntil = performance.now() + 1400;
165
+ }
166
+ }
167
+
168
+ step(dt, now) {
169
+ if (this.path.length) {
170
+ const [tx, ty] = this.path[0];
171
+ const dx = tx - this.x;
172
+ const dy = ty - this.y;
173
+ const d = Math.hypot(dx, dy);
174
+ const v = WALK_SPEED * dt;
175
+ if (d <= v) {
176
+ this.x = tx;
177
+ this.y = ty;
178
+ this.path.shift();
179
+ if (!this.path.length) this.arrive();
180
+ } else {
181
+ this.x += (dx / d) * v;
182
+ this.y += (dy / d) * v;
183
+ }
184
+ }
185
+ if (this.state === 'working' && now > this.workUntil) this.state = this.atHome() && this.kind !== 'guest' ? 'seated' : 'standing';
186
+ }
187
+
188
+ updateDom(now) {
189
+ const show = this.visible;
190
+ const bx = Math.round(this.x);
191
+ const by = Math.round(this.y);
192
+ this.tag.style.display = show && this.state !== 'walking' ? '' : 'none';
193
+ this.tag.style.transform = `translate(${bx}px, ${by + 2}px) translateX(-50%)`;
194
+ // position is decided by Court.layoutBubbles (overlap avoidance, max 3 at once)
195
+ this.bubbleOn = show && (this.bubbleSticky || now < this.bubbleUntil);
196
+ this.anchor = [bx, by - 34];
197
+ if (!this.bubbleOn) this.bubble.classList.remove('on');
198
+ if (this.bubbleOn) {
199
+ if (this.state === 'working' && this.workUntil === Infinity) {
200
+ this.bubbleSub.style.display = '';
201
+ this.bubbleSub.textContent = `${this.court.ph.workingSuffix || '중이옵니다'} · ${elapsedText(now - this.workStart)}`;
202
+ }
203
+ this.bubble.classList.toggle('blot', now < this.blotUntil);
204
+ }
205
+ }
206
+
207
+ draw(ctx, now, px) {
208
+ if (!this.visible) return;
209
+ const seated = this.state === 'seated' || (this.state === 'working' && this.atHome() && this.kind !== 'guest');
210
+ const frame = Math.floor((now / 1000) * FPS);
211
+ const walking = this.state === 'walking';
212
+ const working = this.state === 'working';
213
+ const wf = walking ? frame % 4 : 0;
214
+ const bob = walking && (wf === 1 || wf === 3) ? -1 : 0;
215
+ const ox = Math.round(this.x) - 16;
216
+ const oy = Math.round(this.y) - 31 + bob + (seated ? 3 : 0);
217
+ const P = this.court.pix;
218
+ const robe = P[this.robe] || P.nok;
219
+ const robeD = shade(robe, 0.72);
220
+ const O = P.outline;
221
+ const r = (x, y, w, h, c) => px(ox + x, oy + y, w, h, c);
222
+
223
+ // shadow
224
+ px(Math.round(this.x) - 9, Math.round(this.y) + (seated ? 2 : 0), 18, 2, 'rgba(0,0,0,0.35)');
225
+
226
+ // samo (official's hat): crown + wings
227
+ r(12, 0, 8, 1, O);
228
+ r(11, 1, 10, 6, O);
229
+ r(12, 1, 8, 5, P.heuk);
230
+ r(13, 1, 6, 1, shade(P.heuk, 1.5));
231
+ r(5, 4, 22, 3, O);
232
+ r(6, 5, 5, 1, P.heuk);
233
+ r(21, 5, 5, 1, P.heuk);
234
+ // face
235
+ r(11, 7, 10, 7, O);
236
+ r(12, 7, 8, 6, P.skin);
237
+ r(12, 12, 8, 1, P.skinShade);
238
+ const blink = (now + this.blinkSeed) % 4000 < 130;
239
+ if (blink) {
240
+ r(14, 10, 1, 1, P.skinShade);
241
+ r(17, 10, 1, 1, P.skinShade);
242
+ } else {
243
+ r(14, 9, 1, 2, O);
244
+ r(17, 9, 1, 2, O);
245
+ }
246
+ // robe (dallyeong)
247
+ const rows = [
248
+ [10, 12], [9, 14], [9, 14], [8, 16], [8, 16], [8, 16], [7, 18], [7, 18],
249
+ [7, 18], [7, 18], [6, 20], [6, 20], [6, 20], [6, 20], [6, 20], [6, 20],
250
+ ];
251
+ rows.forEach(([x0, w], i) => {
252
+ r(x0 - 1, 14 + i, w + 2, 1, O);
253
+ r(x0, 14 + i, w, 1, robe);
254
+ });
255
+ r(5, 29, 22, 1, O);
256
+ // round collar
257
+ r(13, 14, 6, 1, P.paper);
258
+ r(14, 15, 4, 1, robeD);
259
+ // sleeves (darker), hands
260
+ const brushUp = working && frame % 2 === 0;
261
+ // walk cycle (4 frames): arms swing opposite to the stepping foot
262
+ const swing = walking ? [0, -1, 0, 1][wf] : 0;
263
+ r(6, 18 + swing, 3, 8, robeD);
264
+ r(6, 26 + swing, 3, 2, P.skin);
265
+ if (working) {
266
+ // right arm works the brush over the desk / scroll
267
+ r(21, 18, 4, 4, robeD);
268
+ r(22, brushUp ? 20 : 22, 3, 2, P.skin);
269
+ r(24, brushUp ? 16 : 18, 1, 5, O); // brush
270
+ r(24, brushUp ? 21 : 23, 1, 1, P.heuk);
271
+ } else {
272
+ r(23, 18 - swing, 3, 8, robeD);
273
+ r(23, 26 - swing, 3, 2, P.skin);
274
+ }
275
+ // hyungbae (chest square): job motif, border 2 lines for hong(당상관), 1 for others
276
+ const border = (this.court.cfg.robes?.[this.robe]?.border) || 1;
277
+ r(12, 16, 8, 8, P.hwang);
278
+ if (border >= 2) {
279
+ r(13, 17, 6, 6, P.heuk);
280
+ r(13, 17, 6, 1, P.hwang);
281
+ r(13, 22, 6, 1, P.hwang);
282
+ r(13, 17, 1, 6, P.hwang);
283
+ r(18, 17, 1, 6, P.hwang);
284
+ r(14, 18, 4, 4, P.heuk);
285
+ } else {
286
+ r(13, 17, 6, 6, P.heuk);
287
+ }
288
+ const em = EMBLEMS[this.emblem] || EMBLEMS.plain;
289
+ em.forEach((row, yy) => [...row].forEach((b, xx) => b === '1' && r(14 + xx, 18 + yy, 1, 1, P.paper)));
290
+ // belt (seodae)
291
+ r(8, 25, 16, 1, P.hwang);
292
+ // feet
293
+ const lf = walking && wf === 1 ? -1 : 0;
294
+ const rf = walking && wf === 3 ? -1 : 0;
295
+ r(10, 30 + lf, 5, 2, O);
296
+ r(17, 30 + rf, 5, 2, O);
297
+ // carried scroll
298
+ if (this.carry) {
299
+ r(4, 23, 3, 7, O);
300
+ r(4, 24, 3, 5, P.paper);
301
+ r(3, 23, 5, 1, P.hwang);
302
+ r(3, 29, 5, 1, P.hwang);
303
+ }
304
+ // desk in front when seated
305
+ if (seated) this.court.drawDesk(Math.round(this.home[0]), Math.round(this.home[1]), working && frame % 2 === 0);
306
+ }
307
+
308
+ remove() {
309
+ this.tag.remove();
310
+ this.bubble.remove();
311
+ }
312
+ }
313
+
314
+ export class Court {
315
+ constructor({ canvas, overlay, config, onPetitionAction }) {
316
+ this.canvas = canvas;
317
+ this.ctx = canvas.getContext('2d');
318
+ this.overlay = overlay;
319
+ this.cfg = config;
320
+ this.pix = config.pixel;
321
+ this.ph = config.phrases || {};
322
+ this.onPetitionAction = onPetitionAction;
323
+ this.reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
324
+ canvas.width = W;
325
+ canvas.height = H;
326
+ this.officials = new Map(); // id -> Official
327
+ this.byAgent = new Map(); // agent_id -> official id
328
+ this.guestUsed = new Set();
329
+ this.seatUsed = new Set(); // 'east:0' ...
330
+ this.fixedSeats = new Map(); // 'east:1' -> config key that owns that desk
331
+ for (const [k, d] of Object.entries(config.officials || {})) {
332
+ if (!k.startsWith('_') && d && d.side && Number.isInteger(d.seat)) this.fixedSeats.set(`${d.side}:${d.seat}`, k);
333
+ }
334
+ this.walkQueue = [];
335
+ this.petition = null;
336
+ this.stats = { petitions: 0, ok: 0, no: 0, hold: 0 };
337
+ this.kingAnim = null;
338
+ this.dusk = 0;
339
+ this.duskTarget = 0;
340
+ this.historianWriteUntil = 0;
341
+ this.bg = this.drawBackground();
342
+ this.buildOverlay();
343
+ this.spawnMain();
344
+ this.last = performance.now();
345
+ this.paused = false;
346
+ const loop = (t) => {
347
+ this.frame(t);
348
+ requestAnimationFrame(loop);
349
+ };
350
+ requestAnimationFrame(loop);
351
+ }
352
+
353
+ // ---------- static background ----------
354
+ drawBackground() {
355
+ const c = document.createElement('canvas');
356
+ c.width = W;
357
+ c.height = H;
358
+ const g = c.getContext('2d');
359
+ const P = this.pix;
360
+ const pal = this.cfg.palette;
361
+ const R = (x, y, w, h, col) => {
362
+ g.fillStyle = col;
363
+ g.fillRect(x, y, w, h);
364
+ };
365
+ // night court base
366
+ R(0, 0, W, H, pal.paper);
367
+ // back wall of the hall
368
+ R(0, 0, W, 78, '#1A1D24');
369
+ // lattice doors on the back wall
370
+ for (let x = 8; x < W; x += 24) {
371
+ if (x > 136 && x < 256) continue;
372
+ R(x, 16, 16, 50, '#20242C');
373
+ for (let yy = 18; yy < 64; yy += 6) R(x + 1, yy, 14, 1, '#2A2F38');
374
+ for (let xx = x + 3; xx < x + 16; xx += 5) R(xx, 17, 1, 48, '#2A2F38');
375
+ }
376
+ // dancheong band: one line of nok / cheong / hong (concept 4-3)
377
+ const band = [P.nok, P.cheong, P.hong];
378
+ for (let x = 0; x < W; x += 6) R(x, 4, 6, 4, band[(x / 6) % 3]);
379
+ R(0, 2, W, 2, P.heuk);
380
+ R(0, 8, W, 1, P.outline);
381
+ // pillars
382
+ for (const x of [40, 112, 280, 352]) {
383
+ R(x - 1, 9, 10, 72, P.outline);
384
+ R(x, 9, 8, 72, P.hong);
385
+ R(x + 1, 9, 2, 72, shade(P.hong, 1.25));
386
+ R(x - 2, 78, 12, 4, P.stoneShade);
387
+ }
388
+
389
+ // ---- Irworobongdo (sun, moon, five peaks), redrawn as plain shapes ----
390
+ const sx = 144;
391
+ const sy = 12;
392
+ const sw = 112;
393
+ const sh = 50;
394
+ R(sx - 2, sy - 2, sw + 4, sh + 4, P.outline);
395
+ R(sx - 1, sy - 1, sw + 2, sh + 2, P.hwang);
396
+ R(sx, sy, sw, sh, '#1C3F5C'); // night-blue sky
397
+ // panel seams of the folding screen
398
+ for (let i = 1; i < 5; i++) R(sx + (sw / 5) * i, sy, 1, sh, 'rgba(0,0,0,0.25)');
399
+ // moon (left, white) and sun (right, red)
400
+ const disc = (cx, cy, rad, col) => {
401
+ for (let yy = -rad; yy <= rad; yy++) {
402
+ const w = Math.round(Math.sqrt(rad * rad - yy * yy));
403
+ R(cx - w, cy + yy, w * 2 + 1, 1, col);
404
+ }
405
+ };
406
+ disc(sx + 20, sy + 10, 5, P.paper);
407
+ disc(sx + sw - 20, sy + 10, 5, P.inju);
408
+ // five peaks
409
+ const peak = (cx, base, hgt, half, col) => {
410
+ for (let yy = 0; yy < hgt; yy++) {
411
+ const w = Math.round((half * yy) / hgt);
412
+ R(cx - w, base - hgt + yy, w * 2 + 1, 1, col);
413
+ }
414
+ };
415
+ const pk = P.nok;
416
+ const pkL = shade(P.nok, 1.35);
417
+ peak(sx + 56, sy + 40, 30, 16, pk);
418
+ peak(sx + 56, sy + 40, 30, 6, pkL);
419
+ peak(sx + 30, sy + 40, 22, 14, pk);
420
+ peak(sx + 82, sy + 40, 22, 14, pk);
421
+ peak(sx + 12, sy + 42, 16, 12, shade(pk, 0.85));
422
+ peak(sx + 100, sy + 42, 16, 12, shade(pk, 0.85));
423
+ // waterfalls
424
+ R(sx + 43, sy + 26, 1, 14, P.paper);
425
+ R(sx + 69, sy + 26, 1, 14, P.paper);
426
+ // water: two wave lines
427
+ R(sx, sy + 40, sw, 10, P.cheong);
428
+ for (let x = sx; x < sx + sw; x += 8) {
429
+ R(x + 1, sy + 42, 4, 1, P.paper);
430
+ R(x + 5, sy + 46, 4, 1, P.paper);
431
+ }
432
+ // two pines
433
+ for (const tx of [sx + 4, sx + sw - 8]) {
434
+ R(tx + 1, sy + 22, 2, 20, P.hong);
435
+ R(tx - 2, sy + 18, 8, 3, P.nok);
436
+ R(tx - 1, sy + 14, 6, 3, P.nok);
437
+ R(tx - 3, sy + 24, 9, 2, P.nok);
438
+ }
439
+
440
+ // canopy (datjip) outline above the throne
441
+ R(sx - 8, 9, sw + 16, 3, P.outline);
442
+ R(sx - 6, 10, sw + 12, 1, P.hwang);
443
+
444
+ // throne: three steps + chair (no dragon carving)
445
+ R(176, 50, 48, 14, P.outline);
446
+ R(177, 51, 46, 12, P.hong);
447
+ R(179, 52, 42, 2, P.hwang);
448
+ R(180, 44, 40, 8, P.outline);
449
+ R(181, 45, 38, 6, P.hong);
450
+ R(181, 45, 38, 1, P.hwang);
451
+ R(176, 62, 4, 6, P.hwang);
452
+ R(220, 62, 4, 6, P.hwang);
453
+ const steps = [[168, 66, 64], [160, 70, 80], [152, 74, 96]];
454
+ for (const [x, y, w] of steps) {
455
+ R(x, y, w, 4, P.stoneShade);
456
+ R(x, y, w, 1, P.stone);
457
+ }
458
+
459
+ // courtyard: night-toned stone slabs (bakseok)
460
+ for (let y = 78; y < H; y += 8) {
461
+ for (let x = ((y / 8) % 2) * 6; x < W; x += 12) {
462
+ const v = ((x * 7 + y * 13) % 5) / 100;
463
+ R(x, y, 11, 7, shade('#2A2C31', 0.92 + v));
464
+ }
465
+ }
466
+ R(0, 78, W, 2, P.outline);
467
+ // royal road (eodo) in the middle
468
+ R(182, 80, 36, H - 80, '#3A3933');
469
+ for (let y = 80; y < H; y += 10) R(183, y, 34, 1, '#46443D');
470
+ R(181, 80, 1, H - 80, P.outline);
471
+ R(218, 80, 1, H - 80, P.outline);
472
+
473
+ // rank stones: 6 per side, labelled in the overlay with the office's first letter
474
+ for (const side of ['east', 'west']) {
475
+ for (const [x, y] of SEATS[side]) {
476
+ const stx = side === 'east' ? x + 22 : x - 30;
477
+ R(stx, y - 14, 8, 14, P.outline);
478
+ R(stx + 1, y - 13, 6, 13, P.stone);
479
+ R(stx + 1, y - 13, 6, 1, '#D8D2C6');
480
+ R(stx + 1, y - 1, 6, 1, P.stoneShade);
481
+ }
482
+ }
483
+ // gate at the bottom
484
+ R(176, H - 4, 48, 4, P.hong);
485
+ R(176, H - 5, 48, 1, P.hwang);
486
+ return c;
487
+ }
488
+
489
+ drawDesk(x, y, wet) {
490
+ const P = this.pix;
491
+ const g = this.ctx;
492
+ const R = (a, b, w, h, col) => {
493
+ g.fillStyle = col;
494
+ g.fillRect(a, b, w, h);
495
+ };
496
+ R(x - 11, y - 9, 22, 8, P.outline);
497
+ R(x - 10, y - 8, 20, 6, '#6E4B2A');
498
+ R(x - 10, y - 8, 20, 1, '#8A5F36');
499
+ R(x - 9, y - 2, 2, 3, P.outline);
500
+ R(x + 7, y - 2, 2, 3, P.outline);
501
+ R(x - 6, y - 9, 10, 3, P.paper); // paper on the desk
502
+ if (wet) R(x - 3, y - 8, 3, 1, P.heuk);
503
+ R(x + 5, y - 9, 3, 2, P.heuk); // inkstone
504
+ }
505
+
506
+ buildOverlay() {
507
+ const o = this.overlay;
508
+ o.style.width = W + 'px';
509
+ o.style.height = H + 'px';
510
+ // stone labels: fixed desks show their owner's letter; free desks get one when someone sits
511
+ const all = this.cfg.officials || {};
512
+ this.stoneLabels = new Map();
513
+ for (const side of ['east', 'west']) {
514
+ SEATS[side].forEach(([x, y], i) => {
515
+ const k = `${side}:${i}`;
516
+ const stx = side === 'west' ? x - 30 : x + 22;
517
+ const s = el('div', 'stone-label', o, '');
518
+ s.style.transform = `translate(${stx + 4}px, ${y - 12}px) translateX(-50%)`;
519
+ const owner = all[this.fixedSeats.get(k)];
520
+ s.dataset.text = owner ? owner.short || owner.title[0] : '';
521
+ s.dataset.title = owner ? `${owner.title} — ${owner.role || ''}` : '';
522
+ s.textContent = s.dataset.text;
523
+ s.title = s.dataset.title;
524
+ this.stoneLabels.set(k, s);
525
+ });
526
+ }
527
+ this.more = el('div', 'bubble-more', o);
528
+ // decree scroll
529
+ this.decree = el('div', 'decree', o);
530
+ el('div', 'd-head', this.decree, '교지');
531
+ this.decreeText = el('div', 'd-text', this.decree);
532
+ this.decreeFoot = el('div', 'd-foot', this.decree, this.ph.decree || '교지를 반포하였사옵니다');
533
+ // petition panel
534
+ this.pet = el('div', 'petition', o);
535
+ this.petHead = el('div', 'p-head', this.pet);
536
+ this.petAsk = el('div', 'p-ask', this.pet, this.ph.permission || '윤허를 청하옵니다');
537
+ this.petWait = el('div', 'p-wait', this.pet);
538
+ const btns = el('div', 'p-btns', this.pet);
539
+ for (const k of ['ok', 'no', 'hold']) {
540
+ const st = (this.cfg.stamps || {})[k] || { label: k };
541
+ const b = el('button', `p-btn p-${k}`, btns);
542
+ el('span', 'mk', b, st.mark || '');
543
+ el('span', 'lb', b, st.label);
544
+ b.addEventListener('click', () => this.petitionAction(k));
545
+ }
546
+ el('div', 'p-hint', this.pet, this.ph.permissionHint || '');
547
+ el('div', 'p-ext', this.pet, '바깥 터미널의 조회입니다 — 그 터미널에서 답하소서');
548
+ this.pet.title = this.ph.permissionHint || '';
549
+ // stamp + plaque
550
+ this.stamp = el('div', 'stamp', o);
551
+ this.stampMark = el('div', 'st-mark', this.stamp);
552
+ this.stampLabel = el('div', 'st-label', this.stamp);
553
+ this.stampFull = el('div', 'stamp-full', o);
554
+ this.plaque = el('div', 'plaque', o);
555
+ this.toast = el('div', 'toast', o);
556
+ }
557
+
558
+ spawnMain() {
559
+ const def = this.cfg.officials.main;
560
+ const m = new Official(this, { key: 'main', def, kind: 'main', id: 'main' });
561
+ m.home = [...SEATS[def.side][def.seat]];
562
+ m.seat = `${def.side}:${def.seat}`;
563
+ this.seatUsed.add(m.seat);
564
+ m.place(...m.home);
565
+ m.visible = true;
566
+ m.state = 'seated';
567
+ this.officials.set('main', m);
568
+ this.main = m;
569
+ const hdef = this.cfg.historian || { title: '사관', robe: 'nok' };
570
+ const h = new Official(this, { key: 'historian', def: { ...hdef, emblem: 'brush' }, kind: 'historian', id: 'historian' });
571
+ h.home = [...HISTORIAN];
572
+ h.place(...HISTORIAN);
573
+ h.visible = true;
574
+ h.state = 'standing';
575
+ h.tag.classList.add('small');
576
+ this.officials.set('historian', h);
577
+ this.historian = h;
578
+ }
579
+
580
+ // ---------- helpers ----------
581
+ officialFor(agentType, agentId) {
582
+ if (!agentId && !agentType) return this.main;
583
+ if (agentId && this.byAgent.has(agentId)) return this.officials.get(this.byAgent.get(agentId)) || this.main;
584
+ return null;
585
+ }
586
+
587
+ // A desk for this official: its own fixed desk, else a free unowned desk (own side first),
588
+ // else a fixed desk whose owner is away. null → stands in the courtyard.
589
+ takeSeat(def) {
590
+ const sides = def.side === 'west' ? ['west', 'east'] : ['east', 'west'];
591
+ const free = (k) => !this.seatUsed.has(k);
592
+ let k = null;
593
+ if (def.side && Number.isInteger(def.seat) && free(`${def.side}:${def.seat}`)) k = `${def.side}:${def.seat}`;
594
+ for (const pass of [0, 1]) {
595
+ for (const side of sides) {
596
+ for (let i = 0; !k && i < SEATS[side].length; i++) {
597
+ const c = `${side}:${i}`;
598
+ if (free(c) && (pass === 1 || !this.fixedSeats.has(c))) k = c;
599
+ }
600
+ }
601
+ }
602
+ if (!k) return null;
603
+ this.seatUsed.add(k);
604
+ const [side, i] = k.split(':');
605
+ const label = this.stoneLabels.get(k);
606
+ if (label) {
607
+ label.textContent = def.short || def.title[0];
608
+ label.title = `${def.title} — ${def.role || ''}`;
609
+ }
610
+ return { k, pos: SEATS[side][Number(i)] };
611
+ }
612
+
613
+ releaseSeat(k) {
614
+ if (!k) return;
615
+ this.seatUsed.delete(k);
616
+ const label = this.stoneLabels.get(k);
617
+ if (label) {
618
+ label.textContent = label.dataset.text;
619
+ label.title = label.dataset.title;
620
+ }
621
+ }
622
+
623
+ enterOfficial(agentType, agentId) {
624
+ const found = lookupOfficial(agentType, this.cfg);
625
+ let def = found.def;
626
+ const dup = found.kind === 'seat' && this.officials.has(found.key);
627
+ const seat = found.kind === 'seat' && !dup ? this.takeSeat(def) : null;
628
+ const kind = seat ? 'seat' : 'guest';
629
+ const id = seat ? found.key : `guest:${agentId || Math.random()}`;
630
+ if (dup) def = { ...def, title: `${def.title}(둘째)` }; // parallel agent of the same type
631
+ const o = new Official(this, { key: found.key, def, kind, id });
632
+ if (seat) {
633
+ o.home = [...seat.pos];
634
+ o.seat = seat.k;
635
+ } else {
636
+ const i = GUEST_SPOTS.findIndex((_, k) => !this.guestUsed.has(k));
637
+ const k = i < 0 ? Math.floor(Math.random() * GUEST_SPOTS.length) : i;
638
+ this.guestUsed.add(k);
639
+ o.guestSpot = k;
640
+ o.home = [...GUEST_SPOTS[k]];
641
+ }
642
+ o.place(...DOOR);
643
+ this.officials.set(id, o);
644
+ if (agentId) this.byAgent.set(agentId, id);
645
+ const route = kind === 'seat' ? [[200, DOOR[1] - 30], [200, o.home[1] + 6], [o.home[0], o.home[1] + 6], o.home] : [[200, DOOR[1] - 30], o.home];
646
+ this.queueWalk(o, route, () => {
647
+ // it may have started a tool while still walking in: keep showing that work, not the greeting
648
+ if (o.workUntil === Infinity) {
649
+ o.state = 'working';
650
+ o.say(o.workText, '', 60_000, true);
651
+ } else o.say(fmt(this.ph.enter, { title: o.title }), o.role, 2600);
652
+ });
653
+ return o;
654
+ }
655
+
656
+ leaveOfficial(o, report) {
657
+ if (!o || o === this.main) return;
658
+ const mainSpot = [this.main.home[0] + 18, this.main.home[1] + 8];
659
+ o.carry = true;
660
+ o.hush();
661
+ const toMain = [[o.x, o.home[1] + 6], [200, o.home[1] + 6], [200, mainSpot[1]], mainSpot];
662
+ this.queueWalk(o, toMain, () => {
663
+ o.say(report ? fmt(this.ph.report, { report }) : this.ph.reportEmpty, '', 2600);
664
+ if (!this.main.bubbleSticky) this.main.say('받았소', '', 1400); // never hide a petition or work bubble
665
+ setTimeout(() => {
666
+ o.carry = false;
667
+ this.queueWalk(o, [[200, mainSpot[1]], [200, DOOR[1] - 20], DOOR], () => {
668
+ o.visible = false;
669
+ o.remove();
670
+ this.officials.delete(o.id);
671
+ if (o.guestSpot != null) this.guestUsed.delete(o.guestSpot);
672
+ this.releaseSeat(o.seat);
673
+ });
674
+ }, this.reduced ? 1200 : 2200);
675
+ });
676
+ }
677
+
678
+ // concept: one walker at a time so each move reads clearly
679
+ queueWalk(o, route, cb) {
680
+ this.walkQueue.push({ o, route, cb });
681
+ this.pumpWalks();
682
+ }
683
+
684
+ pumpWalks() {
685
+ if (this.walking) return;
686
+ const next = this.walkQueue.shift();
687
+ if (!next) return;
688
+ this.walking = next.o;
689
+ next.o.walk(next.route, () => {
690
+ this.walking = null;
691
+ try {
692
+ next.cb && next.cb();
693
+ } finally {
694
+ this.pumpWalks();
695
+ }
696
+ });
697
+ }
698
+
699
+ showDecree(text, slash) {
700
+ this.decreeText.textContent = text || '…';
701
+ this.decreeFoot.textContent = slash ? fmt(this.ph.decreeSlash, { prompt: text }) : this.ph.decree;
702
+ this.decree.classList.remove('on');
703
+ void this.decree.offsetWidth;
704
+ this.decree.classList.add('on');
705
+ clearTimeout(this.decreeTimer);
706
+ this.decreeTimer = setTimeout(() => this.decree.classList.remove('on'), slash ? 2600 : 4200);
707
+ this.historianWrite();
708
+ this.kingDo('decree', 2.2);
709
+ }
710
+
711
+ historianWrite() {
712
+ this.historian.workStart = performance.now();
713
+ this.historian.workUntil = performance.now() + 1600;
714
+ this.historian.state = 'working';
715
+ }
716
+
717
+ showToast(text, ms = 2400) {
718
+ this.toast.textContent = text;
719
+ this.toast.classList.add('on');
720
+ clearTimeout(this.toastTimer);
721
+ this.toastTimer = setTimeout(() => this.toast.classList.remove('on'), ms);
722
+ }
723
+
724
+ showPlaque(text, ms = 3200) {
725
+ this.plaque.textContent = text;
726
+ this.plaque.classList.remove('on');
727
+ void this.plaque.offsetWidth;
728
+ this.plaque.classList.add('on');
729
+ clearTimeout(this.plaqueTimer);
730
+ this.plaqueTimer = setTimeout(() => this.plaque.classList.remove('on'), ms);
731
+ }
732
+
733
+ // ---------- petition (permission request) ----------
734
+ openPetition({ tool, summary, message, actor, ext = false }) {
735
+ const who = actor || this.main;
736
+ const head = tool ? fmt(this.ph.petition, { tool, summary: summary || '' }) : `아뢰옵니다 — ${message || '권한이 필요합니다'}`;
737
+ if (this.petition && !this.petition.closed) {
738
+ // a Notification right after PermissionRequest: keep the richer one
739
+ if (!tool) return;
740
+ } else {
741
+ this.stats.petitions++;
742
+ }
743
+ this.petition = { tool, summary, since: performance.now(), actor: who, closed: false, ext };
744
+ this.petHead.textContent = head;
745
+ // a session outside the court (eojeon watch): the stamps can't reach its terminal
746
+ this.pet.classList.toggle('ext', ext);
747
+ this.pet.classList.add('on');
748
+ this.pet.classList.remove('min');
749
+ who.carry = true;
750
+ who.workUntil = performance.now(); // waiting, not working
751
+ who.say(this.ph.permission || '윤허를 청하옵니다', '', 60_000, true);
752
+ }
753
+
754
+ petitionAction(kind) {
755
+ if (!this.petition || this.petition.closed || this.petition.ext) return;
756
+ if (kind === 'hold') {
757
+ this.pet.classList.add('min');
758
+ this.stampVerdict('hold');
759
+ this.onPetitionAction && this.onPetitionAction('hold');
760
+ return;
761
+ }
762
+ this.onPetitionAction && this.onPetitionAction(kind);
763
+ this.closePetition(kind);
764
+ }
765
+
766
+ closePetition(verdict) {
767
+ const p = this.petition;
768
+ if (!p || p.closed) return;
769
+ p.closed = true;
770
+ this.pet.classList.remove('on', 'min');
771
+ p.actor.carry = false;
772
+ p.actor.hush();
773
+ if (verdict) this.stampVerdict(verdict);
774
+ }
775
+
776
+ stampVerdict(kind) {
777
+ const st = (this.cfg.stamps || {})[kind] || { label: kind, mark: '' };
778
+ if (kind in this.stats) this.stats[kind]++;
779
+ this.stamp.className = `stamp shape-${st.shape || 'circle'} k-${kind}`;
780
+ this.stampMark.textContent = st.mark || '';
781
+ this.stampLabel.textContent = st.label;
782
+ this.stampFull.textContent = st.full || st.label;
783
+ this.stampFull.className = `stamp-full k-${kind}`;
784
+ void this.stamp.offsetWidth;
785
+ if (kind === 'ok') this.kingDo('nod', 1.1);
786
+ else if (kind === 'no') this.kingDo('wave', 1.2);
787
+ const delay = this.reduced ? 0 : 160;
788
+ setTimeout(() => {
789
+ this.stamp.classList.add('on');
790
+ this.stampFull.classList.add('on');
791
+ }, delay);
792
+ clearTimeout(this.stampTimer);
793
+ this.stampTimer = setTimeout(() => {
794
+ this.stamp.classList.remove('on');
795
+ this.stampFull.classList.remove('on');
796
+ }, 2600);
797
+ }
798
+
799
+ // ---------- event handling ----------
800
+ handle(ev, { replay = false } = {}) {
801
+ if (replay) return; // history is shown in the log only; the scene starts fresh
802
+ const ph = this.ph;
803
+ const actor = ev.agentId ? this.officialFor(ev.agentType, ev.agentId) || this.enterOfficial(ev.agentType, ev.agentId) : this.main;
804
+ switch (ev.type) {
805
+ case 'SessionStart': {
806
+ this.duskTarget = 0;
807
+ const resumed = ev.source === 'resume' || ev.source === 'fork';
808
+ if (ev.source === 'clear' || ev.source === 'compact') this.showToast(ev.source === 'clear' ? '조정을 새로 엽니다 (/clear)' : '기록을 줄였사옵니다 (compact)');
809
+ else this.showToast(fmt(resumed ? ph.sessionResume : ph.sessionStart, { folder: ev.folder || '' }));
810
+ this.main.visible = true;
811
+ break;
812
+ }
813
+ case 'UserPromptSubmit':
814
+ if (this.petition && !this.petition.closed) this.closePetition(null);
815
+ this.showDecree(ev.prompt, ev.slash);
816
+ this.main.say('교지를 받드옵니다', '', 1800);
817
+ break;
818
+ case 'PreToolUse': {
819
+ if (ev.spawnType) {
820
+ actor.say(`${ev.tool}: ${ev.spawnType} 부르는 중`, '', 2000);
821
+ break;
822
+ }
823
+ actor.work(fmt(ph.working, { tool: ev.tool, summary: ev.summary || '' }).replace(/:\s*$/, ''));
824
+ break;
825
+ }
826
+ case 'PostToolUse':
827
+ if (this.petition && !this.petition.closed && this.petition.tool === ev.tool) this.closePetition('ok');
828
+ if (!ev.spawnType) actor.doneWork(true);
829
+ break;
830
+ case 'PostToolUseFailure':
831
+ if (this.petition && !this.petition.closed) this.closePetition(null);
832
+ actor.doneWork(false, fmt(ph.failed, { tool: ev.tool }));
833
+ break;
834
+ case 'PermissionRequest':
835
+ this.openPetition({ tool: ev.tool, summary: ev.summary, actor, ext: !!ev.ext });
836
+ break;
837
+ case 'PermissionDenied':
838
+ if (this.petition && !this.petition.closed) this.closePetition('no');
839
+ actor.doneWork(false, fmt(ph.denied, { tool: ev.tool }));
840
+ break;
841
+ case 'Notification':
842
+ if (ev.kind === 'permission_prompt') {
843
+ if (!this.petition || this.petition.closed) this.openPetition({ message: ev.message, actor, ext: !!ev.ext });
844
+ } else if (ev.kind === 'idle_prompt') {
845
+ this.main.say(ph.idle || '분부를 기다리옵니다', '', 120_000);
846
+ } else if (ev.message) {
847
+ this.showToast(ev.message);
848
+ }
849
+ break;
850
+ case 'SubagentStart':
851
+ if (!this.byAgent.has(ev.agentId)) this.enterOfficial(ev.agentType, ev.agentId);
852
+ break;
853
+ case 'SubagentStop': {
854
+ const o = this.officialFor(ev.agentType, ev.agentId);
855
+ if (o && o !== this.main) {
856
+ this.byAgent.delete(ev.agentId);
857
+ this.leaveOfficial(o, ev.report);
858
+ }
859
+ break;
860
+ }
861
+ case 'Stop':
862
+ case 'StopFailure': {
863
+ if (this.petition && !this.petition.closed) this.closePetition(null);
864
+ const m = this.main;
865
+ const fail = ev.type === 'StopFailure';
866
+ m.carry = true;
867
+ this.queueWalk(m, [[m.home[0], m.home[1] + 8], [200, m.home[1] + 8], THRONE_FRONT], () => {
868
+ m.say(fail ? '아뢰옵니다 — 일을 끝내지 못했사옵니다' : ph.stopLine, '', 2600);
869
+ if (!fail) this.showPlaque(ph.stop || '상소 완료', 2800);
870
+ setTimeout(() => {
871
+ m.carry = false;
872
+ this.queueWalk(m, [[200, m.home[1] + 8], [m.home[0], m.home[1] + 8], m.home]);
873
+ }, this.reduced ? 1500 : 2400);
874
+ });
875
+ break;
876
+ }
877
+ case 'SessionEnd': {
878
+ this.duskTarget = 1;
879
+ const s = this.stats;
880
+ this.showPlaque(`오늘 상소 ${s.petitions}건 · 윤허 ${s.ok} · 불허 ${s.no} · 보류 ${s.hold} · ${ph.sessionEnd || '퇴청하옵니다'}`, 6000);
881
+ for (const o of [...this.officials.values()]) if (o.kind === 'seat' || o.kind === 'guest') this.leaveOfficial(o, '');
882
+ break;
883
+ }
884
+ case 'PtyExit':
885
+ this.duskTarget = 1;
886
+ break;
887
+ case 'PtyStart':
888
+ this.duskTarget = 0;
889
+ break;
890
+ default:
891
+ break;
892
+ }
893
+ }
894
+
895
+ reset() {
896
+ for (const o of [...this.officials.values()]) {
897
+ if (o.kind === 'seat' || o.kind === 'guest') {
898
+ o.remove();
899
+ this.officials.delete(o.id);
900
+ this.releaseSeat(o.seat);
901
+ }
902
+ }
903
+ this.byAgent.clear();
904
+ this.guestUsed.clear();
905
+ this.walkQueue = [];
906
+ this.walking = null;
907
+ this.main.path = [];
908
+ this.main.place(...this.main.home);
909
+ this.main.state = 'seated';
910
+ this.main.carry = false;
911
+ this.main.hush();
912
+ this.closePetition(null);
913
+ this.stats = { petitions: 0, ok: 0, no: 0, hold: 0 };
914
+ this.duskTarget = 0;
915
+ }
916
+
917
+ // ---------- frame ----------
918
+ frame(now) {
919
+ const dt = Math.min(0.1, (now - this.last) / 1000);
920
+ this.last = now;
921
+ const g = this.ctx;
922
+ g.imageSmoothingEnabled = false;
923
+ g.drawImage(this.bg, 0, 0);
924
+ const px = (x, y, w, h, c) => {
925
+ g.fillStyle = c;
926
+ g.fillRect(x, y, w, h);
927
+ };
928
+ // throne glow when a petition is waiting
929
+ if (this.petition && !this.petition.closed) {
930
+ const a = 0.12 + 0.06 * Math.sin(now / 300);
931
+ px(150, 40, 100, 40, `rgba(217,174,82,${a.toFixed(3)})`);
932
+ }
933
+ this.drawKing(now, px);
934
+ const list = [...this.officials.values()];
935
+ for (const o of list) o.step(dt, now);
936
+ list.sort((a, b) => a.y - b.y);
937
+ for (const o of list) o.draw(g, now, px);
938
+ // dusk tint (session end)
939
+ this.dusk += (this.duskTarget - this.dusk) * Math.min(1, dt * 1.5);
940
+ if (this.dusk > 0.01) px(0, 0, W, H, `rgba(8,10,20,${(this.dusk * 0.45).toFixed(3)})`);
941
+ for (const o of list) o.updateDom(now);
942
+ this.layoutBubbles();
943
+ if (this.petition && !this.petition.closed) {
944
+ this.petWait.textContent = fmt(this.ph.waiting, { elapsed: elapsedText(now - this.petition.since).replace('째', '') });
945
+ }
946
+ }
947
+
948
+ // Speech bubbles: newest first (the petitioner always), at most MAX_BUBBLES.
949
+ // Each one tries a few spots around its speaker and takes the first that overlaps
950
+ // neither an earlier bubble nor the petition panel. Everything is also in the 사관 일지.
951
+ layoutBubbles() {
952
+ const on = [...this.officials.values()].filter((o) => o.bubbleOn);
953
+ const asker = this.petition && !this.petition.closed ? this.petition.actor : null;
954
+ on.sort((a, b) => (b === asker) - (a === asker) || (b.bubbleAt || 0) - (a.bubbleAt || 0));
955
+ const placed = [];
956
+ if (this.pet.classList.contains('on')) {
957
+ placed.push({ x: PETITION_BOX.x, y: PETITION_BOX.y, w: PETITION_BOX.w, h: this.pet.offsetHeight || 64 });
958
+ }
959
+ const hit = (r) => placed.some((p) => r.x < p.x + p.w + 2 && r.x + r.w + 2 > p.x && r.y < p.y + p.h + 2 && r.y + r.h + 2 > p.y);
960
+ let hidden = 0;
961
+ for (const o of on) {
962
+ if (placed.length - (this.pet.classList.contains('on') ? 1 : 0) >= MAX_BUBBLES) {
963
+ o.bubble.classList.remove('on');
964
+ hidden++;
965
+ continue;
966
+ }
967
+ o.bubble.classList.add('on');
968
+ const w = o.bubble.offsetWidth;
969
+ const h = o.bubble.offsetHeight;
970
+ const [ax, ay] = o.anchor;
971
+ const step = h + 3;
972
+ const side = Math.round(w * 0.55);
973
+ const tries = [[0, 0], [0, -step], [-side, 0], [side, 0], [-side, -step], [side, -step], [0, -2 * step], [0, step + 30]];
974
+ let best = null;
975
+ for (const [dx, dy] of tries) {
976
+ const x = Math.max(2, Math.min(W - 2 - w, ax + dx - w / 2));
977
+ const y = Math.max(2, Math.min(H - 2 - h, ay + dy - h));
978
+ const r = { x, y, w, h };
979
+ if (!best) best = r;
980
+ if (!hit(r)) {
981
+ best = r;
982
+ break;
983
+ }
984
+ }
985
+ placed.push(best);
986
+ // the tail only makes sense when the bubble sits right above its speaker
987
+ o.bubble.classList.toggle('loose', Math.abs(best.x + w / 2 - ax) > w / 2 - 4 || Math.abs(best.y + h - ay) > 2);
988
+ o.bubble.style.transform = `translate(${Math.round(best.x)}px, ${Math.round(best.y)}px)`;
989
+ }
990
+ this.more.textContent = hidden ? `말풍선 +${hidden} · 사관 일지에 있사옵니다` : '';
991
+ this.more.classList.toggle('on', hidden > 0);
992
+ }
993
+
994
+ // The king: a generic pixel monarch on the throne (no historical likeness).
995
+ // Frames: idle (blink), decree (holds up a scroll), nod (approve), wave (refuse).
996
+ kingDo(kind, dur) {
997
+ this.kingAnim = { kind, start: performance.now(), dur };
998
+ }
999
+
1000
+ drawKing(now, px) {
1001
+ const P = this.pix;
1002
+ const kc = this.cfg.king || {};
1003
+ const robe = /^#/.test(kc.robe || '') ? kc.robe : P[kc.robe || 'hong'] || P.hong;
1004
+ const robeD = shade(robe, 0.72);
1005
+ const O = P.outline;
1006
+ const ox = 184;
1007
+ const oy = 33;
1008
+ const r = (x, y, w, h, c) => px(ox + x, oy + y, w, h, c);
1009
+ let a = this.kingAnim;
1010
+ if (a && (now - a.start) / 1000 > a.dur) a = this.kingAnim = null;
1011
+ const t = a ? (now - a.start) / 1000 : 0;
1012
+ const f = Math.floor(t * FPS);
1013
+ const kind = a ? a.kind : 'idle';
1014
+ const nod = kind === 'nod' && !this.reduced && (f % 4 === 1 || f % 4 === 2) ? 2 : 0;
1015
+ // robe (gonryongpo), seated
1016
+ const rows = [[10, 12], [8, 16], [7, 18], [7, 18], [6, 20], [6, 20], [6, 20], [6, 20], [6, 20], [5, 22], [5, 22], [5, 22], [5, 22], [5, 22], [5, 22], [5, 22]];
1017
+ rows.forEach(([x0, w], i) => {
1018
+ r(x0 - 1, 14 + i, w + 2, 1, O);
1019
+ r(x0, 14 + i, w, 1, robe);
1020
+ });
1021
+ r(4, 30, 24, 1, O);
1022
+ r(13, 14, 6, 1, P.paper); // collar
1023
+ // shoulder roundels + chest roundel (plain gold circle — no dragon)
1024
+ r(8, 15, 3, 3, P.hwang);
1025
+ r(21, 15, 3, 3, P.hwang);
1026
+ r(13, 16, 6, 1, P.hwang);
1027
+ r(12, 17, 8, 5, P.hwang);
1028
+ r(13, 22, 6, 1, P.hwang);
1029
+ r(14, 18, 4, 3, shade(robe, 0.85));
1030
+ r(15, 19, 2, 1, P.hwang);
1031
+ // jade belt
1032
+ r(6, 24, 20, 1, P.hwang);
1033
+ for (let x = 7; x < 26; x += 3) r(x, 24, 1, 1, P.paper);
1034
+ // arms
1035
+ if (kind === 'decree') {
1036
+ // both hands hold an opened scroll in front of the chest
1037
+ r(4, 15, 3, 6, robeD);
1038
+ r(25, 15, 3, 6, robeD);
1039
+ r(3, 13, 26, 8, O);
1040
+ r(4, 14, 24, 6, P.paper);
1041
+ r(3, 13, 2, 8, P.hwang);
1042
+ r(27, 13, 2, 8, P.hwang);
1043
+ for (let x = 8; x < 25; x += 4) r(x, 16, 2, 1, '#8A8378');
1044
+ r(5, 20, 2, 2, P.skin);
1045
+ r(25, 20, 2, 2, P.skin);
1046
+ } else if (kind === 'wave') {
1047
+ r(5, 18, 3, 7, robeD);
1048
+ r(5, 25, 3, 2, P.skin);
1049
+ const up = f % 2 === 0 ? 0 : 2;
1050
+ r(24, 18, 6, 3, robeD);
1051
+ r(29, 15 + up, 3, 3, P.skin);
1052
+ } else {
1053
+ // hands resting on the knees
1054
+ r(5, 18, 3, 7, robeD);
1055
+ r(24, 18, 3, 7, robeD);
1056
+ r(8, 26, 3, 2, P.skin);
1057
+ r(21, 26, 3, 2, P.skin);
1058
+ }
1059
+ // head (moves for the nod)
1060
+ const hy = nod;
1061
+ r(11, 7 + hy, 10, 7, O);
1062
+ r(12, 7 + hy, 8, 6, P.skin);
1063
+ r(12, 12 + hy, 8, 1, P.skinShade);
1064
+ const blink = now % 5000 < 140;
1065
+ if (blink || nod) {
1066
+ r(14, 10 + hy, 1, 1, P.skinShade);
1067
+ r(17, 10 + hy, 1, 1, P.skinShade);
1068
+ } else {
1069
+ r(14, 9 + hy, 1, 2, O);
1070
+ r(17, 9 + hy, 1, 2, O);
1071
+ }
1072
+ // ikseongwan: rounded crown + two upright back flaps
1073
+ r(11, 1 + hy, 10, 7, O);
1074
+ r(12, 2 + hy, 8, 5, P.heuk);
1075
+ r(13, 2 + hy, 6, 1, shade(P.heuk, 1.6));
1076
+ r(12, 6 + hy, 8, 1, shade(P.heuk, 1.3));
1077
+ r(8, 0 + hy, 4, 5, O);
1078
+ r(9, 1 + hy, 2, 3, P.heuk);
1079
+ r(20, 0 + hy, 4, 5, O);
1080
+ r(21, 1 + hy, 2, 3, P.heuk);
1081
+ }
1082
+ }