azeroth-shell 0.1.2 → 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 (63) hide show
  1. package/README.md +326 -82
  2. package/bin/azsh.mjs +21 -19
  3. package/package.json +9 -1
  4. package/src/autopilot/plan.ts +8 -11
  5. package/src/autopilot/view.ts +12 -11
  6. package/src/cli/combatLogFormat.ts +22 -0
  7. package/src/cli/gzLoader.mjs +47 -6
  8. package/src/cli/hdArt.mjs +81 -0
  9. package/src/cli/iconArt.mjs +135 -0
  10. package/src/cli/iconGrid.mjs +100 -0
  11. package/src/cli/modelArt.mjs +295 -0
  12. package/src/cli/paperdoll.mjs +76 -0
  13. package/src/cli/perfCheck.mjs +56 -0
  14. package/src/cli/repl.mjs +32 -7
  15. package/src/cli/rotationText.mjs +6 -6
  16. package/src/cli/sixel.mjs +307 -0
  17. package/src/cli/term.mjs +106 -12
  18. package/src/cli/text.mjs +5 -3
  19. package/src/cli/tui.mjs +231 -95
  20. package/src/cli/tuiAutopilot.mjs +347 -0
  21. package/src/cli/tuiBg.mjs +370 -41
  22. package/src/cli/tuiCheck.mjs +362 -27
  23. package/src/cli/tuiCity.mjs +118 -13
  24. package/src/cli/tuiCompanions.mjs +25 -15
  25. package/src/cli/tuiCreate.mjs +223 -0
  26. package/src/cli/tuiDungeon.mjs +1 -1
  27. package/src/cli/tuiFight.mjs +423 -86
  28. package/src/cli/tuiFightPanes.mjs +27 -8
  29. package/src/cli/tuiFloor.mjs +1 -1
  30. package/src/cli/tuiGear.mjs +10 -6
  31. package/src/cli/tuiLoot.mjs +1 -0
  32. package/src/cli/tuiPaperdoll.mjs +107 -0
  33. package/src/cli/tuiPartyStage.mjs +177 -0
  34. package/src/cli/tuiProfessions.mjs +4 -2
  35. package/src/cli/tuiRotationCond.mjs +390 -0
  36. package/src/cli/tuiSaves.mjs +10 -1
  37. package/src/cli/tuiSearch.mjs +75 -22
  38. package/src/cli/tuiStage.mjs +304 -0
  39. package/src/cli/tuiTalentTree.mjs +124 -0
  40. package/src/cli/tuiViews.mjs +284 -110
  41. package/src/cli/tuiWorld.mjs +2 -2
  42. package/src/data/gear-art.bin.br +0 -0
  43. package/src/data/hd-art.json +114 -0
  44. package/src/data/icon-art.bin.gz +0 -0
  45. package/src/data/model-art.bin.gz +0 -0
  46. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  47. package/src/domain/bg/match.ts +85 -24
  48. package/src/domain/bg/strategies.ts +144 -29
  49. package/src/domain/bg/types.ts +2 -0
  50. package/src/domain/bg/wsg.ts +35 -20
  51. package/src/domain/quests/doQuest.ts +24 -6
  52. package/src/domain/rotation/build.ts +3031 -3031
  53. package/src/domain/rotation/classes/paladin.ts +16 -11
  54. package/src/domain/rotation/evaluate.ts +3 -0
  55. package/src/engine/combat/applySpell.ts +20 -9
  56. package/src/engine/combat/applySpellLate.ts +16 -12
  57. package/src/engine/combat/areaAura.ts +284 -283
  58. package/src/engine/combat/fighterFactory.ts +1 -1
  59. package/src/engine/combat/pickAction.ts +39 -1
  60. package/src/engine/combat/procsDamage.ts +31 -1
  61. package/src/engine/combat/sessionChase.ts +7 -1
  62. package/src/engine/combat/sessionMotionKit.ts +40 -2
  63. package/src/quests/quests.ts +60 -0
