azeroth-shell 0.1.1 → 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 (66) hide show
  1. package/README.md +326 -82
  2. package/bin/azsh.mjs +21 -19
  3. package/package.json +19 -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/fileRepository.mjs +46 -1
  8. package/src/cli/gzLoader.mjs +47 -6
  9. package/src/cli/hdArt.mjs +81 -0
  10. package/src/cli/iconArt.mjs +135 -0
  11. package/src/cli/iconGrid.mjs +100 -0
  12. package/src/cli/modelArt.mjs +295 -0
  13. package/src/cli/paperdoll.mjs +76 -0
  14. package/src/cli/perfCheck.mjs +56 -0
  15. package/src/cli/repl.mjs +36 -7
  16. package/src/cli/rotationText.mjs +6 -6
  17. package/src/cli/sixel.mjs +307 -0
  18. package/src/cli/term.mjs +106 -12
  19. package/src/cli/text.mjs +5 -3
  20. package/src/cli/tui.mjs +252 -98
  21. package/src/cli/tuiAutopilot.mjs +347 -0
  22. package/src/cli/tuiBg.mjs +371 -42
  23. package/src/cli/tuiCheck.mjs +415 -28
  24. package/src/cli/tuiCity.mjs +118 -13
  25. package/src/cli/tuiCompanions.mjs +25 -15
  26. package/src/cli/tuiCreate.mjs +223 -0
  27. package/src/cli/tuiDungeon.mjs +1 -1
  28. package/src/cli/tuiFight.mjs +425 -88
  29. package/src/cli/tuiFightPanes.mjs +27 -8
  30. package/src/cli/tuiFloor.mjs +2 -2
  31. package/src/cli/tuiGear.mjs +10 -6
  32. package/src/cli/tuiLoot.mjs +1 -0
  33. package/src/cli/tuiPaperdoll.mjs +107 -0
  34. package/src/cli/tuiPartyStage.mjs +177 -0
  35. package/src/cli/tuiProfessions.mjs +4 -2
  36. package/src/cli/tuiRotationCond.mjs +390 -0
  37. package/src/cli/tuiSaves.mjs +22 -3
  38. package/src/cli/tuiSearch.mjs +75 -22
  39. package/src/cli/tuiSettings.mjs +15 -0
  40. package/src/cli/tuiSimLab.mjs +1 -1
  41. package/src/cli/tuiStage.mjs +304 -0
  42. package/src/cli/tuiTalentTree.mjs +124 -0
  43. package/src/cli/tuiViews.mjs +296 -115
  44. package/src/cli/tuiWorld.mjs +4 -5
  45. package/src/data/gear-art.bin.br +0 -0
  46. package/src/data/hd-art.json +114 -0
  47. package/src/data/icon-art.bin.gz +0 -0
  48. package/src/data/model-art.bin.gz +0 -0
  49. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  50. package/src/domain/bg/match.ts +85 -24
  51. package/src/domain/bg/strategies.ts +144 -29
  52. package/src/domain/bg/types.ts +2 -0
  53. package/src/domain/bg/wsg.ts +35 -20
  54. package/src/domain/quests/doQuest.ts +24 -6
  55. package/src/domain/rotation/build.ts +3031 -3031
  56. package/src/domain/rotation/classes/paladin.ts +16 -11
  57. package/src/domain/rotation/evaluate.ts +3 -0
  58. package/src/engine/combat/applySpell.ts +20 -9
  59. package/src/engine/combat/applySpellLate.ts +16 -12
  60. package/src/engine/combat/areaAura.ts +284 -283
  61. package/src/engine/combat/fighterFactory.ts +1 -1
  62. package/src/engine/combat/pickAction.ts +39 -1
  63. package/src/engine/combat/procsDamage.ts +31 -1
  64. package/src/engine/combat/sessionChase.ts +7 -1
  65. package/src/engine/combat/sessionMotionKit.ts +40 -2
  66. package/src/quests/quests.ts +60 -0
