azeroth-shell 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +362 -85
- package/bin/azsh.mjs +35 -19
- package/package.json +12 -1
- package/src/autopilot/plan.ts +8 -11
- package/src/autopilot/view.ts +12 -11
- package/src/cli/combatLogFormat.ts +22 -0
- package/src/cli/gzLoader.mjs +47 -6
- package/src/cli/hdArt.mjs +81 -0
- package/src/cli/iconArt.mjs +145 -0
- package/src/cli/iconGrid.mjs +100 -0
- package/src/cli/modelArt.mjs +298 -0
- package/src/cli/oocPrep.mjs +32 -0
- package/src/cli/paperdoll.mjs +76 -0
- package/src/cli/perfCheck.mjs +56 -0
- package/src/cli/repl.mjs +48 -7
- package/src/cli/rotationText.mjs +6 -6
- package/src/cli/sixel.mjs +319 -0
- package/src/cli/term.mjs +106 -12
- package/src/cli/text.mjs +21 -3
- package/src/cli/tui.mjs +262 -102
- package/src/cli/tuiAutopilot.mjs +356 -0
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiBg.mjs +370 -41
- package/src/cli/tuiCheck.mjs +633 -37
- package/src/cli/tuiCity.mjs +146 -41
- package/src/cli/tuiCompanionKit.mjs +6 -6
- package/src/cli/tuiCompanions.mjs +211 -41
- package/src/cli/tuiCreate.mjs +230 -0
- package/src/cli/tuiDungeon.mjs +57 -21
- package/src/cli/tuiFight.mjs +467 -88
- package/src/cli/tuiFightPanes.mjs +42 -14
- package/src/cli/tuiFloor.mjs +21 -9
- package/src/cli/tuiGear.mjs +17 -14
- package/src/cli/tuiLoot.mjs +6 -6
- package/src/cli/tuiMap.mjs +214 -0
- package/src/cli/tuiPaperdoll.mjs +108 -0
- package/src/cli/tuiPartyStage.mjs +206 -0
- package/src/cli/tuiPet.mjs +16 -8
- package/src/cli/tuiProfessions.mjs +27 -25
- package/src/cli/tuiRotationCond.mjs +390 -0
- package/src/cli/tuiSaves.mjs +24 -1
- package/src/cli/tuiSearch.mjs +79 -27
- package/src/cli/tuiSettings.mjs +3 -4
- package/src/cli/tuiStage.mjs +360 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiTalents.mjs +3 -4
- package/src/cli/tuiViews.mjs +633 -172
- package/src/cli/tuiWiki.mjs +2 -3
- package/src/cli/tuiWorld.mjs +90 -7
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +114 -0
- package/src/data/icon-art.bin.gz +0 -0
- package/src/data/model-art-worn.bin.br +0 -0
- package/src/data/model-art.bin.gz +0 -0
- package/src/data/rotations/pve/paladin/retribution.json +2 -2
- package/src/data/tables/creatures.json.gz +0 -0
- package/src/data/world-map-sixel.bin.br +0 -0
- package/src/data/world-map.bin.gz +0 -0
- package/src/domain/bg/match.ts +85 -24
- package/src/domain/bg/strategies.ts +144 -29
- package/src/domain/bg/types.ts +2 -0
- package/src/domain/bg/wsg.ts +35 -20
- package/src/domain/character/character.ts +6 -0
- package/src/domain/pet/pet.ts +31 -0
- package/src/domain/quests/doQuest.ts +24 -6
- package/src/domain/rotation/build.ts +3031 -3031
- package/src/domain/rotation/classes/paladin.ts +16 -11
- package/src/domain/rotation/evaluate.ts +3 -0
- package/src/engine/combat/applySpell.ts +20 -9
- package/src/engine/combat/applySpellLate.ts +16 -12
- package/src/engine/combat/areaAura.ts +284 -283
- package/src/engine/combat/fighterFactory.ts +1 -1
- package/src/engine/combat/pickAction.ts +3494 -3449
- package/src/engine/combat/procsDamage.ts +31 -1
- package/src/engine/combat/sessionChase.ts +19 -2
- package/src/engine/combat/sessionMotionKit.ts +40 -2
- package/src/engine/game/loadReconcile.ts +21 -1
- package/src/engine/game/pets.ts +33 -1
- package/src/engine/game/types.ts +4 -0
- package/src/quests/quests.ts +60 -0
|
@@ -0,0 +1,298 @@
|
|
|
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. Version 1 (a release's 128 px bodies) holds the canvas and each x y w h in a byte, version 2 (256 px,
|
|
179
|
+
* past a byte) in a u16. Read the first time the body is drawn. */
|
|
180
|
+
function gearBodyHd(slug) {
|
|
181
|
+
if (gearHd.has(slug)) return gearHd.get(slug);
|
|
182
|
+
let body = null;
|
|
183
|
+
try {
|
|
184
|
+
const buf = brotliDecompressSync(readFileSync(hdArtPath(`gear-art-hd/${slug}.bin.br`)));
|
|
185
|
+
const version = buf[7];
|
|
186
|
+
if (buf.toString('ascii', 0, 7) === 'AZGEARB' && (version === 1 || version === 2)) {
|
|
187
|
+
let off = 8;
|
|
188
|
+
const n = version === 2 ? 2 : 1, num = (o) => (n === 2 ? buf.readUInt16LE(o) : buf[o]);
|
|
189
|
+
const palLen = buf[off++];
|
|
190
|
+
const pal = buf.subarray(off, off + palLen * 3); off += palLen * 3;
|
|
191
|
+
off += 1 + buf[off]; // the slug again
|
|
192
|
+
const canvas = num(off); off += n;
|
|
193
|
+
body = { buf, pal, canvas, skins: new Map(), overlays: new Map() };
|
|
194
|
+
const skinCount = buf[off++];
|
|
195
|
+
for (let i = 0; i < skinCount; i++) { body.skins.set(buf[off], off + 1); off += 1 + canvas * canvas; }
|
|
196
|
+
const ovCount = buf.readUInt16LE(off); off += 2;
|
|
197
|
+
for (let i = 0; i < ovCount; i++) {
|
|
198
|
+
const keyLen = buf[off++];
|
|
199
|
+
const key = buf.toString('ascii', off, off + keyLen); off += keyLen;
|
|
200
|
+
const x = num(off), y = num(off + n), w = num(off + 2 * n), h = num(off + 3 * n); off += 4 * n;
|
|
201
|
+
body.overlays.set(key, { off, x, y, w, h });
|
|
202
|
+
off += w * h;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} catch { /* no hd file for this body: the half-block art draws it */ }
|
|
206
|
+
gearHd.set(slug, body);
|
|
207
|
+
return body;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---- drawing ------------------------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/** The image cut to its drawn pixels (the whole image when nothing is drawn). */
|
|
213
|
+
function crop(img) {
|
|
214
|
+
const { w, h, rgb, on } = img;
|
|
215
|
+
let x0 = w, y0 = h, x1 = -1, y1 = -1;
|
|
216
|
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
|
217
|
+
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; }
|
|
218
|
+
}
|
|
219
|
+
if (x1 < 0) return img;
|
|
220
|
+
const cw = x1 - x0 + 1, ch = y1 - y0 + 1, crgb = new Uint8Array(cw * ch * 3), con = new Uint8Array(cw * ch);
|
|
221
|
+
for (let y = 0; y < ch; y++) for (let x = 0; x < cw; x++) {
|
|
222
|
+
const s = (y + y0) * w + x + x0, d = y * cw + x;
|
|
223
|
+
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];
|
|
224
|
+
}
|
|
225
|
+
return { w: cw, h: ch, rgb: crgb, on: con };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Area-average the image into gw x gh samples: colour from the drawn pixels, coverage from their share.
|
|
229
|
+
function sample(img, gw, gh) {
|
|
230
|
+
const { w, h, rgb, on } = img;
|
|
231
|
+
const cw = w / gw, ch = h / gh;
|
|
232
|
+
const out = new Float32Array(gw * gh * 4);
|
|
233
|
+
for (let gy = 0; gy < gh; gy++) for (let gx = 0; gx < gw; gx++) {
|
|
234
|
+
const sx0 = Math.floor(gx * cw), sx1 = Math.max(sx0 + 1, Math.floor((gx + 1) * cw));
|
|
235
|
+
const sy0 = Math.floor(gy * ch), sy1 = Math.max(sy0 + 1, Math.floor((gy + 1) * ch));
|
|
236
|
+
let r = 0, g = 0, b = 0, a = 0, n = 0;
|
|
237
|
+
for (let y = sy0; y < sy1 && y < h; y++) for (let x = sx0; x < sx1 && x < w; x++) {
|
|
238
|
+
const k = y * w + x;
|
|
239
|
+
n++;
|
|
240
|
+
if (!on[k]) continue;
|
|
241
|
+
r += rgb[k * 3]; g += rgb[k * 3 + 1]; b += rgb[k * 3 + 2]; a++;
|
|
242
|
+
}
|
|
243
|
+
const k = (gy * gw + gx) * 4;
|
|
244
|
+
if (a) { out[k] = r / a; out[k + 1] = g / a; out[k + 2] = b / a; }
|
|
245
|
+
out[k + 3] = n ? a / n : 0;
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const fg = (s, k) => `\x1b[38;2;${s[k] | 0};${s[k + 1] | 0};${s[k + 2] | 0}m`;
|
|
251
|
+
const bg = (s, k) => `\x1b[48;2;${s[k] | 0};${s[k + 1] | 0};${s[k + 2] | 0}m`;
|
|
252
|
+
const RESET = '\x1b[0m';
|
|
253
|
+
|
|
254
|
+
/** The image as half-block lines that fit `maxCols` x `maxRows` keeping its proportions; each line as wide as the art. */
|
|
255
|
+
function renderImage(img, maxCols, maxRows) {
|
|
256
|
+
const scale = Math.min(1, maxCols / img.w, (maxRows * 2) / img.h);
|
|
257
|
+
const gw = Math.max(1, Math.round(img.w * scale)), gh = Math.max(2, Math.round(img.h * scale) + 1) & ~1;
|
|
258
|
+
const s = sample(img, gw, gh);
|
|
259
|
+
const lines = [];
|
|
260
|
+
for (let y = 0; y < gh; y += 2) {
|
|
261
|
+
let line = '';
|
|
262
|
+
for (let x = 0; x < gw; x++) {
|
|
263
|
+
const t = (y * gw + x) * 4, u = ((y + 1) * gw + x) * 4;
|
|
264
|
+
const ta = s[t + 3] > 0.5, ua = s[u + 3] > 0.5;
|
|
265
|
+
if (ta && ua) line += `${fg(s, t)}${bg(s, u)}▀${RESET}`;
|
|
266
|
+
else if (ta) line += `${fg(s, t)}▀${RESET}`;
|
|
267
|
+
else if (ua) line += `${fg(s, u)}▄${RESET}`;
|
|
268
|
+
else line += ' ';
|
|
269
|
+
}
|
|
270
|
+
lines.push(line);
|
|
271
|
+
}
|
|
272
|
+
return lines;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const cache = new Map();
|
|
276
|
+
const CACHE_MAX = 96;
|
|
277
|
+
function remember(key, lines) {
|
|
278
|
+
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value);
|
|
279
|
+
cache.set(key, lines);
|
|
280
|
+
return lines;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A creature's sprite as lines of half-block cells that fit in `maxCols` x `maxRows` (keeping its
|
|
285
|
+
* proportions), or null when there is no art, no colour, or no room (under 3 rows).
|
|
286
|
+
*/
|
|
287
|
+
export function creatureArt(displayId, maxCols, maxRows) {
|
|
288
|
+
if (!useColor || maxCols < 2 || maxRows < 3) return null;
|
|
289
|
+
const six = sixelCreature(displayId, maxCols, maxRows); // real pixels where the terminal draws sixel
|
|
290
|
+
if (six) return six;
|
|
291
|
+
const sp = spriteOf(displayId);
|
|
292
|
+
if (!sp) return null;
|
|
293
|
+
const key = `${displayId}|${maxCols}x${maxRows}`;
|
|
294
|
+
return cache.get(key) ?? remember(key, renderImage(spriteImage(sp), maxCols, maxRows));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Width in cells of art lines (every line is the same width). */
|
|
298
|
+
export const artWidth = (lines) => (lines?.length ? lines[0].replace(/\x1b\[[0-9;]*m/g, '').length : 0);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Out-of-combat prep before a pull: the rotation's buffs phase (armours, Intellect, imbues, conjures, the pet)
|
|
2
|
+
// cast standing still, not on the fight's first ticks. phonecraft autopilotController.ts prepOoc (:889) without
|
|
3
|
+
// its fillMana: the mana the buffs cost stays spent, and the autopilot drinks it back before it pulls.
|
|
4
|
+
import { pickOocAction, useOocPrepItem } from '../autopilot/combatRotation.ts';
|
|
5
|
+
import { maxPower } from '../domain/character/character.ts';
|
|
6
|
+
import { powerType } from '../domain/character/characterStats.ts';
|
|
7
|
+
|
|
8
|
+
const MAX_PREP_ACTIONS = 8; // each pick is a guarded rule cast once; the cap keeps a refused rule from looping
|
|
9
|
+
|
|
10
|
+
/** Cast what the buffs phase wants up. Returns the names cast and the mana left as a fraction (1 for non-mana). */
|
|
11
|
+
export function prepOoc(g, ds) {
|
|
12
|
+
const cast = [];
|
|
13
|
+
for (let i = 0; i < MAX_PREP_ACTIONS; i++) {
|
|
14
|
+
const action = pickOocAction(g.rotation(), g.state, ds);
|
|
15
|
+
if (!action) break;
|
|
16
|
+
if (action.kind === 'spell') {
|
|
17
|
+
if (!g.castOutOfCombat(action.spellId).ok) break;
|
|
18
|
+
cast.push(ds.spell(action.spellId)?.name ?? `spell ${action.spellId}`);
|
|
19
|
+
} else if (action.kind === 'item') {
|
|
20
|
+
if (!useOocPrepItem(g, action)) break;
|
|
21
|
+
cast.push(ds.item(action.itemEntry)?.name ?? `item ${action.itemEntry}`);
|
|
22
|
+
} else break;
|
|
23
|
+
}
|
|
24
|
+
const ch = g.state.char;
|
|
25
|
+
const max = powerType(ch) === 'mana' ? maxPower(ch, ds) : 0;
|
|
26
|
+
return { cast: [...new Set(cast)], manaPct: max > 0 ? ch.power / max : 1 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The log line for a prep that cast something, or null. */
|
|
30
|
+
export function prepText(prep) {
|
|
31
|
+
return prep.cast.length ? `Buffed up: ${prep.cast.join(', ')}` : null;
|
|
32
|
+
}
|
|
@@ -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';
|
|
@@ -17,6 +17,7 @@ import { planStep, noteQuestDefeat } from '../autopilot/plan.ts';
|
|
|
17
17
|
|
|
18
18
|
// Deaths on one quest before world questing gives up on it and moves to the next.
|
|
19
19
|
const QUEST_SKIP_DEFEATS = 2;
|
|
20
|
+
import { prepOoc, prepText } from './oocPrep.mjs';
|
|
20
21
|
import { pullOpts, squadLogNames, squadModeOf, setSquadMode, SQUAD_MODE_LABEL, SQUAD_MODE_WORDS, COMPANION_HINT_LINES } from './squad.mjs';
|
|
21
22
|
|
|
22
23
|
const useColor = stdout.isTTY && !process.env.NO_COLOR;
|
|
@@ -136,7 +137,8 @@ export async function main(argv = []) {
|
|
|
136
137
|
const held = repo.lock(slot);
|
|
137
138
|
if (!held.ok) { console.log(red(`${state.char.name} is open in another terminal (pid ${held.pid}). Close it there first.`)); rl.close(); return; }
|
|
138
139
|
|
|
139
|
-
|
|
140
|
+
// Wall clock, like the TUI: both write the same saves, and a fight-chain stamp reads hours off there.
|
|
141
|
+
const g = createGame(ds, state, { travelPace: 'instant', wallWorldClock: true });
|
|
140
142
|
if (g.state.char.journey) g.completeJourneyNow();
|
|
141
143
|
// Real createInfo has no starting weapon (ETL gap KI-003); phonecraft's harness.mjs hands one over too.
|
|
142
144
|
if (!save && g.state.char.equipped.mainhand == null) {
|
|
@@ -236,7 +238,7 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
|
|
|
236
238
|
// the web client's combat log does, and drop a zero total (rage decay).
|
|
237
239
|
let pend = null;
|
|
238
240
|
const flush = () => { if (pend && pend.amount > 0) rows.push(line(pend.t, pend.remake(pend.amount))); pend = null; };
|
|
239
|
-
for (const ev of events) {
|
|
241
|
+
for (const ev of dropLogRepeats(events)) {
|
|
240
242
|
const row = formatCombatLog(ev, n);
|
|
241
243
|
if (!row) continue;
|
|
242
244
|
if (row.merge) {
|
|
@@ -263,6 +265,8 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
|
|
|
263
265
|
|
|
264
266
|
/** g.fight with the squad when the squad mode says so; the log names the companions. */
|
|
265
267
|
const quickFight = (entry) => {
|
|
268
|
+
const buffed = prepText(prepOoc(g, ds));
|
|
269
|
+
if (buffed) out(dim(` ${buffed}`));
|
|
266
270
|
const opts = pullOpts(g, ds, buffPace, entry, (l) => out(dim(` ${l}`)));
|
|
267
271
|
const r = g.fight(entry, opts);
|
|
268
272
|
return { r, logNames: opts ? squadLogNames(g, entry, names()) : names() };
|
|
@@ -577,9 +581,12 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
|
|
|
577
581
|
// the fight as a real-time session and call `settle(outcome)` when it ends; without it fights are
|
|
578
582
|
// auto-resolved by the engine. `ap.lastKind` is the kind of the step `next()` just took.
|
|
579
583
|
// `quest` pins the run to one quest log entry (Do Quest); it stops when that quest is done or stuck.
|
|
580
|
-
|
|
584
|
+
// `maxSteps` overrides the step count in `args` (Infinity: no cap). `clock` gives the run's real elapsed
|
|
585
|
+
// ms, so the Stop after setting counts wall time; without it the planner's own clock runs (a second a
|
|
586
|
+
// step, ten a rest).
|
|
587
|
+
function createAutopilot(args, { liveFight, quest, maxSteps, clock } = {}) {
|
|
581
588
|
const m = String(args ?? '').match(/^(\d+)?\s*(?:farm\s+(.+))?$/i);
|
|
582
|
-
const steps = Math.min(Number(m?.[1] ?? 20), 5000);
|
|
589
|
+
const steps = maxSteps ?? Math.min(Number(m?.[1] ?? 20), 5000);
|
|
583
590
|
// A reward choice is the player's to make, so Do Quest stops at the hand-in for those.
|
|
584
591
|
let mode = quest != null ? { kind: 'quest', entry: quest, pin: true, handInByHand: !!ds.quest(quest)?.rewChoiceItems?.length } : { kind: 'quest' };
|
|
585
592
|
if (m?.[2]) {
|
|
@@ -599,6 +606,8 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
|
|
|
599
606
|
let done = false;
|
|
600
607
|
let lastKind = null;
|
|
601
608
|
let lastQuest = null; // the quest the latest step worked on, blamed when the planner gives up
|
|
609
|
+
let restedForBuffs = false; // the pull's buffs already sent the run to rest once
|
|
610
|
+
let stopReason = null; // why the run ended: the planner's stop step, or 'steps' when the step budget ran out
|
|
602
611
|
const skipped = [];
|
|
603
612
|
const inLog = (entry) => g.state.questlog.quests.some((qs) => qs.entry === entry && qs.status !== 'turnedIn');
|
|
604
613
|
const blacklist = (entry, why) => {
|
|
@@ -617,7 +626,7 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
|
|
|
617
626
|
} else if (r.outcome === 'win') { prog.consecutiveDefeats = 0; prog.recovering = false; }
|
|
618
627
|
};
|
|
619
628
|
const next = () => {
|
|
620
|
-
if (done || i >= steps) { done = true; return null; }
|
|
629
|
+
if (done || i >= steps) { if (!done) stopReason = { reason: 'steps', detail: `${steps} steps done` }; done = true; return null; }
|
|
621
630
|
i++;
|
|
622
631
|
lastKind = null;
|
|
623
632
|
if (isDead()) {
|
|
@@ -628,7 +637,17 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
|
|
|
628
637
|
let step = null;
|
|
629
638
|
let line = '';
|
|
630
639
|
try {
|
|
640
|
+
if (clock) prog.elapsedMs = clock();
|
|
631
641
|
step = planStep(g.state, ds, mode, cfg, prog);
|
|
642
|
+
// The planner parks quests it cannot finish on its own; those join the blacklist too, so the
|
|
643
|
+
// run panel stops offering them as next and later runs skip them from the first step.
|
|
644
|
+
if (skipping) {
|
|
645
|
+
for (const e of sets.blockedQuests) {
|
|
646
|
+
if (questBlacklist.has(e) || !inLog(e)) continue;
|
|
647
|
+
questBlacklist.add(e); skipped.push(e);
|
|
648
|
+
out(`${dim(String(i).padStart(3) + '.')} ${yellow(`skipping ${nm(e)}: no way to finish it from here`)}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
632
651
|
lastKind = step.kind;
|
|
633
652
|
if (prog.eliteNeedsSquad && !companionHintShown && !(g.state.squad ?? []).length) {
|
|
634
653
|
companionHintShown = true;
|
|
@@ -638,6 +657,18 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
|
|
|
638
657
|
switch (step.kind) {
|
|
639
658
|
case 'fight': {
|
|
640
659
|
const c = ds.creature(step.creatureEntry);
|
|
660
|
+
// Buff before the pull, then drink back what the buffs cost: casting them on the fight's first ticks
|
|
661
|
+
// sent a mage in at three quarters of its bar. Once per pull, so a buff that falls off while it
|
|
662
|
+
// drinks is recast without another rest.
|
|
663
|
+
const prep = prepOoc(g, ds);
|
|
664
|
+
const buffed = prepText(prep);
|
|
665
|
+
if (buffed && !restedForBuffs && cfg.restMode === 'eat-then-wait' && prep.manaPct < cfg.recoverToPct) {
|
|
666
|
+
restedForBuffs = true; prog.recovering = true; lastKind = 'buff';
|
|
667
|
+
line = `${buffed} ${dim('rest before the pull')}`;
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
restedForBuffs = false;
|
|
671
|
+
if (buffed) out(`${dim(String(i).padStart(3) + '.')} ${buffed}`);
|
|
641
672
|
if (liveFight) {
|
|
642
673
|
const started = liveFight(step, (r) => settle(r, step));
|
|
643
674
|
if (!started.ok) { sets.blockedFights.add(step.creatureEntry); line = red(`fight ${c?.name}: ${started.reason}`); }
|
|
@@ -669,6 +700,7 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
|
|
|
669
700
|
const why = `${step.reason}${step.detail ? ` (${step.detail})` : ''}`;
|
|
670
701
|
// Stuck or beaten on a quest: drop that quest and plan again rather than end the run.
|
|
671
702
|
if (skipping && (step.reason === 'noTargets' || step.reason === 'defeated') && lastQuest != null && !sets.blockedQuests.has(lastQuest) && inLog(lastQuest)) { line = blacklist(lastQuest, step.detail ?? step.reason); break; }
|
|
703
|
+
stopReason = { reason: step.reason, detail: step.detail };
|
|
672
704
|
line = yellow(`autopilot stopped: ${why}`); done = true; break;
|
|
673
705
|
}
|
|
674
706
|
default: line = dim(`unhandled step ${step.kind}`);
|
|
@@ -682,7 +714,16 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
|
|
|
682
714
|
return line ? `${dim(String(i).padStart(3) + '.')} ${line}` : '';
|
|
683
715
|
};
|
|
684
716
|
const summary = () => `Level ${g.state.char.level}, ${moneyText(g.state.char.gold)}, ${zoneHere()?.name}.${skipped.length ? ` Skipped: ${skipped.map(nm).join(', ')}.` : ''}`;
|
|
685
|
-
|
|
717
|
+
// The windowed game reads the rest for its run panel, Run pane and end card: why the run stopped, which
|
|
718
|
+
// quests it dropped, the death streak and the planner's own clock (one second a step, ten a rest).
|
|
719
|
+
return {
|
|
720
|
+
next, steps, summary, mode, skipped,
|
|
721
|
+
get done() { return done; }, get lastKind() { return lastKind; }, get step() { return i; },
|
|
722
|
+
get stopReason() { return stopReason; }, get defeats() { return prog.consecutiveDefeats; }, get elapsedMs() { return prog.elapsedMs; },
|
|
723
|
+
get lastQuest() { return lastQuest; },
|
|
724
|
+
skips: (entry) => questBlacklist.has(entry) || sets.blockedQuests.has(entry),
|
|
725
|
+
questDefeats: (entry) => prog.questDefeats.get(entry) ?? 0,
|
|
726
|
+
};
|
|
686
727
|
}
|
|
687
728
|
|
|
688
729
|
function runAutopilot(args) {
|
package/src/cli/rotationText.mjs
CHANGED
|
@@ -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
|
};
|