@@ -0,0 +1,307 @@
1
+ // Sixel sprites: real pixels for the creatures on the fight stage, where the terminal can draw them
2
+ // (Windows Terminal 1.22+, WezTerm, foot, mlterm, xterm -ti vt340). The half-block art (modelArt.mjs) is a
3
+ // pixel per half cell; a sixel image is the terminal's own pixels, about ten per cell across and twenty down.
4
+ //
5
+ // A screen is lines of text, so an image travels through them as a placeholder: a block of blank cells
6
+ // whose rows each start with a marker, an SGR-shaped code (`ESC[9999;id;rowm`) the text helpers count as
7
+ // zero width. RealTerm.draw takes the markers out before it writes, then sends the image at the block's
8
+ // top-left cell after the text (term.mjs). The terminal says whether it speaks sixel in its DA1 reply.
9
+ //
10
+ // The pixels come from model-art-hd.bin.br (scripts/bake-model-art.mjs --hd): the same sprites as the
11
+ // half-block pack at the renders' own size, up to 128 px wide; icons from icon-art-hd.bin.br. Neither ships in
12
+ // the package: hdArt.mjs finds them (downloaded, or a checkout's bake). No pack, no sprite, or no sixel: null,
13
+ // and the caller draws the half-block art. AZSH_SIXEL=0 turns it off, AZSH_SIXEL=1 on without asking.
14
+ import { readFileSync } from 'node:fs';
15
+ import { brotliDecompressSync } from 'node:zlib';
16
+ import { useColor } from './text.mjs';
17
+ import { hdArtPath } from './hdArt.mjs';
18
+
19
+ const MAGIC = 9999;
20
+ export const MARK_RE = /\x1b\[9999;(\d+);(\d+)m/g;
21
+
22
+ let on = process.env.AZSH_SIXEL === '1';
23
+ // The terminal's cell in pixels. Windows Terminal answers CSI 16 t with zeros and scales a sixel as if a
24
+ // cell were 10 x 20 (the VT340's), whatever the font; that is the fallback for any terminal that does not say.
25
+ let cellW = 10, cellH = 20;
26
+
27
+ /** Is sixel art on? Never without colour or under AZSH_SIXEL=0. */
28
+ export const sixelOn = () => on && useColor && process.env.AZSH_SIXEL !== '0';
29
+ /** The DA1 reply's parameters (`?62;4;22`): 4 is sixel graphics. True when this turned sixel on. */
30
+ export function noteDeviceAttributes(params) {
31
+ if (on || process.env.AZSH_SIXEL === '0') return false;
32
+ on = params.replace('?', '').split(';').includes('4');
33
+ return on;
34
+ }
35
+ /** The CSI 16 t reply: the cell's height and width in pixels; zeros (Windows Terminal) keep the fallback. */
36
+ export function noteCellSize(h, w) {
37
+ if (h > 0 && w > 0) { cellH = h; cellW = w; }
38
+ }
39
+
40
+ // ---- the source images ------------------------------------------------------------------------------
41
+
42
+ let index = null; // displayId -> byte offset of the sprite in `pack` (the half-block pack's layout, AZART v1)
43
+ let pack = null;
44
+
45
+ function load() {
46
+ if (index) return index;
47
+ index = new Map();
48
+ try {
49
+ pack = brotliDecompressSync(readFileSync(hdArtPath('model-art-hd.bin.br')));
50
+ if (pack.toString('ascii', 0, 5) !== 'AZART' || pack[5] !== 1) return index;
51
+ const count = pack.readUInt32LE(6);
52
+ let off = 10;
53
+ for (let i = 0; i < count && off + 7 <= pack.length; i++) {
54
+ index.set(pack.readUInt32LE(off), off);
55
+ off += 7 + pack[off + 6] * 3 + pack[off + 4] * pack[off + 5];
56
+ }
57
+ } catch { pack = null; }
58
+ return index;
59
+ }
60
+
61
+ /** Forget the packs (and what was missing) so the next draw reads them again: after a download. */
62
+ export function forgetSixelPacks() { index = pack = iconIndex = iconPack = null; images.clear(); byKey.clear(); }
63
+
64
+ /** The sprite for a display id as { w, h, rgba } (already cropped to its opaque box), or null. */
65
+ function sourceOf(displayId) {
66
+ const off = load().get(displayId);
67
+ if (off == null) return null;
68
+ const w = pack[off + 4], h = pack[off + 5], palLen = pack[off + 6], pal = off + 7, idx = pal + palLen * 3;
69
+ const rgba = new Uint8Array(w * h * 4);
70
+ for (let i = 0; i < w * h; i++) {
71
+ const p = pack[idx + i];
72
+ if (!p) continue;
73
+ const c = pal + (p - 1) * 3;
74
+ rgba[i * 4] = pack[c]; rgba[i * 4 + 1] = pack[c + 1]; rgba[i * 4 + 2] = pack[c + 2]; rgba[i * 4 + 3] = 255;
75
+ }
76
+ return { w, h, rgba };
77
+ }
78
+
79
+ // ---- scaling and encoding -----------------------------------------------------------------------------
80
+
81
+ /** The image scaled to tw x th, placed bottom-centre on a cw x ch canvas: area-sampled when it shrinks,
82
+ * bilinear when it grows. Colour is weighted by alpha; a pixel shows where more than half of it is covered. */
83
+ function scaleOnto(img, tw, th, cw, ch) {
84
+ const out = new Uint8Array(cw * ch * 4);
85
+ const ox = Math.floor((cw - tw) / 2), oy = ch - th;
86
+ const sx = img.w / tw, sy = img.h / th;
87
+ if (sx < 1) {
88
+ const at = (x, y, c) => img.rgba[(Math.min(img.h - 1, Math.max(0, y)) * img.w + Math.min(img.w - 1, Math.max(0, x))) * 4 + c];
89
+ for (let ty = 0; ty < th; ty++) {
90
+ const fy = (ty + 0.5) * sy - 0.5, y0 = Math.floor(fy), wy = fy - y0;
91
+ for (let tx = 0; tx < tw; tx++) {
92
+ const fx = (tx + 0.5) * sx - 0.5, x0 = Math.floor(fx), wx = fx - x0;
93
+ let r = 0, g = 0, b = 0, a = 0;
94
+ for (const [x, y, wt] of [[x0, y0, (1 - wx) * (1 - wy)], [x0 + 1, y0, wx * (1 - wy)], [x0, y0 + 1, (1 - wx) * wy], [x0 + 1, y0 + 1, wx * wy]]) {
95
+ const al = at(x, y, 3) * wt;
96
+ r += at(x, y, 0) * al; g += at(x, y, 1) * al; b += at(x, y, 2) * al; a += al;
97
+ }
98
+ if (a < 128) continue;
99
+ const d = ((oy + ty) * cw + ox + tx) * 4;
100
+ out[d] = r / a; out[d + 1] = g / a; out[d + 2] = b / a; out[d + 3] = 255;
101
+ }
102
+ }
103
+ return out;
104
+ }
105
+ for (let ty = 0; ty < th; ty++) {
106
+ const y0 = Math.floor(ty * sy), y1 = Math.max(y0 + 1, Math.floor((ty + 1) * sy));
107
+ for (let tx = 0; tx < tw; tx++) {
108
+ const x0 = Math.floor(tx * sx), x1 = Math.max(x0 + 1, Math.floor((tx + 1) * sx));
109
+ let r = 0, g = 0, b = 0, a = 0, n = 0;
110
+ for (let y = y0; y < y1 && y < img.h; y++) for (let x = x0; x < x1 && x < img.w; x++) {
111
+ const k = (y * img.w + x) * 4, al = img.rgba[k + 3];
112
+ r += img.rgba[k] * al; g += img.rgba[k + 1] * al; b += img.rgba[k + 2] * al; a += al; n++;
113
+ }
114
+ if (!n || a / n < 128) continue;
115
+ const d = ((oy + ty) * cw + ox + tx) * 4;
116
+ out[d] = r / a; out[d + 1] = g / a; out[d + 2] = b / a; out[d + 3] = 255;
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /** Up to 255 colours: the most used 15-bit buckets (each the mean of its pixels), the rest mapped to the
123
+ * nearest kept one. Returns the palette and a colour index per pixel (-1 where transparent). */
124
+ function quantize(rgba, n) {
125
+ const count = new Uint32Array(32768), sum = new Float64Array(32768 * 3), bucket = new Int32Array(n).fill(-1);
126
+ for (let i = 0; i < n; i++) {
127
+ if (!rgba[i * 4 + 3]) continue;
128
+ const r = rgba[i * 4], g = rgba[i * 4 + 1], b = rgba[i * 4 + 2], k = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3);
129
+ bucket[i] = k; count[k]++; sum[k * 3] += r; sum[k * 3 + 1] += g; sum[k * 3 + 2] += b;
130
+ }
131
+ const used = [];
132
+ for (let k = 0; k < 32768; k++) if (count[k]) used.push(k);
133
+ used.sort((a, b) => count[b] - count[a]);
134
+ const kept = used.slice(0, 255);
135
+ const pal = kept.map((k) => [sum[k * 3] / count[k], sum[k * 3 + 1] / count[k], sum[k * 3 + 2] / count[k]]);
136
+ const slot = new Int32Array(32768).fill(-1);
137
+ kept.forEach((k, i) => { slot[k] = i; });
138
+ for (const k of used.slice(255)) {
139
+ const r = ((k >> 10) & 31) * 8 + 4, g = ((k >> 5) & 31) * 8 + 4, b = (k & 31) * 8 + 4;
140
+ let best = 0, bd = Infinity;
141
+ pal.forEach(([pr, pg, pb], i) => { const d = (pr - r) ** 2 + (pg - g) ** 2 + (pb - b) ** 2; if (d < bd) { bd = d; best = i; } });
142
+ slot[k] = best;
143
+ }
144
+ const idx = new Int16Array(n).fill(-1);
145
+ for (let i = 0; i < n; i++) if (bucket[i] >= 0) idx[i] = slot[bucket[i]];
146
+ return { pal, idx };
147
+ }
148
+
149
+ /** An RGBA image as a sixel string: 1:1 pixels, unset pixels transparent (P2=1), run-length coded. */
150
+ function encode(rgba, w, h) {
151
+ const { pal, idx } = quantize(rgba, w * h);
152
+ let out = `\x1bP0;1;0q"1;1;${w};${h}`;
153
+ pal.forEach(([r, g, b], i) => { out += `#${i};2;${Math.round((r * 100) / 255)};${Math.round((g * 100) / 255)};${Math.round((b * 100) / 255)}`; });
154
+ const rle = (ch, n) => (n > 3 ? `!${n}${ch}` : ch.repeat(n));
155
+ for (let y0 = 0; y0 < h; y0 += 6) {
156
+ // Each colour in the band: a six-bit column per x, the rows it covers.
157
+ const bands = new Map();
158
+ for (let dy = 0; dy < 6 && y0 + dy < h; dy++) for (let x = 0; x < w; x++) {
159
+ const c = idx[(y0 + dy) * w + x];
160
+ if (c < 0) continue;
161
+ let cols = bands.get(c);
162
+ if (!cols) bands.set(c, (cols = new Uint8Array(w)));
163
+ cols[x] |= 1 << dy;
164
+ }
165
+ let first = true;
166
+ for (const [c, cols] of bands) {
167
+ let last = w - 1;
168
+ while (last >= 0 && !cols[last]) last--;
169
+ let s = `#${c}`, run = 0, prev = -1;
170
+ for (let x = 0; x <= last; x++) {
171
+ if (cols[x] === prev) { run++; continue; }
172
+ if (run) s += rle(String.fromCharCode(63 + prev), run);
173
+ prev = cols[x]; run = 1;
174
+ }
175
+ if (run) s += rle(String.fromCharCode(63 + prev), run);
176
+ out += (first ? '' : '$') + s;
177
+ first = false;
178
+ }
179
+ out += '-';
180
+ }
181
+ return `${out}\x1b\\`;
182
+ }
183
+
184
+ // ---- placements ----------------------------------------------------------------------------------------
185
+
186
+ // Encoded images by id, the oldest dropped past IMAGES_MAX (a marker for a dropped id is ignored, and the
187
+ // next draw that wants the picture encodes it again under a new id).
188
+ const IMAGES_MAX = 480;
189
+ const images = new Map(); // id -> { cols, rows, sixel, holes }
190
+ const byKey = new Map(); // what was drawn and at which cell size -> id
191
+ let nextId = 0;
192
+
193
+ /** The sixel string, size in cells and transparent `holes` of a placed image, by the id its markers carry. */
194
+ export const imageOf = (id) => images.get(id) ?? null;
195
+
196
+ /** The id of the image under `key`, built by `make()` -> { cols, rows, rgba, holes? } (cols x rows cells of
197
+ * pixels, `holes` the cells left transparent) the first time; null when `make` has nothing. */
198
+ function imageId(key, make) {
199
+ key = `${key}|${cellW}x${cellH}`;
200
+ const hit = byKey.get(key);
201
+ if (hit != null && images.has(hit)) return hit;
202
+ const img = make();
203
+ if (!img) return null;
204
+ const id = nextId++;
205
+ images.set(id, { cols: img.cols, rows: img.rows, holes: img.holes ?? [], sixel: encode(img.rgba, img.cols * cellW, img.rows * cellH) });
206
+ byKey.set(key, id);
207
+ if (images.size > IMAGES_MAX) {
208
+ const old = images.keys().next().value;
209
+ images.delete(old);
210
+ for (const [k, v] of byKey) if (v === old) { byKey.delete(k); break; }
211
+ }
212
+ return id;
213
+ }
214
+
215
+ /** The placeholder for image `id`: one array of cells per row, the row's marker riding on its first cell. */
216
+ function placeholderCells(id) {
217
+ const { cols, rows } = images.get(id);
218
+ return Array.from({ length: rows }, (_, r) => Array.from({ length: cols }, (_, x) => (x ? ' ' : `[${MAGIC};${id};${r}m `)));
219
+ }
220
+
221
+ /**
222
+ * A creature as a sixel placeholder that fits in `maxCols` x `maxRows` cells (bottom-centred, keeping its
223
+ * proportions): `rows` lines of `cols` blank cells, each line starting with its marker. Null when sixel is
224
+ * off or the pack has no sprite for the display id.
225
+ */
226
+ export function sixelCreature(displayId, maxCols, maxRows) {
227
+ if (!sixelOn() || displayId == null || maxCols < 2 || maxRows < 2) return null;
228
+ return sixelImage(`c${displayId}`, () => sourceOf(displayId), maxCols, maxRows);
229
+ }
230
+
231
+ /** Any image (`source()` -> { w, h, rgba }, cropped) fitted to `maxCols` x `maxRows` cells as placeholder lines,
232
+ * cached under `key`; null when sixel is off or there is no image. */
233
+ export function sixelImage(key, source, maxCols, maxRows) {
234
+ if (!sixelOn()) return null;
235
+ const id = imageId(`${key}|${maxCols}x${maxRows}`, () => {
236
+ const img = source();
237
+ if (!img) return null;
238
+ const scale = Math.min((maxCols * cellW) / img.w, (maxRows * cellH) / img.h);
239
+ const tw = Math.max(1, Math.round(img.w * scale)), th = Math.max(1, Math.round(img.h * scale));
240
+ const cols = Math.ceil(tw / cellW), rows = Math.ceil(th / cellH);
241
+ return { cols, rows, rgba: scaleOnto(img, tw, th, cols * cellW, rows * cellH) };
242
+ });
243
+ return id == null ? null : placeholderCells(id).map((r) => r.join(''));
244
+ }
245
+
246
+ // ---- icons ----------------------------------------------------------------------------------------------
247
+
248
+ let iconIndex = null; // name -> byte offset in `iconPack`
249
+ let iconPack = null;
250
+
251
+ function loadIcons() {
252
+ if (iconIndex) return iconIndex;
253
+ iconIndex = new Map();
254
+ try {
255
+ iconPack = brotliDecompressSync(readFileSync(hdArtPath('icon-art-hd.bin.br')));
256
+ if (iconPack.toString('ascii', 0, 5) !== 'AZICH' || iconPack[5] !== 1) return iconIndex;
257
+ const count = iconPack.readUInt32LE(6);
258
+ let off = 10;
259
+ for (let i = 0; i < count && off < iconPack.length; i++) {
260
+ const n = iconPack[off];
261
+ iconIndex.set(iconPack.toString('ascii', off + 1, off + 1 + n), off + 1 + n);
262
+ off += 1 + n;
263
+ off += 2 + iconPack[off + 1] * 3 + iconPack[off] * iconPack[off];
264
+ }
265
+ } catch { iconPack = null; }
266
+ return iconIndex;
267
+ }
268
+
269
+ /** An icon's picture as { w, h, rgba }; `tone` 'gray' greys and darkens it (a talent out of reach). */
270
+ function iconSource(name, tone) {
271
+ const off = loadIcons().get(name);
272
+ if (off == null) return null;
273
+ const side = iconPack[off], palLen = iconPack[off + 1], pal = off + 2, idx = pal + palLen * 3;
274
+ const rgba = new Uint8Array(side * side * 4);
275
+ for (let i = 0; i < side * side; i++) {
276
+ const c = pal + iconPack[idx + i] * 3;
277
+ let r = iconPack[c], g = iconPack[c + 1], b = iconPack[c + 2];
278
+ if (tone === 'gray') r = g = b = Math.round((0.3 * r + 0.59 * g + 0.11 * b) * 0.6);
279
+ rgba[i * 4] = r; rgba[i * 4 + 1] = g; rgba[i * 4 + 2] = b; rgba[i * 4 + 3] = 255;
280
+ }
281
+ return { w: side, h: side, rgba };
282
+ }
283
+
284
+ /** Is there a full-size icon for `name` to draw as sixel? */
285
+ export const sixelIconOn = (name) => sixelOn() && !!name && loadIcons().has(String(name).toLowerCase());
286
+
287
+ /**
288
+ * The icon `name` filling `cols` x `cols / 2` cells, as rows of cells like iconCells (iconArt.mjs) makes,
289
+ * or null. `holes` ([{ x, y, w }] in cells) stay transparent so text drawn there (a rank, a count) shows
290
+ * through the picture.
291
+ */
292
+ export function sixelIconCells(name, cols, tone = '', holes = []) {
293
+ if (!sixelIconOn(name)) return null;
294
+ name = String(name).toLowerCase();
295
+ const rows = cols / 2;
296
+ const id = imageId(`i${name}|${cols}|${tone}|${holes.map((h) => `${h.x},${h.y},${h.w}`).join(';')}`, () => {
297
+ const img = iconSource(name, tone);
298
+ if (!img) return null;
299
+ const pw = cols * cellW, ph = rows * cellH;
300
+ const rgba = scaleOnto(img, pw, ph, pw, ph);
301
+ for (const h of holes) for (let y = h.y * cellH; y < (h.y + 1) * cellH; y++) {
302
+ for (let x = h.x * cellW; x < (h.x + h.w) * cellW; x++) rgba[(y * pw + x) * 4 + 3] = 0;
303
+ }
304
+ return { cols, rows, rgba, holes };
305
+ });
306
+ return id == null ? null : placeholderCells(id);
307
+ }
package/src/cli/term.mjs CHANGED
@@ -7,6 +7,8 @@
7
7
  // the Esc key and is reported at once.
8
8
  import { stdin, stdout } from 'node:process';
9
9
  import { fit } from './text.mjs';
10
+ import { splitCells } from './iconGrid.mjs';
11
+ import { MARK_RE, imageOf, noteDeviceAttributes, noteCellSize } from './sixel.mjs';
10
12
 
11
13
  const ESC = '\x1b[';
12
14
  const MIN_COLS = 60, MIN_ROWS = 20;
@@ -62,6 +64,7 @@ export class RealTerm {
62
64
  constructor() {
63
65
  this.onKey = () => {};
64
66
  this.onResize = () => {};
67
+ this.onSixel = () => {}; // the terminal said it draws sixel
65
68
  this.isTTY = stdout.isTTY === true && stdin.isTTY === true;
66
69
  this.last = [];
67
70
  }
@@ -75,9 +78,19 @@ export class RealTerm {
75
78
  stdin.resume();
76
79
  // Autowrap off: a line a terminal measures wider than we do (an ambiguous-width glyph) is clipped
77
80
  // instead of wrapping and scrolling the whole frame out of step with `last`.
78
- stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?7l${ESC}H${ESC}2J`);
81
+ // Then ask what the terminal is (DA1: does it draw sixel) and its cell size in pixels (CSI 16 t); each
82
+ // image keeps its own colour registers (?1070h).
83
+ stdout.write(`${ESC}?1049h${ESC}?25l${ESC}?7l${ESC}H${ESC}2J${ESC}?1070h${ESC}c${ESC}16t`);
79
84
  // Ctrl-L forgets what is on screen and repaints everything, to recover from a terminal that moved it.
80
- this._data = (chunk) => { for (const k of decodeKeys(chunk)) { if (k === 'ctrl-l') this.refresh(); else this.onKey(k); } };
85
+ // The replies to the questions above arrive as input; they are taken out before the keys are read.
86
+ this._data = (chunk) => {
87
+ let s = String(chunk), repaint = false;
88
+ s = s.replace(/\x1b\[(\?[0-9;]*)c/g, (_, p) => { if (noteDeviceAttributes(p)) { repaint = true; this.onSixel(); } return ''; });
89
+ s = s.replace(/\x1b\[6;(\d+);(\d+)t/g, (_, h, w) => { noteCellSize(+h, +w); repaint = true; return ''; });
90
+ if (repaint) this.refresh();
91
+ if (!s) return;
92
+ for (const k of decodeKeys(s)) { if (k === 'ctrl-l') this.refresh(); else this.onKey(k); }
93
+ };
81
94
  this._resize = () => this.refresh();
82
95
  stdin.on('data', this._data);
83
96
  stdout.on('resize', this._resize);
@@ -89,22 +102,54 @@ export class RealTerm {
89
102
  if (stdin.isTTY) stdin.setRawMode(false);
90
103
  stdin.pause();
91
104
  }
92
- refresh() { this.last = []; this.onResize(); }
93
- /** Paint a whole frame. Lines are fitted to the width; unchanged lines are skipped, unless most of the frame
94
- * changed (a new screen), which repaints it all so a stale row cannot outlive a screen change. The last
95
- * column stays blank so a terminal without deferred wrap never scrolls when the bottom row is painted. */
105
+ refresh() { this.last = []; this.lastImages = []; this.onResize(); }
106
+ /** Paint a whole frame. Lines are fitted to the width; of a changed line only the cells that changed are
107
+ * written, unless most of the frame changed (a new screen), which repaints it all so a stale row cannot
108
+ * outlive a screen change. The last column stays blank so a terminal without deferred wrap never scrolls
109
+ * when the bottom row is painted. Sixel placeholders (sixel.mjs) are taken out of the lines and their images
110
+ * sent after the text: an image that moved or went has its old rows erased, and one goes out again only when
111
+ * a cell written this frame lies on its pixels (text in a hole, a count on an icon, leaves it be). The frame
112
+ * is one synchronized update (mode 2026), so a terminal that knows it never shows the half-drawn state; one
113
+ * that does not ignores it. */
96
114
  draw(lines) {
97
115
  if (this.tooSmall) { this.drawTooSmall(); return; }
98
116
  const w = this.cols - 1, h = this.rows;
99
- const next = [];
100
- for (let i = 0; i < h; i++) next.push(fit(lines[i] ?? '', w));
101
- const changed = next.filter((line, i) => this.last[i] !== line).length;
117
+ const next = [], placed = new Map(); // `id@x,y` -> { id, x, y, bottom }: the same picture can stand twice
118
+ for (let i = 0; i < h; i++) next.push(takeMarks(fit(lines[i] ?? '', w), i, placed));
119
+ // An image goes out only when its bottom row made it to the screen: a pane that cut it short (a detail
120
+ // pane's last rows) would otherwise have it hang over what is below. Text drawn over its upper rows (a
121
+ // hit rising from the chest) can take a marker; the bottom row says where the image is all the same.
122
+ const images = [...placed.values()].filter((im) => im.bottom);
123
+ const same = (a, b) => a.id === b.id && a.x === b.x && a.y === b.y;
124
+ const erase = new Set();
125
+ for (const p of this.lastImages ?? []) {
126
+ if (images.some((im) => same(im, p))) continue;
127
+ for (let r = 0; r < (imageOf(p.id)?.rows ?? 0); r++) erase.add(p.y + r);
128
+ }
129
+ const changed = next.filter((line, i) => this.last[i] !== line || erase.has(i)).length;
102
130
  const full = !this.last.length || changed * 2 > h;
103
131
  // Every row is padded to the width, so only a frame with nothing known on screen needs a clear first.
104
132
  let out = this.last.length ? '' : `${ESC}0m${ESC}H${ESC}2J`;
105
- next.forEach((line, i) => { if (full || this.last[i] !== line) out += `${ESC}${i + 1};1H${ESC}0m${line}`; });
133
+ const wrote = new Map(); // row -> [from, to) column spans written
134
+ next.forEach((line, i) => {
135
+ if (full || erase.has(i)) {
136
+ out += `${ESC}${i + 1};1H${ESC}2K${ESC}0m${line}`;
137
+ wrote.set(i, [[0, w]]);
138
+ return;
139
+ }
140
+ if (this.last[i] === line) return;
141
+ const spans = changedSpans(this.last[i], line);
142
+ for (const [from, to, text] of spans) out += `${ESC}${i + 1};${from + 1}H${ESC}0m${text}`;
143
+ wrote.set(i, spans);
144
+ });
145
+ for (const im of images) {
146
+ const img = imageOf(im.id);
147
+ const known = (this.lastImages ?? []).some((p) => same(im, p));
148
+ if (!known || covers(img, im, wrote)) out += `${ESC}${im.y + 1};${im.x + 1}H${img.sixel}`;
149
+ }
106
150
  this.last = next;
107
- if (out) stdout.write(out);
151
+ this.lastImages = images;
152
+ if (out) stdout.write(`${ESC}?2026h${out}${ESC}?2026l`);
108
153
  }
109
154
  /** The screens need MIN_COLS x MIN_ROWS; a smaller terminal gets a notice instead of a frame drawn off its edge. */
110
155
  drawTooSmall() {
@@ -121,6 +166,55 @@ export class RealTerm {
121
166
  clearInterval(h) { clearInterval(h); }
122
167
  }
123
168
 
169
+ /** The runs of cells that differ between two painted lines of one width, as [from, to, text]: `text` paints
170
+ * columns from..to-1 of `line`, each cell carrying its own paint. */
171
+ export function changedSpans(prev, line) {
172
+ const a = splitCells(prev ?? ''), b = splitCells(line);
173
+ const spans = [];
174
+ for (let x = 0; x < b.length; x++) {
175
+ if (a[x] === b[x]) continue;
176
+ let end = x + 1;
177
+ while (end < b.length && a[end] !== b[end]) end++;
178
+ spans.push([x, end, b.slice(x, end).join('')]);
179
+ x = end;
180
+ }
181
+ return spans;
182
+ }
183
+
184
+ /** Does a written span lie on the pixels of image `img` placed at `im` (a hole's cells are no pixels)? */
185
+ function covers(img, im, wrote) {
186
+ for (let r = 0; r < img.rows; r++) {
187
+ for (const [from, to] of wrote.get(im.y + r) ?? []) {
188
+ for (let x = Math.max(from, im.x); x < Math.min(to, im.x + img.cols); x++) {
189
+ const cx = x - im.x;
190
+ if (!img.holes.some((h) => h.y === r && cx >= h.x && cx < h.x + h.w)) return true;
191
+ }
192
+ }
193
+ }
194
+ return false;
195
+ }
196
+
197
+ /** `line` without its sixel markers; each marker found notes a placement's top-left cell in `placed` (a row's
198
+ * marker sits at the image's left edge, and its row number says how far down the image the line is). */
199
+ export function takeMarks(line, y, placed) {
200
+ if (!line.includes('\x1b[9999;')) return line;
201
+ let out = '', col = 0, i = 0;
202
+ for (const m of line.matchAll(MARK_RE)) {
203
+ const before = line.slice(i, m.index);
204
+ out += before;
205
+ col += before.replace(/\x1b\[[0-9;]*m/g, '').length;
206
+ const id = +m[1], row = +m[2], img = imageOf(id);
207
+ if (img) {
208
+ const at = `${id}@${col},${y - row}`;
209
+ const p = placed.get(at) ?? { id, x: col, y: y - row, bottom: false };
210
+ if (row === img.rows - 1) p.bottom = true;
211
+ placed.set(at, p);
212
+ }
213
+ i = m.index + m[0].length;
214
+ }
215
+ return out + line.slice(i);
216
+ }
217
+
124
218
  export class HeadlessTerm {
125
219
  constructor(cols = 100, rows = 32) {
126
220
  this.cols = cols; this.rows = rows;
@@ -132,7 +226,7 @@ export class HeadlessTerm {
132
226
  }
133
227
  open() {}
134
228
  close() {}
135
- draw(lines) { this.frame = lines.slice(0, this.rows).map((l) => fit(l ?? '', this.cols)); this.frames++; }
229
+ draw(lines) { this.frame = lines.slice(0, this.rows).map((l, i) => takeMarks(fit(l ?? '', this.cols), i, new Map())); this.frames++; }
136
230
  /** Feed keys one at a time, as `decodeKeys` would produce them. */
137
231
  press(...keys) { for (const k of keys) this.onKey(k); }
138
232
  /** Type a string as individual character keys, then Enter. */
package/src/cli/text.mjs CHANGED
@@ -16,8 +16,10 @@ export const orange = sgr('38;5;208');
16
16
  export const QUALITY = ['poor', 'common', 'uncommon', 'rare', 'epic', 'legendary', 'artifact'];
17
17
  // The client's item quality colours (24-bit): grey poor, white common, green uncommon, blue rare,
18
18
  // purple epic, orange legendary, light gold artifact.
19
- const rgb = (r, g, b) => sgr(`38;2;${r};${g};${b}`);
20
- const QUALITY_PAINT = [rgb(157, 157, 157), rgb(255, 255, 255), rgb(30, 255, 0), rgb(0, 112, 221), rgb(163, 53, 238), rgb(255, 128, 0), rgb(230, 204, 128)];
19
+ const QUALITY_RGB = [[157, 157, 157], [255, 255, 255], [30, 255, 0], [0, 112, 221], [163, 53, 238], [255, 128, 0], [230, 204, 128]];
20
+ const QUALITY_PAINT = QUALITY_RGB.map(([r, g, b]) => sgr(`38;2;${r};${g};${b}`));
21
+ /** The quality colour as a bare SGR code, for a painter that builds its own escapes (an icon frame). */
22
+ export function qualityCodes(quality) { const [r, g, b] = QUALITY_RGB[quality ?? 1] ?? QUALITY_RGB[1]; return `38;2;${r};${g};${b}`; }
21
23
  /** An item name in its 1.12 quality colour. */
22
24
  export function qualityPaint(quality) { return QUALITY_PAINT[quality ?? 1] ?? white; }
23
25
  /** An item record's name in its quality colour; `fallback` when the record is missing. */
@@ -51,7 +53,7 @@ export function wrapLines(text, width) {
51
53
  let open = '';
52
54
  return out.map((l) => {
53
55
  const line = open + l;
54
- for (const m of l.match(SGR_RE) ?? []) open = m === '\x1b[0m' || m === '\x1b[m' ? '' : open + m;
56
+ for (const m of l.match(SGR_RE) ?? []) if (!m.startsWith('\x1b[9999;')) open = m === '\x1b[0m' || m === '\x1b[m' ? '' : open + m; // a sixel marker is no paint
55
57
  return open ? `${line}\x1b[0m` : line;
56
58
  });
57
59
  }