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.
- package/README.md +326 -82
- package/bin/azsh.mjs +21 -19
- package/package.json +9 -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 +135 -0
- package/src/cli/iconGrid.mjs +100 -0
- package/src/cli/modelArt.mjs +295 -0
- package/src/cli/paperdoll.mjs +76 -0
- package/src/cli/perfCheck.mjs +56 -0
- package/src/cli/repl.mjs +32 -7
- package/src/cli/rotationText.mjs +6 -6
- package/src/cli/sixel.mjs +307 -0
- package/src/cli/term.mjs +106 -12
- package/src/cli/text.mjs +5 -3
- package/src/cli/tui.mjs +231 -95
- package/src/cli/tuiAutopilot.mjs +347 -0
- package/src/cli/tuiBg.mjs +370 -41
- package/src/cli/tuiCheck.mjs +362 -27
- package/src/cli/tuiCity.mjs +118 -13
- package/src/cli/tuiCompanions.mjs +25 -15
- package/src/cli/tuiCreate.mjs +223 -0
- package/src/cli/tuiDungeon.mjs +1 -1
- package/src/cli/tuiFight.mjs +423 -86
- package/src/cli/tuiFightPanes.mjs +27 -8
- package/src/cli/tuiFloor.mjs +1 -1
- package/src/cli/tuiGear.mjs +10 -6
- package/src/cli/tuiLoot.mjs +1 -0
- package/src/cli/tuiPaperdoll.mjs +107 -0
- package/src/cli/tuiPartyStage.mjs +177 -0
- package/src/cli/tuiProfessions.mjs +4 -2
- package/src/cli/tuiRotationCond.mjs +390 -0
- package/src/cli/tuiSaves.mjs +10 -1
- package/src/cli/tuiSearch.mjs +75 -22
- package/src/cli/tuiStage.mjs +304 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiViews.mjs +284 -110
- package/src/cli/tuiWorld.mjs +2 -2
- 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.bin.gz +0 -0
- package/src/data/rotations/pve/paladin/retribution.json +2 -2
- 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/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 +39 -1
- package/src/engine/combat/procsDamage.ts +31 -1
- package/src/engine/combat/sessionChase.ts +7 -1
- package/src/engine/combat/sessionMotionKit.ts +40 -2
- package/src/quests/quests.ts +60 -0
|
@@ -333,6 +333,28 @@ function wrap(
|
|
|
333
333
|
return { segs, cls: lineCls(type, source, crit), type, source, dest };
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
+
/**
|
|
337
|
+
* One thing happening reads as one line. The engine reports a stance as a form change plus its aura,
|
|
338
|
+
* a multi-effect aura once per effect, and a buff or damageless debuff as its aura plus a 0 spell_hit
|
|
339
|
+
* the meters count as the cast. The log keeps the first aura line and drops the rest.
|
|
340
|
+
*/
|
|
341
|
+
export function dropLogRepeats(events: readonly CombatEvent[]): CombatEvent[] {
|
|
342
|
+
const key = (t: number, unit: Side, name: string, on: boolean) => `${t}|${unit}|${name}|${on}`;
|
|
343
|
+
const auras = new Set<string>();
|
|
344
|
+
for (const ev of events) if (ev.kind === 'aura' && ev.applied) auras.add(key(ev.t, ev.actor, ev.name, true));
|
|
345
|
+
const seen = new Set<string>();
|
|
346
|
+
return events.filter((ev) => {
|
|
347
|
+
if (ev.kind === 'spell_hit') {
|
|
348
|
+
return !(ev.amount === 0 && (ev.result === 'hit' || ev.result === 'crit') && ev.target != null && auras.has(key(ev.t, ev.target, ev.name, true)));
|
|
349
|
+
}
|
|
350
|
+
if (ev.kind !== 'aura' && ev.kind !== 'form') return true;
|
|
351
|
+
const k = key(ev.t, ev.actor, ev.name, ev.kind === 'aura' ? ev.applied : !!ev.form);
|
|
352
|
+
if (seen.has(k)) return false;
|
|
353
|
+
seen.add(k);
|
|
354
|
+
return true;
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
336
358
|
/** Format one combat event into 1.12 combat-log wording. Null = drop the row. */
|
|
337
359
|
export function formatCombatLog(ev: CombatEvent, names: CombatLogNames): FormattedCombatLog | null {
|
|
338
360
|
const who = (a: Side) => whoOf(a, names);
|
package/src/cli/gzLoader.mjs
CHANGED
|
@@ -1,20 +1,62 @@
|
|
|
1
1
|
// Module hooks: an `import x from './t.json' with { type: 'json' }` whose file is missing resolves to
|
|
2
2
|
// `t.json.gz` and is gunzipped on load. Lets every bake table ship gzipped without touching the
|
|
3
3
|
// 42 modules that static-import one. Registered in-thread by src/cli/register.mjs.
|
|
4
|
-
//
|
|
5
|
-
|
|
4
|
+
//
|
|
5
|
+
// Every .ts module is also type-stripped here instead of by Node, with the stripped JS cached on disk
|
|
6
|
+
// under $AZSH_HOME/cache: Node's own stripper runs its wasm over all 900+ engine files on every launch
|
|
7
|
+
// (about a second), and deciding each file's module kind walks up for a package.json; a cache hit skips
|
|
8
|
+
// both. Stripping keeps line positions, so stack traces stay right. Set AZSH_NO_JS_CACHE to bypass.
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
6
10
|
import { stripTypeScriptTypes } from 'node:module';
|
|
7
11
|
import { gunzipSync } from 'node:zlib';
|
|
8
12
|
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const CACHE_DIR = process.env.AZSH_NO_JS_CACHE ? null : join(process.env.AZSH_HOME ?? join(homedir(), '.azsh'), 'cache', 'js');
|
|
18
|
+
let cacheReady = false;
|
|
19
|
+
|
|
20
|
+
/** Stripped source for one .ts file: from the cache when its stamp (size, mtime, Node version) still matches. */
|
|
21
|
+
function stripCached(path) {
|
|
22
|
+
const st = statSync(path);
|
|
23
|
+
const stamp = `// ${process.version} ${st.size} ${Math.floor(st.mtimeMs)}\n`;
|
|
24
|
+
const file = CACHE_DIR && join(CACHE_DIR, `${createHash('sha1').update(path).digest('hex').slice(0, 20)}.mjs`);
|
|
25
|
+
if (file) {
|
|
26
|
+
try {
|
|
27
|
+
const cached = readFileSync(file, 'utf8');
|
|
28
|
+
if (cached.startsWith(stamp)) return cached.slice(stamp.length);
|
|
29
|
+
} catch { /* miss */ }
|
|
30
|
+
}
|
|
31
|
+
// The stripper announces itself as experimental on its first call; that would print over the screen.
|
|
32
|
+
const warn = process.emitWarning;
|
|
33
|
+
process.emitWarning = () => {};
|
|
34
|
+
let source;
|
|
35
|
+
try { source = stripTypeScriptTypes(readFileSync(path, 'utf8')); }
|
|
36
|
+
finally { process.emitWarning = warn; }
|
|
37
|
+
if (file) {
|
|
38
|
+
try {
|
|
39
|
+
if (!cacheReady) { mkdirSync(CACHE_DIR, { recursive: true }); cacheReady = true; }
|
|
40
|
+
const tmp = `${file}.${process.pid}.tmp`; // another launch filling the same entry must not read half a file
|
|
41
|
+
writeFileSync(tmp, stamp + source);
|
|
42
|
+
renameSync(tmp, file);
|
|
43
|
+
} catch { /* a read-only home: strip every launch */ }
|
|
44
|
+
}
|
|
45
|
+
return source;
|
|
46
|
+
}
|
|
9
47
|
|
|
10
48
|
export function resolve(specifier, context, next) {
|
|
11
|
-
if (
|
|
49
|
+
if (!/^\.\.?\//.test(specifier) || !context.parentURL) return next(specifier, context);
|
|
50
|
+
if (specifier.endsWith('.json')) {
|
|
12
51
|
const url = new URL(specifier, context.parentURL);
|
|
13
52
|
const path = fileURLToPath(url);
|
|
14
53
|
if (!existsSync(path) && existsSync(`${path}.gz`)) {
|
|
15
54
|
return { url: `${url.href}.gz`, format: 'json', shortCircuit: true };
|
|
16
55
|
}
|
|
17
56
|
}
|
|
57
|
+
// Node's own resolve labels a .ts file's format by walking up to its package.json, once per file; every
|
|
58
|
+
// engine module is ESM, so say so here and skip the walk. A missing file still fails, in `load`.
|
|
59
|
+
if (specifier.endsWith('.ts')) return { url: new URL(specifier, context.parentURL).href, format: 'module', shortCircuit: true };
|
|
18
60
|
return next(specifier, context);
|
|
19
61
|
}
|
|
20
62
|
|
|
@@ -23,9 +65,8 @@ export function load(url, context, next) {
|
|
|
23
65
|
const source = gunzipSync(readFileSync(fileURLToPath(url))).toString('utf8');
|
|
24
66
|
return { format: 'json', source, shortCircuit: true };
|
|
25
67
|
}
|
|
26
|
-
if (url.endsWith('.ts') && url.
|
|
27
|
-
|
|
28
|
-
return { format: 'module', source, shortCircuit: true };
|
|
68
|
+
if (url.endsWith('.ts') && url.startsWith('file:')) {
|
|
69
|
+
return { format: 'module', source: stripCached(fileURLToPath(url)), shortCircuit: true };
|
|
29
70
|
}
|
|
30
71
|
return next(url, context);
|
|
31
72
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The full-size art sixel draws (sixel.mjs, modelArt.mjs): creature sprites, icons and one gear file per body,
|
|
2
|
+
// about 72 MB, too much to ship to every terminal that cannot draw it. The package carries only the manifest
|
|
3
|
+
// (src/data/hd-art.json: the release tag, and each file's name, size and sha256); the files are assets of that
|
|
4
|
+
// GitHub release, fetched on request into ~/.azsh/art/<tag>/ and checked against the manifest.
|
|
5
|
+
//
|
|
6
|
+
// A checkout that baked them (npm run art:hd) has them in src/data and uses those; AZSH_ART_DIR points
|
|
7
|
+
// anywhere else. scripts/publish-hd-art.mjs writes the manifest and uploads a release.
|
|
8
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { Readable } from 'node:stream';
|
|
14
|
+
|
|
15
|
+
const DATA_DIR = fileURLToPath(new URL('../data/', import.meta.url));
|
|
16
|
+
|
|
17
|
+
let manifest;
|
|
18
|
+
/** { tag, base, files: [{ name, path, size, sha256 }] }, or null when the package has none. */
|
|
19
|
+
export function hdManifest() {
|
|
20
|
+
if (manifest !== undefined) return manifest;
|
|
21
|
+
try { manifest = JSON.parse(readFileSync(join(DATA_DIR, 'hd-art.json'), 'utf8')); } catch { manifest = null; }
|
|
22
|
+
return manifest;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Where downloaded art goes: ~/.azsh/art/<tag>/. */
|
|
26
|
+
export function downloadDir() {
|
|
27
|
+
const m = hdManifest();
|
|
28
|
+
return process.env.AZSH_ART_DIR ?? join(process.env.AZSH_HOME ?? join(homedir(), '.azsh'), 'art', m?.tag ?? 'local');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The file to read for an art path ("model-art-hd.bin.br", "gear-art-hd/orc-male.bin.br"): the download, or a
|
|
32
|
+
* checkout's own bake (unless AZSH_ART_DIR says where the art is). Null when neither is there. */
|
|
33
|
+
export function hdArtPath(path) {
|
|
34
|
+
const got = join(downloadDir(), path);
|
|
35
|
+
if (existsSync(got)) return got;
|
|
36
|
+
if (process.env.AZSH_ART_DIR) return null;
|
|
37
|
+
const baked = join(DATA_DIR, path);
|
|
38
|
+
return existsSync(baked) ? baked : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The manifest's files not on disk yet. */
|
|
42
|
+
export function missingHdArt() {
|
|
43
|
+
const m = hdManifest();
|
|
44
|
+
return m ? m.files.filter((f) => !hdArtPath(f.path)) : [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Bytes still to fetch. */
|
|
48
|
+
export const missingHdBytes = () => missingHdArt().reduce((n, f) => n + f.size, 0);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fetch every missing file from the release. `onProgress(done, total)` in bytes. Each file is written beside
|
|
52
|
+
* its place, hashed as it streams, and moved in only when the sha256 matches. Resolves { ok, reason }.
|
|
53
|
+
*/
|
|
54
|
+
export async function downloadHdArt(onProgress = () => {}) {
|
|
55
|
+
const m = hdManifest();
|
|
56
|
+
if (!m) return { ok: false, reason: 'This build has no art manifest.' };
|
|
57
|
+
const todo = missingHdArt(), total = todo.reduce((n, f) => n + f.size, 0);
|
|
58
|
+
let done = 0;
|
|
59
|
+
for (const f of todo) {
|
|
60
|
+
const dest = join(downloadDir(), f.path), part = `${dest}.part`;
|
|
61
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(`${m.base}${f.name}`);
|
|
64
|
+
if (!res.ok || !res.body) return { ok: false, reason: `${f.name}: HTTP ${res.status}` };
|
|
65
|
+
const hash = createHash('sha256'), out = createWriteStream(part);
|
|
66
|
+
for await (const chunk of Readable.fromWeb(res.body)) {
|
|
67
|
+
hash.update(chunk);
|
|
68
|
+
if (!out.write(chunk)) await new Promise((r) => out.once('drain', r));
|
|
69
|
+
done += chunk.length;
|
|
70
|
+
onProgress(done, total);
|
|
71
|
+
}
|
|
72
|
+
await new Promise((r, j) => out.end((e) => (e ? j(e) : r())));
|
|
73
|
+
if (hash.digest('hex') !== f.sha256) { rmSync(part, { force: true }); return { ok: false, reason: `${f.name} did not match its checksum` }; }
|
|
74
|
+
renameSync(part, dest);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
rmSync(part, { force: true });
|
|
77
|
+
return { ok: false, reason: `${f.name}: ${e.message}` };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { ok: true };
|
|
81
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Spell and item icons for the windowed game, drawn as truecolour half-block cells like the model art
|
|
2
|
+
// (modelArt.mjs): a cell is ▀ with the top pixel in the foreground and the bottom one in the background.
|
|
3
|
+
// The pack src/data/icon-art.bin.gz (scripts/bake-icon-art.mjs) holds every icon the spells, items,
|
|
4
|
+
// talents and professions name at three sizes a pixel each: 8x8 (4 lines, a detail pane), 6x6 (3 lines, a
|
|
5
|
+
// bag grid or an ability bar) and 4x4 (2 lines, a list row or an aura strip). Icons are keyed by the icon
|
|
6
|
+
// name the tables carry (`spell_holy_holysmite`, `inv_ore_truesilver_01`). No pack, or NO_COLOR: no art,
|
|
7
|
+
// never an error.
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { gunzipSync } from 'node:zlib';
|
|
10
|
+
import { useColor, vis, wrapLines } from './text.mjs';
|
|
11
|
+
import { sixelIconCells } from './sixel.mjs';
|
|
12
|
+
|
|
13
|
+
const PACK_URL = new URL('../data/icon-art.bin.gz', import.meta.url);
|
|
14
|
+
const SIZES = [8, 6, 4];
|
|
15
|
+
const BYTES = SIZES.map((g) => g * g * 3);
|
|
16
|
+
const RECORD = BYTES.reduce((a, b) => a + b, 0);
|
|
17
|
+
|
|
18
|
+
let index = null; // name -> byte offset of the 8x8 square
|
|
19
|
+
let pack = null;
|
|
20
|
+
|
|
21
|
+
function load() {
|
|
22
|
+
if (index) return index;
|
|
23
|
+
index = new Map();
|
|
24
|
+
try {
|
|
25
|
+
pack = gunzipSync(readFileSync(PACK_URL));
|
|
26
|
+
if (pack.toString('ascii', 0, 5) !== 'AZICO' || pack[5] !== 1) return index;
|
|
27
|
+
const count = pack.readUInt32LE(6);
|
|
28
|
+
let off = 10;
|
|
29
|
+
for (let i = 0; i < count && off < pack.length; i++) {
|
|
30
|
+
const len = pack[off];
|
|
31
|
+
index.set(pack.toString('ascii', off + 1, off + 1 + len), off + 1 + len);
|
|
32
|
+
off += 1 + len + RECORD;
|
|
33
|
+
}
|
|
34
|
+
} catch { pack = null; }
|
|
35
|
+
return index;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Are icons drawable at all (colour on and the pack present)? Cheap once loaded. */
|
|
39
|
+
export const iconsOn = () => useColor && load().size > 0;
|
|
40
|
+
|
|
41
|
+
/** Is there art for this icon name? */
|
|
42
|
+
export const hasIcon = (name) => !!name && useColor && load().has(String(name).toLowerCase());
|
|
43
|
+
|
|
44
|
+
/** Lines an icon of `cols` pixels a side takes: 4, 3 or 2. */
|
|
45
|
+
export const iconRows = (cols) => cols / 2;
|
|
46
|
+
|
|
47
|
+
const RESET = '\x1b[0m';
|
|
48
|
+
const cache = new Map();
|
|
49
|
+
const CACHE_MAX = 768;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The icon `name` as `cols / 2` rows of `cols` cells, each cell a self-contained painted ▀ (it resets after
|
|
53
|
+
* itself), or null when there is no art. `tone` 'gray' draws it desaturated and dark: a talent you cannot
|
|
54
|
+
* take yet. A grid (iconGrid.mjs) lays cells one by one so it can paint a rank or a count over a corner;
|
|
55
|
+
* where the terminal draws sixel the icon is a picture instead (sixel.mjs), and `holes` ([{ x, y, w }] in
|
|
56
|
+
* cells) are the corners it leaves see-through for that text.
|
|
57
|
+
*/
|
|
58
|
+
export function iconCells(name, cols = 4, tone = '', holes = []) {
|
|
59
|
+
if (!name || !useColor) return null;
|
|
60
|
+
const six = sixelIconCells(name, cols, tone, holes);
|
|
61
|
+
if (six) return six;
|
|
62
|
+
const key = `${String(name).toLowerCase()}|${cols}|${tone}`;
|
|
63
|
+
const hit = cache.get(key);
|
|
64
|
+
if (hit !== undefined) return hit;
|
|
65
|
+
const sz = SIZES.indexOf(cols);
|
|
66
|
+
const off = load().get(String(name).toLowerCase());
|
|
67
|
+
let rows = null;
|
|
68
|
+
if (off != null && sz >= 0) {
|
|
69
|
+
let start = off;
|
|
70
|
+
for (let i = 0; i < sz; i++) start += BYTES[i];
|
|
71
|
+
const px = (k) => {
|
|
72
|
+
const r = pack[k], g = pack[k + 1], b = pack[k + 2];
|
|
73
|
+
if (tone !== 'gray') return `${r};${g};${b}`;
|
|
74
|
+
const l = Math.round((0.3 * r + 0.59 * g + 0.11 * b) * 0.6);
|
|
75
|
+
return `${l};${l};${l}`;
|
|
76
|
+
};
|
|
77
|
+
rows = [];
|
|
78
|
+
for (let y = 0; y < cols; y += 2) {
|
|
79
|
+
const row = [];
|
|
80
|
+
for (let x = 0; x < cols; x++) row.push(`\x1b[38;2;${px(start + (y * cols + x) * 3)}m\x1b[48;2;${px(start + ((y + 1) * cols + x) * 3)}m▀${RESET}`);
|
|
81
|
+
rows.push(row);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value);
|
|
85
|
+
cache.set(key, rows);
|
|
86
|
+
return rows;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The icon `name` as `cols / 2` lines of `cols` half-block cells (cols 8, 6 or 4), or null when there is no
|
|
91
|
+
* art. Each line resets at its end, so it can sit inside any painted text.
|
|
92
|
+
*/
|
|
93
|
+
export function iconArt(name, cols = 4, tone = '') {
|
|
94
|
+
return iconCells(name, cols, tone)?.map((row) => row.join('')) ?? null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A blank the size of an icon, for rows in a list that mixes icons and none. */
|
|
98
|
+
export const iconBlank = (cols = 4) => Array.from({ length: cols / 2 }, () => ' '.repeat(cols));
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `lines` with the icon beside their head: the first `cols / 2` lines get an icon cell row and a space
|
|
102
|
+
* before them (the name, the kind, the first facts), the rest run on at full width. Without art the lines
|
|
103
|
+
* come back as they are. The result remembers its parts (`iconHead`) so a pane can wrap it with
|
|
104
|
+
* wrapIconLines and keep the text beside the icon instead of wrapping under it.
|
|
105
|
+
*/
|
|
106
|
+
export function withIcon(name, lines, cols = 8) {
|
|
107
|
+
const art = iconArt(name, cols);
|
|
108
|
+
if (!art) return lines;
|
|
109
|
+
const out = [];
|
|
110
|
+
for (let i = 0; i < Math.max(art.length, lines.length); i++) out.push(i < art.length ? `${art[i]} ${lines[i] ?? ''}` : lines[i]);
|
|
111
|
+
out.iconHead = { art, lines };
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Detail lines wrapped to `width`. Beside an icon (a withIcon result) the text wraps to the room right of
|
|
117
|
+
* it, so a long first sentence never runs back under the art; once the icon's rows are used the rest of
|
|
118
|
+
* that sentence and every later line take the full width.
|
|
119
|
+
*/
|
|
120
|
+
export function wrapIconLines(lines, width) {
|
|
121
|
+
const wrap = (l, w) => (vis(l) > w ? wrapLines(l, w) : [l]);
|
|
122
|
+
const head = lines?.iconHead;
|
|
123
|
+
if (!head) return (lines ?? []).flatMap((l) => wrap(l, width));
|
|
124
|
+
const { art, lines: text } = head;
|
|
125
|
+
const beside = width - vis(art[0]) - 1;
|
|
126
|
+
const out = [];
|
|
127
|
+
for (const l of text) {
|
|
128
|
+
if (out.length >= art.length) { out.push(...wrap(l, width)); continue; }
|
|
129
|
+
const pieces = wrap(l, beside);
|
|
130
|
+
while (pieces.length && out.length < art.length) out.push(`${art[out.length]} ${pieces.shift()}`);
|
|
131
|
+
if (pieces.length) out.push(...wrap(pieces.join(' '), width));
|
|
132
|
+
}
|
|
133
|
+
while (out.length < art.length) out.push(art[out.length]);
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Icon grids for views that draw their list side as a picture instead of rows: the paperdoll's slots round
|
|
2
|
+
// your body (tuiGear.mjs) and the talent trees' 4x7 nodes (tuiTalents.mjs). A grid is a canvas of one-column
|
|
3
|
+
// cells, each a painted character that resets after itself, so icons, frames, arrows and text can be laid
|
|
4
|
+
// over one another and joined into lines at the end. The view keeps its rows (detail, Enter, row keys all
|
|
5
|
+
// work as in a list); the grid only decides where each row is drawn and where the arrows move the cursor.
|
|
6
|
+
import { iconCells } from './iconArt.mjs';
|
|
7
|
+
|
|
8
|
+
const RESET = '\x1b[0m';
|
|
9
|
+
const SGR_RE = /\x1b\[[0-9;]*m/g;
|
|
10
|
+
|
|
11
|
+
/** A painted string split into one string per visible column, each carrying the paint open at it. A sixel
|
|
12
|
+
* marker (sixel.mjs) is no paint: it rides on the next cell only. */
|
|
13
|
+
export function splitCells(s) {
|
|
14
|
+
const out = [];
|
|
15
|
+
let open = '', mark = '';
|
|
16
|
+
const str = String(s);
|
|
17
|
+
let i = 0;
|
|
18
|
+
while (i < str.length) {
|
|
19
|
+
if (str[i] === '\x1b') {
|
|
20
|
+
SGR_RE.lastIndex = i;
|
|
21
|
+
const m = SGR_RE.exec(str);
|
|
22
|
+
if (m && m.index === i && m[0].startsWith('\x1b[9999;')) { mark += m[0]; i += m[0].length; continue; }
|
|
23
|
+
if (m && m.index === i) { open = m[0] === RESET || m[0] === '\x1b[m' ? '' : open + m[0]; i += m[0].length; continue; }
|
|
24
|
+
}
|
|
25
|
+
out.push(mark + (open ? `${open}${str[i]}${RESET}` : str[i]));
|
|
26
|
+
mark = '';
|
|
27
|
+
i++;
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A blank canvas `w` columns by `h` lines. */
|
|
33
|
+
export function canvas(w, h) {
|
|
34
|
+
const cells = Array.from({ length: Math.max(0, h) }, () => Array(Math.max(0, w)).fill(' '));
|
|
35
|
+
const put = (x, y, c) => { if (y >= 0 && y < h && x >= 0 && x < w) cells[y][x] = c; };
|
|
36
|
+
return {
|
|
37
|
+
w, h, put,
|
|
38
|
+
/** Painted text from (x, y), cut at `max` columns (default: the canvas edge). */
|
|
39
|
+
text(x, y, s, max = w - x) { splitCells(s).slice(0, Math.max(0, max)).forEach((c, k) => put(x + k, y, c)); },
|
|
40
|
+
/** Lines of art (equal widths) with their top-left at (x, y); blank art cells leave the canvas as it is. */
|
|
41
|
+
art(x, y, lines) { lines.forEach((l, dy) => splitCells(l).forEach((c, dx) => { if (c !== ' ') put(x + dx, y + dy, c); })); },
|
|
42
|
+
lines: () => cells.map((r) => r.join('')),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const paint = (codes, ch) => `\x1b[${codes}m${ch}${RESET}`;
|
|
47
|
+
const EMPTY_BG = '48;2;34;34;38';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One framed icon at (x, y): a column of frame each side of an `s`-wide icon (s / 2 lines), so a cell is
|
|
51
|
+
* s + 2 columns. The frame is thin in `frame` (an SGR code: the item's quality, the talent's state) and
|
|
52
|
+
* thick and bright when `selected`. No icon draws a dark slot with `empty` (a short label) on its middle
|
|
53
|
+
* line. `badge` (a rank, a count) sits in the bottom-right corner and `mark` (an upgrade arrow) top-right,
|
|
54
|
+
* each `{ text, codes }` on black.
|
|
55
|
+
*/
|
|
56
|
+
export function drawCell(cv, x, y, s, { icon, tone = '', frame = '90', selected = false, empty = '', badge = null, mark = null } = {}) {
|
|
57
|
+
const rows = s / 2;
|
|
58
|
+
const holes = [badge?.text && { x: s - badge.text.length, y: rows - 1, w: badge.text.length }, mark?.text && { x: s - mark.text.length, y: 0, w: mark.text.length }].filter(Boolean);
|
|
59
|
+
const art = icon ? iconCells(icon, s, tone, holes) : null;
|
|
60
|
+
for (let dy = 0; dy < rows; dy++) {
|
|
61
|
+
cv.put(x, y + dy, selected ? paint('1;97', '▐') : paint(frame, '▕'));
|
|
62
|
+
cv.put(x + s + 1, y + dy, selected ? paint('1;97', '▌') : paint(frame, '▏'));
|
|
63
|
+
for (let dx = 0; dx < s; dx++) cv.put(x + 1 + dx, y + dy, art ? art[dy][dx] : paint(EMPTY_BG, ' '));
|
|
64
|
+
}
|
|
65
|
+
if (!art && empty) {
|
|
66
|
+
const label = empty.slice(0, s);
|
|
67
|
+
const lx = x + 1 + Math.floor((s - label.length) / 2);
|
|
68
|
+
[...label].forEach((ch, k) => cv.put(lx + k, y + Math.floor((rows - 1) / 2), paint(`${EMPTY_BG};38;2;110;110;118`, ch)));
|
|
69
|
+
}
|
|
70
|
+
const corner = (o, yy) => [...o.text].reverse().forEach((ch, k) => cv.put(x + s - k, yy, paint(`48;2;0;0;0;${o.codes}`, ch)));
|
|
71
|
+
if (badge?.text) corner(badge, y + rows - 1);
|
|
72
|
+
if (mark?.text) corner(mark, y);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Where the cursor goes from row `cur` toward `dir` among the drawn points `{ i, x, y, g? }`, or null.
|
|
77
|
+
* Up and down take the nearest point that way scored `|dy| * dyW + |dx| * dxW`, inside the same group `g`
|
|
78
|
+
* when points have one (one talent tree). Left and right take the nearest point that way on the same line,
|
|
79
|
+
* or up to `slack` lines off when that line has none.
|
|
80
|
+
*/
|
|
81
|
+
export function gridStep(pts, cur, dir, { dyW = 1, dxW = 10, slack = 0 } = {}) {
|
|
82
|
+
const at = pts.find((p) => p.i === cur) ?? pts[0];
|
|
83
|
+
if (!at) return null;
|
|
84
|
+
if (at.i !== cur) return at.i;
|
|
85
|
+
let best = null, bestScore = Infinity;
|
|
86
|
+
for (const p of pts) {
|
|
87
|
+
if (p === at) continue;
|
|
88
|
+
const dx = p.x - at.x, dy = p.y - at.y;
|
|
89
|
+
let score;
|
|
90
|
+
if (dir === 'up' || dir === 'down') {
|
|
91
|
+
if ((dir === 'up' ? -dy : dy) <= 0 || (at.g != null && p.g !== at.g)) continue;
|
|
92
|
+
score = Math.abs(dy) * dyW + Math.abs(dx) * dxW;
|
|
93
|
+
} else {
|
|
94
|
+
if ((dir === 'left' ? -dx : dx) <= 0 || Math.abs(dy) > slack) continue;
|
|
95
|
+
score = Math.abs(dy) * 100 + Math.abs(dx);
|
|
96
|
+
}
|
|
97
|
+
if (score < bestScore) { bestScore = score; best = p; }
|
|
98
|
+
}
|
|
99
|
+
return best?.i ?? null;
|
|
100
|
+
}
|