@@ -0,0 +1,295 @@
1
+ // Model art for the windowed game, drawn as truecolour half-block cells (▀ with the top pixel in the
2
+ // foreground and the bottom one in the background), two pixels a row. Two packs:
3
+ // src/data/model-art.bin.gz (scripts/bake-model-art.mjs) creature sprites by display id, a palette each
4
+ // src/data/gear-art.bin.br (scripts/bake-gear-art.py) the sixteen player bodies with their skins, faces,
5
+ // hair and every item appearance, one shared palette
6
+ // A creature is one sprite; a character is a skin with its layers stacked on it (paperdoll.mjs decides the
7
+ // layers). Either is scaled to the room it gets, so the same pack serves a 12-column corner and a 32-column
8
+ // pane. Transparent pixels stay bare spaces, so the art sits on whatever the screen paints behind it. No
9
+ // pack, or NO_COLOR: no art, never an error.
10
+ import { readFileSync } from 'node:fs';
11
+ import { gunzipSync, brotliDecompressSync } from 'node:zlib';
12
+ import { useColor } from './text.mjs';
13
+ import { sixelCreature, sixelImage, sixelOn, forgetSixelPacks } from './sixel.mjs';
14
+ import { hdArtPath } from './hdArt.mjs';
15
+
16
+ const PACK_URL = new URL('../data/model-art.bin.gz', import.meta.url);
17
+ const GEAR_URL = new URL('../data/gear-art.bin.br', import.meta.url); // brotli: a fifth smaller than gzip for a hundred thousand tiny images
18
+
19
+ // ---- the creature pack ------------------------------------------------------------------------------
20
+
21
+ let index = null; // displayId -> byte offset of the sprite header in `pack`
22
+ let pack = null;
23
+
24
+ function load() {
25
+ if (index) return index;
26
+ index = new Map();
27
+ try {
28
+ pack = gunzipSync(readFileSync(PACK_URL));
29
+ if (pack.toString('ascii', 0, 5) !== 'AZART' || pack[5] !== 1) return index;
30
+ const count = pack.readUInt32LE(6);
31
+ let off = 10;
32
+ for (let i = 0; i < count && off + 7 <= pack.length; i++) {
33
+ const id = pack.readUInt32LE(off), w = pack[off + 4], h = pack[off + 5], palLen = pack[off + 6];
34
+ index.set(id, off);
35
+ off += 7 + palLen * 3 + w * h;
36
+ }
37
+ } catch { pack = null; }
38
+ return index;
39
+ }
40
+
41
+ /** The baked sprite for a display id: `{ w, h, pal, idx }` (idx 0 is transparent, n is pal[n-1]), or null. */
42
+ export function spriteOf(displayId) {
43
+ const off = load().get(displayId);
44
+ if (off == null) return null;
45
+ const w = pack[off + 4], h = pack[off + 5], palLen = pack[off + 6];
46
+ const palStart = off + 7, idxStart = palStart + palLen * 3;
47
+ return { w, h, pal: pack.subarray(palStart, idxStart), idx: pack.subarray(idxStart, idxStart + w * h) };
48
+ }
49
+
50
+ /** Is there art for this display id? Cheap once the pack is loaded. */
51
+ export const hasArt = (displayId) => useColor && load().has(displayId);
52
+
53
+ /** A sprite as an image: `rgb` per pixel and an `on` mask (1 where the pixel is drawn). */
54
+ function spriteImage(sp) {
55
+ const n = sp.w * sp.h, rgb = new Uint8Array(n * 3), on = new Uint8Array(n);
56
+ for (let i = 0; i < n; i++) {
57
+ const p = sp.idx[i];
58
+ if (!p) continue;
59
+ on[i] = 1;
60
+ rgb[i * 3] = sp.pal[(p - 1) * 3]; rgb[i * 3 + 1] = sp.pal[(p - 1) * 3 + 1]; rgb[i * 3 + 2] = sp.pal[(p - 1) * 3 + 2];
61
+ }
62
+ return { w: sp.w, h: sp.h, rgb, on };
63
+ }
64
+
65
+ // ---- the gear pack ------------------------------------------------------------------------------------
66
+
67
+ let gear = null; // { buf, pal, bodies: slug -> { canvas, skins: skin -> offset, overlays: key -> { off, x, y, w, h } }, items: entry -> key }
68
+
69
+ function loadGear() {
70
+ if (gear) return gear;
71
+ gear = { buf: null, pal: null, bodies: new Map(), items: new Map() };
72
+ try {
73
+ const buf = brotliDecompressSync(readFileSync(GEAR_URL)); // about 170 ms once, on the first character drawn
74
+ if (buf.toString('ascii', 0, 6) !== 'AZGEAR' || buf[6] !== 1) return gear;
75
+ let off = 7;
76
+ const palLen = buf[off++];
77
+ const pal = buf.subarray(off, off + palLen * 3); off += palLen * 3;
78
+ const bodyCount = buf[off++];
79
+ const bodies = new Map();
80
+ for (let b = 0; b < bodyCount; b++) {
81
+ const slugLen = buf[off++];
82
+ const slug = buf.toString('ascii', off, off + slugLen); off += slugLen;
83
+ const canvas = buf[off++];
84
+ const body = { canvas, skins: new Map(), overlays: new Map() };
85
+ const skinCount = buf[off++];
86
+ for (let i = 0; i < skinCount; i++) { body.skins.set(buf[off], off + 1); off += 1 + canvas * canvas; }
87
+ const ovCount = buf.readUInt16LE(off); off += 2;
88
+ for (let i = 0; i < ovCount; i++) {
89
+ const keyLen = buf[off++];
90
+ const key = buf.toString('ascii', off, off + keyLen); off += keyLen;
91
+ const x = buf[off], y = buf[off + 1], w = buf[off + 2], h = buf[off + 3]; off += 4;
92
+ body.overlays.set(key, { off, x, y, w, h });
93
+ off += w * h;
94
+ }
95
+ bodies.set(slug, body);
96
+ }
97
+ const items = new Map();
98
+ const itemCount = buf.readUInt16LE(off); off += 2;
99
+ for (let i = 0; i < itemCount; i++) {
100
+ const entry = buf.readUInt32LE(off); off += 4;
101
+ const keyLen = buf[off++];
102
+ items.set(entry, buf.toString('ascii', off, off + keyLen)); off += keyLen;
103
+ }
104
+ gear = { buf, pal, bodies, items };
105
+ } catch { /* no gear pack: characters draw as bare bodies from the creature pack, or not at all */ }
106
+ return gear;
107
+ }
108
+
109
+ /** The gear pack's body for a slug ("human-male"): `{ canvas, skins, overlays }`, or null. */
110
+ export const gearBody = (slug) => (useColor ? loadGear().bodies.get(slug) ?? null : null);
111
+ /** The overlay key ("5-29958": InventoryType-appearance) an item entry draws as, or null. */
112
+ export const gearKeyOfItem = (entry) => loadGear().items.get(entry) ?? null;
113
+
114
+ /**
115
+ * A character composed on the gear pack: the body's `skin` with `layers` ({ key, z, mirrored }) stacked
116
+ * in z order, then cropped and drawn like a creature. A mirrored layer flips about the frame's centre (a
117
+ * main-hand weapon drawn in the off hand). Null without the pack, the body or room.
118
+ */
119
+ export function compositeArt(slug, skin, layers, maxCols, maxRows) {
120
+ if (!useColor || maxCols < 2 || maxRows < 3) return null;
121
+ const look = `${slug}|${skin}|${layers.map((l) => (l.mirrored ? `~${l.key}` : l.key)).join(',')}`;
122
+ if (sixelOn()) {
123
+ const hd = gearBodyHd(slug);
124
+ const six = hd && sixelImage(`g${look}`, () => {
125
+ const img = compose(hd, hd.buf, hd.pal, skin, layers);
126
+ return img && rgbaOf(crop(img));
127
+ }, maxCols, maxRows);
128
+ if (six) return six;
129
+ }
130
+ const g = loadGear(), body = g.bodies.get(slug);
131
+ if (!body || !g.buf) return null;
132
+ const key = `${look}|${maxCols}x${maxRows}`;
133
+ const hit = cache.get(key);
134
+ if (hit) return hit;
135
+ const img = compose(body, g.buf, g.pal, skin, layers);
136
+ return img ? remember(key, renderImage(crop(img), maxCols, maxRows)) : null;
137
+ }
138
+
139
+ /** A body's `skin` with `layers` stacked on it, as { w, h, rgb, on } on the body's square canvas; null
140
+ * without the skin. The pack (`buf`, `pal`) is the small one or a body's hd file. */
141
+ function compose(body, buf, pal, skin, layers) {
142
+ const skinOff = body.skins.get(skin) ?? body.skins.get(0);
143
+ if (skinOff == null) return null;
144
+ const c = body.canvas, n = c * c, rgb = new Uint8Array(n * 3), on = new Uint8Array(n);
145
+ const paint = (k, p) => { on[k] = 1; rgb[k * 3] = pal[(p - 1) * 3]; rgb[k * 3 + 1] = pal[(p - 1) * 3 + 1]; rgb[k * 3 + 2] = pal[(p - 1) * 3 + 2]; };
146
+ for (let k = 0; k < n; k++) { const p = buf[skinOff + k]; if (p) paint(k, p); }
147
+ for (const l of [...layers].sort((a, b) => a.z - b.z)) {
148
+ const ov = body.overlays.get(l.key);
149
+ if (!ov) continue;
150
+ const x0 = l.mirrored ? c - (ov.x + ov.w) : ov.x;
151
+ for (let y = 0; y < ov.h; y++) for (let x = 0; x < ov.w; x++) {
152
+ const p = buf[ov.off + y * ov.w + x];
153
+ if (!p) continue;
154
+ const cx = x0 + (l.mirrored ? ov.w - 1 - x : x), cy = ov.y + y;
155
+ if (cx < 0 || cx >= c || cy >= c) continue;
156
+ paint(cy * c + cx, p);
157
+ }
158
+ }
159
+ return { w: c, h: c, rgb, on };
160
+ }
161
+
162
+ /** { w, h, rgb, on } as { w, h, rgba }, the shape sixel.mjs scales. */
163
+ function rgbaOf({ w, h, rgb, on }) {
164
+ const rgba = new Uint8Array(w * h * 4);
165
+ for (let i = 0; i < w * h; i++) if (on[i]) { rgba[i * 4] = rgb[i * 3]; rgba[i * 4 + 1] = rgb[i * 3 + 1]; rgba[i * 4 + 2] = rgb[i * 3 + 2]; rgba[i * 4 + 3] = 255; }
166
+ return { w, h, rgba };
167
+ }
168
+
169
+ // ---- the hd gear bodies (sixel) -------------------------------------------------------------------------
170
+
171
+ const gearHd = new Map(); // slug -> { buf, pal, canvas, skins, overlays } or null
172
+
173
+ /** Forget every full-size pack read or found missing, so the next draw looks again (after a download), and
174
+ * the half-block art cached in their place. */
175
+ export function forgetHdArt() { forgetSixelPacks(); gearHd.clear(); cache.clear(); }
176
+
177
+ /** A body's hd file (scripts/bake-gear-art.py --hd): its palette, then the body section the small pack has
178
+ * for it. Read the first time the body is drawn; about 11 MB each once unpacked. */
179
+ function gearBodyHd(slug) {
180
+ if (gearHd.has(slug)) return gearHd.get(slug);
181
+ let body = null;
182
+ try {
183
+ const buf = brotliDecompressSync(readFileSync(hdArtPath(`gear-art-hd/${slug}.bin.br`)));
184
+ if (buf.toString('ascii', 0, 7) === 'AZGEARB' && buf[7] === 1) {
185
+ let off = 8;
186
+ const palLen = buf[off++];
187
+ const pal = buf.subarray(off, off + palLen * 3); off += palLen * 3;
188
+ off += 1 + buf[off]; // the slug again
189
+ const canvas = buf[off++];
190
+ body = { buf, pal, canvas, skins: new Map(), overlays: new Map() };
191
+ const skinCount = buf[off++];
192
+ for (let i = 0; i < skinCount; i++) { body.skins.set(buf[off], off + 1); off += 1 + canvas * canvas; }
193
+ const ovCount = buf.readUInt16LE(off); off += 2;
194
+ for (let i = 0; i < ovCount; i++) {
195
+ const keyLen = buf[off++];
196
+ const key = buf.toString('ascii', off, off + keyLen); off += keyLen;
197
+ const x = buf[off], y = buf[off + 1], w = buf[off + 2], h = buf[off + 3]; off += 4;
198
+ body.overlays.set(key, { off, x, y, w, h });
199
+ off += w * h;
200
+ }
201
+ }
202
+ } catch { /* no hd file for this body: the half-block art draws it */ }
203
+ gearHd.set(slug, body);
204
+ return body;
205
+ }
206
+
207
+ // ---- drawing ------------------------------------------------------------------------------------------
208
+
209
+ /** The image cut to its drawn pixels (the whole image when nothing is drawn). */
210
+ function crop(img) {
211
+ const { w, h, rgb, on } = img;
212
+ let x0 = w, y0 = h, x1 = -1, y1 = -1;
213
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
214
+ if (on[y * w + x]) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
215
+ }
216
+ if (x1 < 0) return img;
217
+ const cw = x1 - x0 + 1, ch = y1 - y0 + 1, crgb = new Uint8Array(cw * ch * 3), con = new Uint8Array(cw * ch);
218
+ for (let y = 0; y < ch; y++) for (let x = 0; x < cw; x++) {
219
+ const s = (y + y0) * w + x + x0, d = y * cw + x;
220
+ con[d] = on[s]; crgb[d * 3] = rgb[s * 3]; crgb[d * 3 + 1] = rgb[s * 3 + 1]; crgb[d * 3 + 2] = rgb[s * 3 + 2];
221
+ }
222
+ return { w: cw, h: ch, rgb: crgb, on: con };
223
+ }
224
+
225
+ // Area-average the image into gw x gh samples: colour from the drawn pixels, coverage from their share.
226
+ function sample(img, gw, gh) {
227
+ const { w, h, rgb, on } = img;
228
+ const cw = w / gw, ch = h / gh;
229
+ const out = new Float32Array(gw * gh * 4);
230
+ for (let gy = 0; gy < gh; gy++) for (let gx = 0; gx < gw; gx++) {
231
+ const sx0 = Math.floor(gx * cw), sx1 = Math.max(sx0 + 1, Math.floor((gx + 1) * cw));
232
+ const sy0 = Math.floor(gy * ch), sy1 = Math.max(sy0 + 1, Math.floor((gy + 1) * ch));
233
+ let r = 0, g = 0, b = 0, a = 0, n = 0;
234
+ for (let y = sy0; y < sy1 && y < h; y++) for (let x = sx0; x < sx1 && x < w; x++) {
235
+ const k = y * w + x;
236
+ n++;
237
+ if (!on[k]) continue;
238
+ r += rgb[k * 3]; g += rgb[k * 3 + 1]; b += rgb[k * 3 + 2]; a++;
239
+ }
240
+ const k = (gy * gw + gx) * 4;
241
+ if (a) { out[k] = r / a; out[k + 1] = g / a; out[k + 2] = b / a; }
242
+ out[k + 3] = n ? a / n : 0;
243
+ }
244
+ return out;
245
+ }
246
+
247
+ const fg = (s, k) => `\x1b[38;2;${s[k] | 0};${s[k + 1] | 0};${s[k + 2] | 0}m`;
248
+ const bg = (s, k) => `\x1b[48;2;${s[k] | 0};${s[k + 1] | 0};${s[k + 2] | 0}m`;
249
+ const RESET = '\x1b[0m';
250
+
251
+ /** The image as half-block lines that fit `maxCols` x `maxRows` keeping its proportions; each line as wide as the art. */
252
+ function renderImage(img, maxCols, maxRows) {
253
+ const scale = Math.min(1, maxCols / img.w, (maxRows * 2) / img.h);
254
+ const gw = Math.max(1, Math.round(img.w * scale)), gh = Math.max(2, Math.round(img.h * scale) + 1) & ~1;
255
+ const s = sample(img, gw, gh);
256
+ const lines = [];
257
+ for (let y = 0; y < gh; y += 2) {
258
+ let line = '';
259
+ for (let x = 0; x < gw; x++) {
260
+ const t = (y * gw + x) * 4, u = ((y + 1) * gw + x) * 4;
261
+ const ta = s[t + 3] > 0.5, ua = s[u + 3] > 0.5;
262
+ if (ta && ua) line += `${fg(s, t)}${bg(s, u)}▀${RESET}`;
263
+ else if (ta) line += `${fg(s, t)}▀${RESET}`;
264
+ else if (ua) line += `${fg(s, u)}▄${RESET}`;
265
+ else line += ' ';
266
+ }
267
+ lines.push(line);
268
+ }
269
+ return lines;
270
+ }
271
+
272
+ const cache = new Map();
273
+ const CACHE_MAX = 96;
274
+ function remember(key, lines) {
275
+ if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value);
276
+ cache.set(key, lines);
277
+ return lines;
278
+ }
279
+
280
+ /**
281
+ * A creature's sprite as lines of half-block cells that fit in `maxCols` x `maxRows` (keeping its
282
+ * proportions), or null when there is no art, no colour, or no room (under 3 rows).
283
+ */
284
+ export function creatureArt(displayId, maxCols, maxRows) {
285
+ if (!useColor || maxCols < 2 || maxRows < 3) return null;
286
+ const six = sixelCreature(displayId, maxCols, maxRows); // real pixels where the terminal draws sixel
287
+ if (six) return six;
288
+ const sp = spriteOf(displayId);
289
+ if (!sp) return null;
290
+ const key = `${displayId}|${maxCols}x${maxRows}`;
291
+ return cache.get(key) ?? remember(key, renderImage(spriteImage(sp), maxCols, maxRows));
292
+ }
293
+
294
+ /** Width in cells of art lines (every line is the same width). */
295
+ export const artWidth = (lines) => (lines?.length ? lines[0].replace(/\x1b\[[0-9;]*m/g, '').length : 0);
@@ -0,0 +1,76 @@
1
+ // A character (you, a companion, a saved hero) as layers on the gear pack: skin, face, facial hair and hair
2
+ // from the appearance, then every equipped item's appearance overlay in phonecraft's draw order
3
+ // (src/ui/core/paperdollSprite.ts). The pixels are modelArt.mjs's; this file only decides what stacks.
4
+ import { clampAppearance, deterministicAppearance, DEFAULT_APPEARANCE, bodySlug, hairStyleHiddenByHelm } from '../domain/character/appearance.ts';
5
+ import { equippedEntry } from '../domain/items/itemInstance.ts';
6
+ import { compositeArt, gearBody, gearKeyOfItem } from './modelArt.mjs';
7
+
8
+ // Composite draw order, bottom → top (the skin base is underneath):
9
+ // face → shirt → legs → feet → chest/robe → waist → tabard → wrist → hands
10
+ // → facial/beard (over torso gear so long beards drape on chest) → hair → cloak → shoulders → helm
11
+ // → weapons → off-hand. Appearance and gear share one z space so beard and hair interleave with armour.
12
+ const Z_APPEARANCE = { face: 1, facial: 10, hair: 11 };
13
+ const Z_BY_INVTYPE = {
14
+ 4: 2, 7: 3, 8: 4, 5: 5, 20: 5, 6: 6, 19: 7, 9: 8, 10: 9, 16: 12, 3: 13, 1: 14,
15
+ 13: 15, 17: 15, 21: 15, 15: 15, 25: 15, 26: 15, 14: 16, 22: 16, 23: 16,
16
+ };
17
+ const Z_GEAR_DEFAULT = 15, Z_MIRRORED = 16;
18
+ const WEAPON_SLOTS = new Set(['mainhand', 'offhand', 'ranged']);
19
+ // Overlays are keyed by the item's InventoryType, so a one-hander only exists drawn in the main hand; in the
20
+ // off hand it is mirrored about the frame's centre. Shields and held-in-off-hand items are baked on that side.
21
+ const MAINHAND_INVTYPES = new Set([13, 17, 21]);
22
+ const HUNTER = 3;
23
+
24
+ // The bake holds every weapon unsheathed, so a hunter with an axe and a gun would swing both: one set is
25
+ // drawn. The class that auto-attacks at range carries the ranged item; everyone else the melee pair.
26
+ // An empty set yields to the other, so a wand-only priest still holds something.
27
+ function drawnWeaponSlots(c) {
28
+ const hasMelee = equippedEntry(c.equipped?.mainhand) != null || equippedEntry(c.equipped?.offhand) != null;
29
+ const hasRanged = equippedEntry(c.equipped?.ranged) != null;
30
+ if (!hasMelee) return new Set(['ranged']);
31
+ if (!hasRanged || c.cls !== HUNTER) return new Set(['mainhand', 'offhand']);
32
+ return new Set(['ranged']);
33
+ }
34
+
35
+ /**
36
+ * What a subject ({ race, gender, cls, name, equipped, appearance }) composes into: the body slug, the skin
37
+ * and the layers `{ key, z, mirrored }`. The look is the stored appearance (clamped to the body's choices),
38
+ * else the deterministic-by-name one companions get, else the default. Null when the pack lacks the body.
39
+ */
40
+ export function paperdollLayers(c) {
41
+ const slug = bodySlug(c.race, c.gender);
42
+ const body = slug ? gearBody(slug) : null;
43
+ if (!body) return null;
44
+ const look = c.appearance ? clampAppearance(c.appearance, c.race, c.gender)
45
+ : c.name ? deterministicAppearance(c.name, c.race, c.gender) : DEFAULT_APPEARANCE;
46
+ const has = (k) => body.overlays.has(k);
47
+ const layers = [];
48
+ const headOn = equippedEntry(c.equipped?.head) != null;
49
+ const face = `face-${look.face}-skin-${look.skin}`;
50
+ if (look.face > 0 && has(face)) layers.push({ key: face, z: Z_APPEARANCE.face });
51
+ // One colour option drives beard as well as scalp; the bake has the per-colour beard where it was good.
52
+ const facial = has(`facial-${look.facialHair}-${look.hairColor}`) ? `facial-${look.facialHair}-${look.hairColor}`
53
+ : has(`facial-${look.facialHair}-0`) ? `facial-${look.facialHair}-0` : null;
54
+ if (facial) layers.push({ key: facial, z: Z_APPEARANCE.facial });
55
+ // Hair is additive over a bald base: style 0 is none. A helm hides hair, never horns or tusks.
56
+ const hair = `hair-${look.hairStyle}-${look.hairColor}`;
57
+ if (look.hairStyle > 0 && !(headOn && hairStyleHiddenByHelm(c.race, c.gender)) && has(hair)) layers.push({ key: hair, z: Z_APPEARANCE.hair });
58
+ const drawn = drawnWeaponSlots(c);
59
+ for (const [slot, v] of Object.entries(c.equipped ?? {})) {
60
+ if (WEAPON_SLOTS.has(slot) && !drawn.has(slot)) continue;
61
+ const entry = equippedEntry(v);
62
+ if (entry == null) continue;
63
+ const key = gearKeyOfItem(entry);
64
+ if (!key || !has(key)) continue;
65
+ const inv = Number(key.split('-')[0]);
66
+ const mirrored = slot === 'offhand' && MAINHAND_INVTYPES.has(inv);
67
+ layers.push({ key, z: mirrored ? Z_MIRRORED : Z_BY_INVTYPE[inv] ?? Z_GEAR_DEFAULT, mirrored });
68
+ }
69
+ return { slug, skin: look.skin, layers };
70
+ }
71
+
72
+ /** The subject drawn in its gear as half-block lines fitting `maxCols` x `maxRows`, or null. */
73
+ export function paperdollArt(c, maxCols, maxRows) {
74
+ const p = paperdollLayers(c);
75
+ return p ? compositeArt(p.slug, p.skin, p.layers, maxCols, maxRows) : null;
76
+ }
@@ -0,0 +1,56 @@
1
+ // Timing probe for the windowed game: module import, boot stages, heap, a repaint on every tab, and a live fight
2
+ // tick by tick, all headless. Numbers, not pass/fail; run with `npm run check:perf` and compare against the last
3
+ // run. The import is timed from a child process so the loader cache state is what a real launch would see.
4
+ import { execFileSync } from 'node:child_process';
5
+
6
+ const registerUrl = new URL('./register.mjs', import.meta.url).href;
7
+ const mb = (n) => `${(n / 1048576).toFixed(0)} MB`;
8
+ const now = () => performance.now();
9
+ const heap = () => process.memoryUsage();
10
+
11
+ // 1. Import cost, twice: the first run fills the stripped-JS cache when it is empty, the second is a normal launch.
12
+ const probe = 'const t = performance.now(); await import(process.argv[1]); console.log((performance.now() - t).toFixed(0));';
13
+ const tuiUrl = new URL('./tui.mjs', import.meta.url).href;
14
+ const importMs = () => Number(execFileSync(process.execPath, ['--import', registerUrl, '--input-type=module', '-e', probe, tuiUrl], { encoding: 'utf8' }).trim());
15
+ console.log(`import tui.mjs: ${importMs()} ms, then ${importMs()} ms`);
16
+
17
+ // 2. Boot stages and heap, in this process.
18
+ const { bootDataStore } = await import('./bootData.mjs');
19
+ const { HeadlessTerm } = await import('./term.mjs');
20
+ const { App } = await import('./tui.mjs');
21
+ const { newGameState } = await import('../engine/game.ts');
22
+ console.log(`heap after imports: ${mb(heap().heapUsed)}`);
23
+ let t = now(), stage = null;
24
+ const stages = [];
25
+ const ds = bootDataStore((s) => { if (stage) stages.push([stage, now() - t]); stage = s; t = now(); });
26
+ stages.push([stage, now() - t]);
27
+ console.log(`boot: ${stages.map(([s, ms]) => `${s} ${ms.toFixed(0)}`).join(', ')} = ${stages.reduce((a, [, ms]) => a + ms, 0).toFixed(0)} ms; heap ${mb(heap().heapUsed)}, rss ${mb(heap().rss)}`);
28
+
29
+ // 3. A repaint on every tab, and the world rows alone.
30
+ const term = new HeadlessTerm(110, 34);
31
+ const repo = { async save() {}, async list() { return []; }, async load() { return null; } };
32
+ const app = new App({ term, ds, repo });
33
+ term.open();
34
+ const human = ds.races().find((r) => r.name === 'Human');
35
+ app.startGame(newGameState(ds, human.id, 1, 'Perftest', 12345, 0), 'perftest', Date.now(), true);
36
+ const per = (fn, n = 50) => { fn(); const t0 = now(); for (let i = 0; i < n; i++) fn(); return (now() - t0) / n; };
37
+ const tabs = [];
38
+ for (let i = 0; i < app.tabs.length; i++) { app.switchTab(i); tabs.push(`${app.tabs[i].label} ${per(() => app.render()).toFixed(2)}`); }
39
+ app.switchTab(0);
40
+ console.log(`repaint ms/frame: ${tabs.join(', ')}; world rows alone ${per(() => app.rowsOf(app.top())).toFixed(2)}`);
41
+
42
+ // 4. A live fight: the engine tick and the repaint timed apart.
43
+ const mobRow = app.rowsOf(app.top()).findIndex((r) => r.data?.creature);
44
+ app.top().cursor = mobRow; term.press('return');
45
+ if (!app.fight) { console.log('FAIL no fight started'); process.exit(1); }
46
+ const s = app.fight.session;
47
+ let n = 0, eng = 0, ren = 0;
48
+ while (app.fight && n < 5000) {
49
+ const a = now(); const ev = s.tick(); eng += now() - a;
50
+ app.fight.logEvents(ev);
51
+ const b = now(); app.render(); ren += now() - b;
52
+ if (s.ended) { app.fight.finish(); app.fight?.release(); } // no hold on the win: the timing stops at the end
53
+ n++;
54
+ }
55
+ console.log(`fight: ${n} ticks, engine ${(eng / n).toFixed(2)} ms/tick, repaint ${(ren / n).toFixed(2)} ms/frame`);
56
+ app.closed = true;
package/src/cli/repl.mjs CHANGED
@@ -4,7 +4,7 @@ import { createInterface } from 'node:readline';
4
4
  import { stdin, stdout } from 'node:process';
5
5
  import { bootDataStore } from './bootData.mjs';
6
6
  import { FileSaveRepository } from './fileRepository.mjs';
7
- import { formatCombatLog, combatLogPlainText, POWER_MERGE_MS } from './combatLogFormat.ts';
7
+ import { formatCombatLog, combatLogPlainText, dropLogRepeats, POWER_MERGE_MS } from './combatLogFormat.ts';
8
8
  import { createGame, newGameState } from '../engine/game.ts';
9
9
  import { toSave } from '../persistence/saveShape.ts';
10
10
  import { className, raceName } from '../domain/core/ids.ts';
@@ -132,8 +132,12 @@ export async function main(argv = []) {
132
132
  state = await createCharacter(ds, ask);
133
133
  slot = `${state.char.name.toLowerCase()}-${Date.now().toString(36)}`;
134
134
  }
135
+ // Another terminal already playing this character would trade overwrites with us on every save.
136
+ const held = repo.lock(slot);
137
+ if (!held.ok) { console.log(red(`${state.char.name} is open in another terminal (pid ${held.pid}). Close it there first.`)); rl.close(); return; }
135
138
 
136
- const g = createGame(ds, state, { travelPace: 'instant' });
139
+ // Wall clock, like the TUI: both write the same saves, and a fight-chain stamp reads hours off there.
140
+ const g = createGame(ds, state, { travelPace: 'instant', wallWorldClock: true });
137
141
  if (g.state.char.journey) g.completeJourneyNow();
138
142
  // Real createInfo has no starting weapon (ETL gap KI-003); phonecraft's harness.mjs hands one over too.
139
143
  if (!save && g.state.char.equipped.mainhand == null) {
@@ -166,6 +170,7 @@ export async function main(argv = []) {
166
170
  try { await persist(); } catch (err) { console.log(red(`save failed: ${err.message}`)); }
167
171
  }
168
172
  await persist();
173
+ repo.unlock(slot);
169
174
  console.log(dim(`Saved ${g.state.char.name}. Farewell.`));
170
175
  rl.close();
171
176
  }
@@ -232,7 +237,7 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
232
237
  // the web client's combat log does, and drop a zero total (rage decay).
233
238
  let pend = null;
234
239
  const flush = () => { if (pend && pend.amount > 0) rows.push(line(pend.t, pend.remake(pend.amount))); pend = null; };
235
- for (const ev of events) {
240
+ for (const ev of dropLogRepeats(events)) {
236
241
  const row = formatCombatLog(ev, n);
237
242
  if (!row) continue;
238
243
  if (row.merge) {
@@ -573,9 +578,12 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
573
578
  // the fight as a real-time session and call `settle(outcome)` when it ends; without it fights are
574
579
  // auto-resolved by the engine. `ap.lastKind` is the kind of the step `next()` just took.
575
580
  // `quest` pins the run to one quest log entry (Do Quest); it stops when that quest is done or stuck.
576
- function createAutopilot(args, { liveFight, quest } = {}) {
581
+ // `maxSteps` overrides the step count in `args` (Infinity: no cap). `clock` gives the run's real elapsed
582
+ // ms, so the Stop after setting counts wall time; without it the planner's own clock runs (a second a
583
+ // step, ten a rest).
584
+ function createAutopilot(args, { liveFight, quest, maxSteps, clock } = {}) {
577
585
  const m = String(args ?? '').match(/^(\d+)?\s*(?:farm\s+(.+))?$/i);
578
- const steps = Math.min(Number(m?.[1] ?? 20), 5000);
586
+ const steps = maxSteps ?? Math.min(Number(m?.[1] ?? 20), 5000);
579
587
  // A reward choice is the player's to make, so Do Quest stops at the hand-in for those.
580
588
  let mode = quest != null ? { kind: 'quest', entry: quest, pin: true, handInByHand: !!ds.quest(quest)?.rewChoiceItems?.length } : { kind: 'quest' };
581
589
  if (m?.[2]) {
@@ -595,6 +603,7 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
595
603
  let done = false;
596
604
  let lastKind = null;
597
605
  let lastQuest = null; // the quest the latest step worked on, blamed when the planner gives up
606
+ let stopReason = null; // why the run ended: the planner's stop step, or 'steps' when the step budget ran out
598
607
  const skipped = [];
599
608
  const inLog = (entry) => g.state.questlog.quests.some((qs) => qs.entry === entry && qs.status !== 'turnedIn');
600
609
  const blacklist = (entry, why) => {
@@ -613,7 +622,7 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
613
622
  } else if (r.outcome === 'win') { prog.consecutiveDefeats = 0; prog.recovering = false; }
614
623
  };
615
624
  const next = () => {
616
- if (done || i >= steps) { done = true; return null; }
625
+ if (done || i >= steps) { if (!done) stopReason = { reason: 'steps', detail: `${steps} steps done` }; done = true; return null; }
617
626
  i++;
618
627
  lastKind = null;
619
628
  if (isDead()) {
@@ -624,7 +633,17 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
624
633
  let step = null;
625
634
  let line = '';
626
635
  try {
636
+ if (clock) prog.elapsedMs = clock();
627
637
  step = planStep(g.state, ds, mode, cfg, prog);
638
+ // The planner parks quests it cannot finish on its own; those join the blacklist too, so the
639
+ // run panel stops offering them as next and later runs skip them from the first step.
640
+ if (skipping) {
641
+ for (const e of sets.blockedQuests) {
642
+ if (questBlacklist.has(e) || !inLog(e)) continue;
643
+ questBlacklist.add(e); skipped.push(e);
644
+ out(`${dim(String(i).padStart(3) + '.')} ${yellow(`skipping ${nm(e)}: no way to finish it from here`)}`);
645
+ }
646
+ }
628
647
  lastKind = step.kind;
629
648
  if (prog.eliteNeedsSquad && !companionHintShown && !(g.state.squad ?? []).length) {
630
649
  companionHintShown = true;
@@ -665,6 +684,7 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
665
684
  const why = `${step.reason}${step.detail ? ` (${step.detail})` : ''}`;
666
685
  // Stuck or beaten on a quest: drop that quest and plan again rather than end the run.
667
686
  if (skipping && (step.reason === 'noTargets' || step.reason === 'defeated') && lastQuest != null && !sets.blockedQuests.has(lastQuest) && inLog(lastQuest)) { line = blacklist(lastQuest, step.detail ?? step.reason); break; }
687
+ stopReason = { reason: step.reason, detail: step.detail };
668
688
  line = yellow(`autopilot stopped: ${why}`); done = true; break;
669
689
  }
670
690
  default: line = dim(`unhandled step ${step.kind}`);
@@ -678,7 +698,16 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
678
698
  return line ? `${dim(String(i).padStart(3) + '.')} ${line}` : '';
679
699
  };
680
700
  const summary = () => `Level ${g.state.char.level}, ${moneyText(g.state.char.gold)}, ${zoneHere()?.name}.${skipped.length ? ` Skipped: ${skipped.map(nm).join(', ')}.` : ''}`;
681
- return { next, steps, summary, get done() { return done; }, get lastKind() { return lastKind; }, get step() { return i; } };
701
+ // The windowed game reads the rest for its run panel, Run pane and end card: why the run stopped, which
702
+ // quests it dropped, the death streak and the planner's own clock (one second a step, ten a rest).
703
+ return {
704
+ next, steps, summary, mode, skipped,
705
+ get done() { return done; }, get lastKind() { return lastKind; }, get step() { return i; },
706
+ get stopReason() { return stopReason; }, get defeats() { return prog.consecutiveDefeats; }, get elapsedMs() { return prog.elapsedMs; },
707
+ get lastQuest() { return lastQuest; },
708
+ skips: (entry) => questBlacklist.has(entry) || sets.blockedQuests.has(entry),
709
+ questDefeats: (entry) => prog.questDefeats.get(entry) ?? 0,
710
+ };
682
711
  }
683
712
 
684
713
  function runAutopilot(args) {
@@ -10,14 +10,14 @@ export const SUBJECT_LABEL = { self: 'you', target: 'the enemy', pet: 'your pet'
10
10
  const ALLY_ROLE_LABEL = { tank: 'tank', healer: 'healer', damage: 'DPS' };
11
11
  const COND_OP_LABEL = { '<': 'less than', '<=': 'at most', '>': 'more than', '>=': 'at least', '==': 'equal to', '!=': 'not equal to' };
12
12
  const RAID_JOB_LABEL = { 'main-tank': 'Main tank', 'off-tank': 'Off-tank', healer: 'Healer', dps: 'DPS' };
13
- const SCHOOL_NAMES = ['Physical', 'Holy', 'Fire', 'Nature', 'Frost', 'Shadow', 'Arcane'];
14
- const TOTEM_ELEMENTS = ['Fire', 'Earth', 'Water', 'Air'];
15
- const AOE_MODE_NAMES = { 0: 'auto', 1: 'AoE', 2: 'single-target' };
13
+ export const SCHOOL_NAMES = ['Physical', 'Holy', 'Fire', 'Nature', 'Frost', 'Shadow', 'Arcane'];
14
+ export const TOTEM_ELEMENTS = ['Fire', 'Earth', 'Water', 'Air'];
15
+ export const AOE_MODE_NAMES = { 0: 'auto', 1: 'AoE', 2: 'single-target' };
16
16
 
17
17
  const titleCase = (k) => k.charAt(0) + k.slice(1).toLowerCase().replace(/_/g, ' ');
18
- const CREATURE_TYPE_NAME = new Map(Object.entries(CREATURE_TYPE).map(([k, v]) => [v, titleCase(k)]));
19
- const DISPEL_TYPE_NAME = new Map(Object.entries(DISPEL_TYPE).map(([k, v]) => [v, titleCase(k)]));
20
- const creatureMaskName = (mask) => {
18
+ export const CREATURE_TYPE_NAME = new Map(Object.entries(CREATURE_TYPE).map(([k, v]) => [v, titleCase(k)]));
19
+ export const DISPEL_TYPE_NAME = new Map(Object.entries(DISPEL_TYPE).map(([k, v]) => [v, titleCase(k)]));
20
+ export const creatureMaskName = (mask) => {
21
21
  const hit = [...CREATURE_TYPE_NAME].filter(([t]) => (((1 << (t - 1)) & (mask ?? 0)) !== 0));
22
22
  return hit.length ? hit.map(([, n]) => n).join(' or ') : 'that creature type';
23
23
  };