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
package/src/cli/tuiSearch.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// World search (s on the World tab): "where do I find X?"
|
|
1
|
+
// World search (s on the World tab): "where do I find X?" It starts on the zone you stand in (its vendors,
|
|
2
|
+
// trainers, wares, nodes, creatures); a in the window widens it to the whole world. Every place that answers is a
|
|
2
3
|
// row: the vendors that sell an item and the zone each stands in, the creatures that drop it, the node that
|
|
3
4
|
// yields it, who crafts it; an NPC by name and where it is; a dungeon and its entrance; a gather node and the
|
|
4
5
|
// zones it grows in; a zone, a quest, an object. Enter acts on the place: open it when you are already there
|
|
5
6
|
// (the vendor, the fight, the portal, the node), travel there when you are not. The matching is the wiki's
|
|
6
7
|
// (substring, then character subsequence), over the wiki's name index plus a gather-node index of our own.
|
|
7
|
-
import { bold, dim, yellow, green, red, cyan, qualityPaint, secondsText, plural } from './text.mjs';
|
|
8
|
+
import { bold, dim, yellow, green, red, cyan, qualityPaint, secondsText, plural, coinText } from './text.mjs';
|
|
8
9
|
import { wikiIndex, wikiTokens, rowMatches } from './wikiIndex.ts';
|
|
9
10
|
import { wikiSourceIndexSync } from './wikiSources.ts';
|
|
10
11
|
import { wikiPage } from './tuiWiki.mjs';
|
|
@@ -12,7 +13,6 @@ import { itemLines, creatureLines, questLines, travelPreviewLines, travelTo, ven
|
|
|
12
13
|
import { profTrainerDialog } from './tuiProfessions.mjs';
|
|
13
14
|
import { entryDialog } from './tuiDungeon.mjs';
|
|
14
15
|
import { startFight } from './tuiFight.mjs';
|
|
15
|
-
import { moneyText } from '../domain/economy/money.ts';
|
|
16
16
|
import { vendorPriceMultiplierFor } from '../engine/game/vendorOffers.ts';
|
|
17
17
|
import { className } from '../domain/core/ids.ts';
|
|
18
18
|
|
|
@@ -63,6 +63,51 @@ function searchPool(ds) {
|
|
|
63
63
|
return [...ix.all.filter((r) => WORLD_KINDS.has(r.kind) && !(r.kind === 'object' && nodeNames.has(r.lower))), ...pi.nodeRows];
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
const zoneCache = new WeakMap();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What one zone holds: the search pool cut to its NPCs, nodes, objects, dungeon doors and quest givers,
|
|
70
|
+
* plus every item its vendors sell, its nodes yield and its creatures and objects drop, each with the
|
|
71
|
+
* sources here (`items`: item entry → sources, the itemSources shape). Keyed on the wiki index so a
|
|
72
|
+
* rebuilt index (catalogs arriving) rebuilds this too.
|
|
73
|
+
*/
|
|
74
|
+
export function zoneSearchIndex(ds, zoneId) {
|
|
75
|
+
const ix = wikiIndex(ds);
|
|
76
|
+
let byZone = zoneCache.get(ix);
|
|
77
|
+
if (!byZone) zoneCache.set(ix, (byZone = new Map()));
|
|
78
|
+
const hit = byZone.get(zoneId);
|
|
79
|
+
if (hit) return hit;
|
|
80
|
+
const z = ds.zone(zoneId), pi = placeIndex(ds);
|
|
81
|
+
const nodeNames = new Set(pi.nodeRows.map((r) => r.lower));
|
|
82
|
+
const items = new Map();
|
|
83
|
+
const add = (item, src) => {
|
|
84
|
+
const list = items.get(item);
|
|
85
|
+
if (!list) { items.set(item, [src]); return; }
|
|
86
|
+
const same = list.find((s) => s.kind === src.kind && s.ref === src.ref);
|
|
87
|
+
if (!same) list.push(src);
|
|
88
|
+
else if ((src.chance ?? 0) > (same.chance ?? 0)) Object.assign(same, src);
|
|
89
|
+
};
|
|
90
|
+
const loot = (key, holder) => { for (const row of ds.lootTable(key)) add(row.item, { ...holder, chance: Math.abs(row.chance), ...(row.chance < 0 || row.questRequired ? { questOnly: true } : {}) }); };
|
|
91
|
+
for (const e of z?.vendors ?? []) for (const item of ds.vendor(e)?.items ?? []) add(item, { kind: 'vendor', ref: e });
|
|
92
|
+
for (const id of z?.nodes ?? []) for (const y of ds.gatherNode(id)?.yield ?? []) add(y.item, { kind: 'gather', ref: id, zone: zoneId, chance: y.chance });
|
|
93
|
+
for (const e of z?.huntable ?? []) { const c = ds.creature(e); if (c?.lootId) loot(c.lootId, { kind: 'drop', ref: e }); }
|
|
94
|
+
for (const ge of z?.objects ?? []) { const go = ds.gameObject(ge); if (go?.lootId && !nodeNames.has(go.name.toLowerCase())) loot(`go:${go.lootId}`, { kind: 'object', ref: ge }); }
|
|
95
|
+
const isHere = (r) => {
|
|
96
|
+
switch (r.kind) {
|
|
97
|
+
case 'item': return items.has(r.id);
|
|
98
|
+
case 'npc': return (pi.npcZones.get(r.id) ?? []).includes(zoneId);
|
|
99
|
+
case 'node': return r.zones.includes(zoneId);
|
|
100
|
+
case 'object': return (pi.objectZones.get(r.id) ?? []).includes(zoneId);
|
|
101
|
+
case 'dungeon': return ds.instance(r.id)?.portalZone === zoneId;
|
|
102
|
+
case 'quest': return (ds.quest(r.id)?.starterZones ?? []).includes(zoneId);
|
|
103
|
+
default: return false;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const built = { pool: searchPool(ds).filter(isHere), items };
|
|
107
|
+
byZone.set(zoneId, built);
|
|
108
|
+
return built;
|
|
109
|
+
}
|
|
110
|
+
|
|
66
111
|
/**
|
|
67
112
|
* wikiSearch's buckets (exact, prefix, substring, then subsequence only when nothing contains the words),
|
|
68
113
|
* with one change for typos: the loose bucket ranks by how little longer the name is than what was typed,
|
|
@@ -116,7 +161,7 @@ function goTo(app, zoneId, here) {
|
|
|
116
161
|
if (!dest) { app.flashMsg('No road leads there.'); return; }
|
|
117
162
|
const pv = app.g.previewTravel(dest.id);
|
|
118
163
|
if (!app.result(pv)) return;
|
|
119
|
-
app.confirm(`Travel to ${dest.name}? ${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${
|
|
164
|
+
app.confirm(`Travel to ${dest.name}? ${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${coinText(pv.totalCopper)} in fares` : ''}`, () => travelTo(app, dest));
|
|
120
165
|
}
|
|
121
166
|
|
|
122
167
|
/** Enter on a place with several zones: travel to the one you pick, the current zone first. */
|
|
@@ -137,7 +182,7 @@ function travelHint(app, zones) {
|
|
|
137
182
|
const dest = app.ds.zone(zones[0]);
|
|
138
183
|
const pv = dest ? app.g.previewTravel(dest.id) : null;
|
|
139
184
|
if (pv && !pv.ok) return red(pv.reason);
|
|
140
|
-
return dim(`Enter: travel to ${zoneName(app, zones[0])}${pv ? ` (${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${
|
|
185
|
+
return dim(`Enter: travel to ${zoneName(app, zones[0])}${pv ? ` (${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${coinText(pv.totalCopper)}` : ''})` : ''}${zones.length > 1 ? ', or pick another zone' : ''}`);
|
|
141
186
|
}
|
|
142
187
|
|
|
143
188
|
// ---- rows per kind ------------------------------------------------------------------------------------------
|
|
@@ -155,10 +200,10 @@ function npcRow(app, entry, { indent = '', note = '' } = {}) {
|
|
|
155
200
|
const where = dungeon ? `${cyan(dungeon.name)}${dungeon.portalZone ? dim(`, entrance in ${zoneName(app, dungeon.portalZone)}`) : ''}` : zonesLabel(app, zones);
|
|
156
201
|
const paint = hostile && c ? conPaint(ch.level, c.level) : (s) => s;
|
|
157
202
|
const here = () => {
|
|
203
|
+
if (inn) { const i = (app.zone()?.innkeepers ?? []).find((x) => x.entry === entry); if (i) { app.push(innDialog(app, i)); return; } }
|
|
158
204
|
if (v) { app.push(vendorDialog(app, v)); return; }
|
|
159
205
|
if (pt) { app.push(profTrainerDialog(app, pt)); return; }
|
|
160
206
|
if (tr) { app.push(trainerDialog(app, tr)); return; }
|
|
161
|
-
if (inn) { const i = (app.zone()?.innkeepers ?? []).find((x) => x.entry === entry); if (i) app.push(innDialog(app, i)); return; }
|
|
162
207
|
if (hostile && app.zone()?.huntable.includes(entry)) { startFight(app, entry); return; }
|
|
163
208
|
app.flashMsg(`${name} is around here somewhere.`);
|
|
164
209
|
};
|
|
@@ -232,7 +277,7 @@ function questRow(app, q, { indent = '', note = '' } = {}) {
|
|
|
232
277
|
const zones = q.starterZones ?? [];
|
|
233
278
|
const qs = app.g.state.questlog.quests.find((s) => s.entry === q.entry);
|
|
234
279
|
return {
|
|
235
|
-
text: `${indent}${yellow('!')} ${q.title} ${dim(`Quest L${q.questLevel}`)}${note} ${dim('from')} ${q.starterName || '?'}${zones.length ? `, ${zonesLabel(app, zones)}` : ''}`,
|
|
280
|
+
text: `${indent}${yellow('!')} ${conPaint(app.ch.level, q.questLevel)(q.title)} ${dim(`Quest L${q.questLevel}`)}${note} ${dim('from')} ${q.starterName || '?'}${zones.length ? `, ${zonesLabel(app, zones)}` : ''}`,
|
|
236
281
|
detail: () => [...questLines(app, q, qs), '', cyan('Starts in'), ...(zones.length ? zones.map((z) => ` ${zoneName(app, z)}`) : [dim(' unknown')]), '', travelHint(app, zones)],
|
|
237
282
|
act: () => goToOne(app, zones, `Where ${q.title} starts`, () => app.push(questDialog(app, q, qs ? 'log' : 'offer'))),
|
|
238
283
|
};
|
|
@@ -249,12 +294,12 @@ function recipeRow(app, r, { indent = '', note = '' } = {}) {
|
|
|
249
294
|
};
|
|
250
295
|
}
|
|
251
296
|
|
|
252
|
-
/** An item hit: the item line, then every place that gives it, best first. */
|
|
253
|
-
function itemRows(app, entry) {
|
|
297
|
+
/** An item hit: the item line, then every place that gives it, best first. `only` limits it to those sources (a zone search). */
|
|
298
|
+
function itemRows(app, entry, only) {
|
|
254
299
|
const ds = app.ds, it = ds.item(entry);
|
|
255
300
|
if (!it) return [];
|
|
256
301
|
const rows = [{ text: `${qualityPaint(it.quality)(bold(it.name))} ${dim(`Item${it.typeName ? ` ${it.typeName}` : ''}${it.itemLevel ? ` ilvl ${it.itemLevel}` : ''}`)}`, detail: () => [...itemLines(app, it), '', dim('Enter: the wiki page, every source')], act: () => app.push(wikiPage(app, 'item', entry)) }];
|
|
257
|
-
const { sources, more } = wikiSourceIndexSync(ds).itemSources(entry);
|
|
302
|
+
const { sources, more } = only ? { sources: only, more: 0 } : wikiSourceIndexSync(ds).itemSources(entry);
|
|
258
303
|
const here = app.ch.zoneId;
|
|
259
304
|
const pi = placeIndex(ds);
|
|
260
305
|
const nodeNames = new Set(pi.nodeRows.map((r) => r.lower));
|
|
@@ -282,7 +327,7 @@ function itemRows(app, entry) {
|
|
|
282
327
|
if (s.kind === 'vendor') {
|
|
283
328
|
const v = ds.vendor(s.ref);
|
|
284
329
|
if (!v) continue;
|
|
285
|
-
const price = it.buyPrice ? dim(` ${
|
|
330
|
+
const price = it.buyPrice ? dim(` ${coinText(Math.floor(it.buyPrice * vendorPriceMultiplierFor(ds, app.ch, v.entry)))}`) : '';
|
|
286
331
|
rows.push(npcRow(app, s.ref, { indent: `${indent}${dim('sold by')} `, note: price }));
|
|
287
332
|
} else if (s.kind === 'drop' || s.kind === 'skin' || s.kind === 'pickpocket') {
|
|
288
333
|
if (!ds.creature(s.ref)) continue;
|
|
@@ -312,11 +357,11 @@ function itemRows(app, entry) {
|
|
|
312
357
|
return rows;
|
|
313
358
|
}
|
|
314
359
|
|
|
315
|
-
/** The rows for one search hit. */
|
|
316
|
-
function hitRows(app, r) {
|
|
360
|
+
/** The rows for one search hit. `zoneItems` (a zone search) keeps an item to its sources in that zone. */
|
|
361
|
+
function hitRows(app, r, zoneItems) {
|
|
317
362
|
const ds = app.ds;
|
|
318
363
|
switch (r.kind) {
|
|
319
|
-
case 'item': return itemRows(app, r.id);
|
|
364
|
+
case 'item': return itemRows(app, r.id, zoneItems?.get(r.id));
|
|
320
365
|
case 'npc': return [npcRow(app, r.id)];
|
|
321
366
|
case 'zone': { const z = ds.zone(r.id); return z ? [zoneRow(app, z)] : []; }
|
|
322
367
|
case 'dungeon': { const d = ds.instance(r.id); return d ? [dungeonRow(app, d)] : []; }
|
|
@@ -330,24 +375,31 @@ function hitRows(app, r) {
|
|
|
330
375
|
|
|
331
376
|
// ---- the dialog ----------------------------------------------------------------------------------------------
|
|
332
377
|
|
|
333
|
-
/** The search window. `query` starts it filled in. */
|
|
334
|
-
export function worldSearchDialog(app, query = '') {
|
|
335
|
-
const s = { query };
|
|
378
|
+
/** The search window. `query` starts it filled in; `all` searches the whole world instead of the zone you stand in. */
|
|
379
|
+
export function worldSearchDialog(app, query = '', all = false) {
|
|
380
|
+
const s = { query, all };
|
|
381
|
+
const top = () => { view.cursor = 0; view.scroll = 0; };
|
|
336
382
|
const view = {
|
|
337
|
-
id: 'world-search', title: 'Search
|
|
383
|
+
id: 'world-search', title: 'Search', hint: 'Enter: go there / open',
|
|
338
384
|
keys: {
|
|
339
|
-
s: { hint: 'search', fn: (a) => a.prompt('Find in the world: ', (t) => { s.query = t;
|
|
385
|
+
s: { hint: 'search', fn: (a) => a.prompt(s.all ? 'Find in the world: ' : `Find in ${zoneName(a, a.ch.zoneId)}: `, (t) => { s.query = t; top(); a.render(); }, s.query) },
|
|
386
|
+
a: { hint: 'this zone / all', fn: (a) => { s.all = !s.all; top(); a.flashMsg(s.all ? 'Searching the whole world.' : `Searching ${zoneName(a, a.ch.zoneId)} only.`); } },
|
|
340
387
|
w: { hint: 'wiki', fn: (a, row) => { if (row?.data?.entry) a.push(wikiPage(a, 'npc', row.data.entry)); else a.flashMsg('Open the wiki with /.'); } },
|
|
341
388
|
},
|
|
342
389
|
rows(a) {
|
|
343
|
-
const
|
|
390
|
+
const zone = zoneName(a, a.ch.zoneId);
|
|
391
|
+
view.title = s.all ? 'Search the world' : `Search ${zone}`;
|
|
392
|
+
const scope = s.all ? dim(' (whole world, a: this zone only)') : dim(` (in ${zone}, a: search everywhere)`);
|
|
393
|
+
const rows = [H(s.query ? `Find: ${yellow(s.query)}${scope}` : dim(`Press s and type a name: ${s.all ? 'an item, a vendor, a creature, a dungeon, an ore or herb, a zone...' : `a vendor, a trainer, an item sold or dropped in ${zone}...`}`)), H('')];
|
|
344
394
|
if (!s.query.trim()) {
|
|
345
|
-
rows.push(H(dim(' "mining pick" lists who sells one and where they stand; "copper vein" the zones it is mined in;')), H(dim(' "deadmines" the entrance; "hogger" where he prowls. Enter travels there, or opens it when you are there.')));
|
|
395
|
+
if (s.all) rows.push(H(dim(' "mining pick" lists who sells one and where they stand; "copper vein" the zones it is mined in;')), H(dim(' "deadmines" the entrance; "hogger" where he prowls. Enter travels there, or opens it when you are there.')));
|
|
396
|
+
else rows.push(H(dim(` Only what is in ${zone}: its people, its wares, its nodes and creatures. Enter opens it.`)), H(dim(' a searches the whole world instead.')));
|
|
346
397
|
return rows;
|
|
347
398
|
}
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
399
|
+
const zi = s.all ? null : zoneSearchIndex(a.ds, a.ch.zoneId);
|
|
400
|
+
const hits = worldSearch(zi ? zi.pool : searchPool(a.ds), wikiTokens(s.query));
|
|
401
|
+
if (!hits.length) { rows.push(H(dim(s.all ? ' Nothing by that name. The wiki (/) searches spells and talents too.' : ` Nothing by that name in ${zone}. Press a to search the whole world.`))); return rows; }
|
|
402
|
+
for (const r of hits.slice(0, MAX_HITS)) rows.push(...hitRows(a, r, zi?.items));
|
|
351
403
|
if (hits.length > MAX_HITS) rows.push(H(''), H(dim(` ${hits.length - MAX_HITS} more matches. Narrow the search.`)));
|
|
352
404
|
return rows;
|
|
353
405
|
},
|
|
@@ -355,9 +407,9 @@ export function worldSearchDialog(app, query = '') {
|
|
|
355
407
|
return view;
|
|
356
408
|
}
|
|
357
409
|
|
|
358
|
-
/** s on the World tab: open the search and start typing. */
|
|
359
|
-
export function openWorldSearch(app, query = '') {
|
|
360
|
-
const v = worldSearchDialog(app, query);
|
|
410
|
+
/** s on the World tab: open the search on the zone you stand in and start typing (`all`: the whole world). */
|
|
411
|
+
export function openWorldSearch(app, query = '', all = false) {
|
|
412
|
+
const v = worldSearchDialog(app, query, all);
|
|
361
413
|
app.push(v);
|
|
362
414
|
if (!query) v.keys.s.fn(app);
|
|
363
415
|
}
|
package/src/cli/tuiSettings.mjs
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
// disenchanting, the junk rules (what gets sold or destroyed on its own) and which gathering nodes the autopilot
|
|
3
3
|
// takes. Enter flips or cycles a row. "Every character" settings live in ~/.azsh/prefs.json through the
|
|
4
4
|
// createGame hooks; "this character" settings live in the save.
|
|
5
|
-
import { bold, dim, yellow, green, red, cyan, qualityPaint } from './text.mjs';
|
|
5
|
+
import { bold, dim, yellow, green, red, cyan, qualityPaint, coinText } from './text.mjs';
|
|
6
6
|
import { autoLootOn } from './tuiLoot.mjs';
|
|
7
|
-
import { moneyText } from '../domain/economy/money.ts';
|
|
8
7
|
import { JUNK_PROTECT_CATEGORIES } from '../domain/items/junkCategories.ts';
|
|
9
8
|
import { autopilotSettingsDialog } from './tuiAutopilotSettings.mjs';
|
|
10
9
|
import { paceLabel, nextPace } from './tuiWorld.mjs';
|
|
@@ -81,7 +80,7 @@ export function settingsDialog(app) {
|
|
|
81
80
|
const eh = g.equipmentHelperConfig();
|
|
82
81
|
rows.push(H(''), H(bold('Equipment helper')));
|
|
83
82
|
rows.push({ text: `Helper ${onOff(eh.enabled)}`, detail: () => [bold('Equipment helper'), '', 'Scores what you pick up against what you wear and marks upgrades with ▲.'], act: (a2) => g.setEquipmentHelperConfig({ ...eh, enabled: !eh.enabled }) });
|
|
84
|
-
rows.push({ text: `Upgrades ${eh.mode === 'auto' ? green('put on as they drop') : cyan('suggest only')}`, detail: () => [bold('Upgrades'), '', 'Suggest: ▲ on the
|
|
83
|
+
rows.push({ text: `Upgrades ${eh.mode === 'auto' ? green('put on as they drop') : cyan('suggest only')}`, detail: () => [bold('Upgrades'), '', 'Suggest: ▲ on the Character and Bags tabs, u puts them on.', 'Put on: strict upgrades are worn the moment they land in your bags.'], act: () => g.setEquipmentHelperConfig({ ...eh, mode: eh.mode === 'auto' ? 'suggest' : 'auto' }) });
|
|
85
84
|
|
|
86
85
|
const deMine = g.characterAutoDisenchantConfig(), deAll = g.globalAutoDisenchantConfig();
|
|
87
86
|
rows.push(H(''), H(bold('Disenchant on its own')));
|
|
@@ -110,7 +109,7 @@ export function settingsDialog(app) {
|
|
|
110
109
|
const n = jf[key].length;
|
|
111
110
|
rows.push({ text: `${label.padEnd(26)}${n ? `${n} item${n === 1 ? '' : 's'}` : dim('none')}`, detail: () => [bold(label), '', ...(n ? jf[key].map((e) => a.itemLabel(e)) : [dim('Empty. m on a bag item adds to it.')]), '', dim('Enter: edit')], act: (a2) => a2.push(junkListDialog(a2, key, label)) });
|
|
112
111
|
}
|
|
113
|
-
rows.push({ text: green('Clear out junk now'), detail: () => [bold('Clear out junk now'), '', 'Sells (when a vendor is open or the rule allows) and destroys what the rules say, once.'], act: (a2) => { const r = g.runAutoJunk(); if (r.sold || r.destroyed) a2.say(green(`Junk: ${r.sold} sold${r.earned ? ` for ${
|
|
112
|
+
rows.push({ text: green('Clear out junk now'), detail: () => [bold('Clear out junk now'), '', 'Sells (when a vendor is open or the rule allows) and destroys what the rules say, once.'], act: (a2) => { const r = g.runAutoJunk(); if (r.sold || r.destroyed) a2.say(green(`Junk: ${r.sold} sold${r.earned ? ` for ${coinText(r.earned)}` : ''}, ${r.destroyed} destroyed.`)); else a2.flashMsg('Nothing in your bags is junk by these rules.'); } });
|
|
114
113
|
|
|
115
114
|
const gf = g.gatherFilterConfig();
|
|
116
115
|
rows.push(H(''), H(`${bold('Gathering')} ${dim('which nodes the autopilot mines, picks and skins, by skill colour')}`));
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// The stage under a fight: a canvas of W x H cells that sprites, plates, numbers and bars are drawn onto
|
|
2
|
+
// at a column and a row, instead of a list of frames. The duel (tuiFight duelStage) places you down the
|
|
3
|
+
// left and the foe down the right; the dungeon room and the pack are meant to be more actor lists through
|
|
4
|
+
// the same canvas. Sizes come from the design page: a sprite's box says its rank, and the terminal's
|
|
5
|
+
// width says which boxes it can afford.
|
|
6
|
+
import { bold, dim, cyan, yellow, green, red, gray, inverse, fit, vis, cut, bar, brightBlue, orange, useColor, strip, itemText, coinText } from './text.mjs';
|
|
7
|
+
import { className } from '../domain/core/ids.ts';
|
|
8
|
+
import { iconArt, iconsOn } from './iconArt.mjs';
|
|
9
|
+
import { sixelOn } from './sixel.mjs';
|
|
10
|
+
|
|
11
|
+
const RESET = '\x1b[0m';
|
|
12
|
+
|
|
13
|
+
export const POWER_PAINT = { mana: brightBlue, rage: red, energy: yellow, focus: yellow };
|
|
14
|
+
export const POP_MS = 1500; // a hit or heal stays on screen this long (fight time)
|
|
15
|
+
export const POP_RISE_MS = 400; // a pop climbs one row every this long
|
|
16
|
+
export const LUNGE_MS = 250; // an auto-attack swing moves the sprite two columns toward its target this long
|
|
17
|
+
export const SHORT_BUFF_MS = 60_000; // a frame shows only buffs about to run out; the long ones are under s on the Character tab
|
|
18
|
+
export const ROLE_GLYPH = { tank: cyan('T'), healer: green('H'), damage: red('D') };
|
|
19
|
+
|
|
20
|
+
/** Sprite boxes, columns x rows: a pack add or a pet, dungeon trash, a normal foe, you or an elite, a boss. */
|
|
21
|
+
export const SIZES = { xs: [10, 5], s: [14, 7], m: [20, 10], l: [28, 14], xl: [40, 22] };
|
|
22
|
+
|
|
23
|
+
/** The box your own sprite gets at this width: large from 140 columns, medium from 110, a bust under that. */
|
|
24
|
+
export const youSize = (W) => (W >= 140 ? 'l' : W >= 110 ? 'm' : 'xs');
|
|
25
|
+
|
|
26
|
+
/** The foe's box by creature rank (0 normal, 1 elite, 2 rare elite, 3 boss, 4 rare) and the width:
|
|
27
|
+
* a normal foe is medium, an elite or rare large, a boss the largest thing on screen. */
|
|
28
|
+
export function foeSize(W, rank = 0) {
|
|
29
|
+
const byRank = rank === 3 ? 'xl' : rank > 0 ? 'l' : 'm';
|
|
30
|
+
if (W >= 140) return byRank;
|
|
31
|
+
if (W >= 110) return byRank === 'xl' ? 'l' : byRank;
|
|
32
|
+
return 'm';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The word a rank prints after the level on a plate; nothing for a normal creature. */
|
|
36
|
+
export const RANK_WORD = { 1: 'elite', 2: 'rare elite', 3: 'boss', 4: 'rare' };
|
|
37
|
+
|
|
38
|
+
/** A blank canvas: H lines of W spaces. */
|
|
39
|
+
export const canvas = (W, H) => Array.from({ length: Math.max(0, H) }, () => ' '.repeat(W));
|
|
40
|
+
|
|
41
|
+
/** The painted string without its first `n` visible characters; the colour codes among them stay, so the
|
|
42
|
+
* rest keeps the colour it was in. */
|
|
43
|
+
export function dropVisible(s, n) {
|
|
44
|
+
let i = 0, seen = 0;
|
|
45
|
+
while (i < s.length && seen < n) {
|
|
46
|
+
if (s[i] === '\x1b') { const m = s.slice(i).match(/^\x1b\[[0-9;]*m/); if (m) { i += m[0].length; continue; } }
|
|
47
|
+
i++; seen++;
|
|
48
|
+
}
|
|
49
|
+
return s.slice(i);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `line` with `text` drawn over it from visible column `x`. The line keeps its width: text past the
|
|
53
|
+
* right edge is cut, and a negative x cuts the text's left. Half-block art cells are self-contained
|
|
54
|
+
* (each resets), so drawing over a sprite leaves the cells around the text intact. */
|
|
55
|
+
export function overlay(line, x, text) {
|
|
56
|
+
const w = vis(line);
|
|
57
|
+
if (x < 0) { text = dropVisible(text, -x); x = 0; }
|
|
58
|
+
const tw = Math.min(vis(text), w - x);
|
|
59
|
+
if (tw <= 0) return line;
|
|
60
|
+
if (vis(text) > tw) text = cut(text, tw);
|
|
61
|
+
const head = cut(line, x), pad = ' '.repeat(Math.max(0, x - vis(head)));
|
|
62
|
+
const tail = dropVisible(line, x + tw);
|
|
63
|
+
return `${head}${pad}${text}${useColor ? RESET : ''}${tail}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Draw `text` at (x, y) on the canvas; nothing when y is off it. */
|
|
67
|
+
export function put(c, x, y, text) {
|
|
68
|
+
if (y < 0 || y >= c.length || !text || !Number.isFinite(x)) return;
|
|
69
|
+
c[y] = overlay(c[y], x, text);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Draw `lines` down from (x, y). */
|
|
73
|
+
export function putLines(c, x, y, lines) {
|
|
74
|
+
for (let i = 0; i < lines.length; i++) put(c, x, y + i, lines[i]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Draw `text` so that its right edge sits at column `right`. */
|
|
78
|
+
export function putRight(c, right, y, text) {
|
|
79
|
+
put(c, right - vis(text), y, text);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Draw `text` centred on column `mid`. */
|
|
83
|
+
export function putCentred(c, mid, y, text) {
|
|
84
|
+
put(c, Math.round(mid - vis(text) / 2), y, text);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The ground line: a dark run of low blocks with a bump every so often, so the stage has a floor. */
|
|
88
|
+
export function floorLine(W, paint) {
|
|
89
|
+
if (process.env.AZSH_ASCII) return paint('_'.repeat(W));
|
|
90
|
+
let s = '';
|
|
91
|
+
for (let x = 0; x < W; x++) s += (x * 7) % 23 === 5 ? '▂' : '▁';
|
|
92
|
+
return paint(s);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- plates, casts and auras: what hangs under a sprite on any stage, and what a frame row reuses --------
|
|
96
|
+
|
|
97
|
+
/** A unit's plate: name, level and class or rank (the faced foe marked); HP; power. `small` keeps to the
|
|
98
|
+
* first two. Every line is `plateW` wide. */
|
|
99
|
+
export function plateLines(app, v, { isPlayer, focused, friend, small, dead, barW, plateW, rank = 0 }) {
|
|
100
|
+
const paint = dead ? gray : isPlayer ? green : friend ? cyan : focused ? red : yellow;
|
|
101
|
+
const tag = isPlayer ? dim(className(app.ch.cls)) : RANK_WORD[rank] ? orange(RANK_WORD[rank]) : '';
|
|
102
|
+
let head = `${paint(bold(v.name))} ${dim(`L${v.level}`)}${tag ? ` ${tag}` : ''}${dead ? gray(' dead') : ''}`;
|
|
103
|
+
head = focused && !dead ? `${fit(head, plateW - 10)} ${dim('◀ target')}` : fit(head, plateW);
|
|
104
|
+
const hp = `${bar(v.hp, v.maxHp, barW, dead ? gray : v.hp / Math.max(1, v.maxHp) < 0.25 ? red : green)} ${(dead ? gray : (t) => t)(String(v.hp))}${dim(`/${v.maxHp}`)}`;
|
|
105
|
+
if (small) return [head, fit(hp, plateW)];
|
|
106
|
+
const pp = POWER_PAINT[v.powerType] ?? cyan;
|
|
107
|
+
const power = v.maxPower > 0 && !dead ? `${bar(v.power, v.maxPower, barW, pp)} ${pp(String(v.power))}${v.powerType !== 'mana' ? dim(` ${v.powerType}`) : ''}` : '';
|
|
108
|
+
return [head, fit(hp, plateW), fit(power, plateW)];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A number in up to four cells: 999, 1.2k, 12k, 123k. */
|
|
112
|
+
export function shortNum(n) {
|
|
113
|
+
n = Math.round(n);
|
|
114
|
+
if (n < 1000) return String(n);
|
|
115
|
+
if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
|
|
116
|
+
return `${Math.round(n / 1000)}k`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The narrow plate of a crowded stage, `w` wide: the name (a party member's role letter first, the faced foe
|
|
121
|
+
* red), the HP bar with its percent, the power bar with its figure (blank for a unit without power or dead).
|
|
122
|
+
* The bars are what the plate is for, so nothing else squeezes in; the level and the rank go when `w` is short.
|
|
123
|
+
*/
|
|
124
|
+
export function narrowPlate(app, v, { isPlayer, focused, friend, dead, role, rank = 0, w }) {
|
|
125
|
+
const paint = dead ? gray : isPlayer ? green : friend ? cyan : focused ? red : yellow;
|
|
126
|
+
const tag = friend || isPlayer ? `${ROLE_GLYPH[role] ?? dim('·')} ` : '';
|
|
127
|
+
const after = dead ? '' : focused ? red(' ◀') : RANK_WORD[rank] && w >= 18 ? ` ${orange(RANK_WORD[rank])}` : w >= 16 ? ` ${dim(`L${v.level}`)}` : '';
|
|
128
|
+
const room = w - vis(tag) - vis(after);
|
|
129
|
+
const head = `${tag}${paint(bold(cut(v.name, Math.max(1, room))))}${after}`;
|
|
130
|
+
const hp = `${bar(v.hp, v.maxHp, w - 4, dead ? gray : v.hp / Math.max(1, v.maxHp) < 0.25 ? red : green)}${(dead ? gray : (t) => t)(fit(`${Math.round((100 * v.hp) / Math.max(1, v.maxHp))}%`, 4, true))}`;
|
|
131
|
+
const pp = POWER_PAINT[v.powerType] ?? cyan;
|
|
132
|
+
const power = v.maxPower > 0 && !dead ? `${bar(v.power, v.maxPower, w - 5, pp)}${pp(fit(shortNum(v.power), 5, true))}` : '';
|
|
133
|
+
return [fit(head, w), fit(hp, w), fit(power, w)];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The state row of a narrow plate, `w` wide: a cast bar that fills under the spell's name (a channel drains
|
|
137
|
+
* it), else crowd control, the cast just done (castSlot), stealth, or "dead"; empty when idle. */
|
|
138
|
+
export function narrowState(v, now, w, recent) {
|
|
139
|
+
if (v.hp <= 0) return gray('dead');
|
|
140
|
+
if (v.casting) {
|
|
141
|
+
const { prog, left } = castProgress(v.casting, now);
|
|
142
|
+
const secs = w >= 14 ? ` ${(left / 1000).toFixed(1)}s` : '';
|
|
143
|
+
const text = fit(cut(v.casting.name, w - secs.length), w - secs.length);
|
|
144
|
+
const filled = Math.round(prog * vis(text));
|
|
145
|
+
return `${inverse(yellow(text.slice(0, filled)))}${yellow(text.slice(filled))}${dim(secs)}`;
|
|
146
|
+
}
|
|
147
|
+
if (v.controlled) return red(cut(v.controlled.control, w));
|
|
148
|
+
const last = freshCast(recent, now);
|
|
149
|
+
if (last) return cyan(bold(cut(last.name, w)));
|
|
150
|
+
if (v.stealthed) return dim('stealthed');
|
|
151
|
+
return '';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The aura strip under a plate: short buffs then debuffs in the order they first showed up. With icons
|
|
155
|
+
* three rows (the icon's two, then its time or stacks); without, one row of names. */
|
|
156
|
+
export function auraRows(app, mem, v, key, plateW, icons) {
|
|
157
|
+
if (!icons) return [cut(auraText(app, mem, v, key), plateW)];
|
|
158
|
+
const order = mem.auraOrder.get(key) ?? new Map();
|
|
159
|
+
mem.auraOrder.set(key, order);
|
|
160
|
+
const placed = (list) => {
|
|
161
|
+
for (const a of list) if (!order.has(a.spellId)) order.set(a.spellId, order.size);
|
|
162
|
+
return [...list].sort((a, b) => order.get(a.spellId) - order.get(b.spellId));
|
|
163
|
+
};
|
|
164
|
+
const short = v.buffs.filter((a) => a.remainingMs != null && a.remainingMs < SHORT_BUFF_MS);
|
|
165
|
+
const all = [...placed(short).slice(0, 6).map((a) => [a, green]), ...placed(v.debuffs).slice(0, 6).map((a) => [a, red])].slice(0, Math.floor((plateW + 1) / 5));
|
|
166
|
+
const rows = ['', '', ''];
|
|
167
|
+
for (const [a, paint] of all) {
|
|
168
|
+
const sp = app.ds.spell(a.spellId);
|
|
169
|
+
const art = iconArt(sp?.icon, 4);
|
|
170
|
+
const name = sp?.name ?? String(a.spellId);
|
|
171
|
+
rows[0] += `${art ? art[0] : paint(fit(name, 4))} `;
|
|
172
|
+
rows[1] += `${art ? art[1] : paint(fit(name.slice(4), 4))} `;
|
|
173
|
+
rows[2] += `${a.stacks > 1 ? yellow(fit(`x${a.stacks}`, 4)) : paint(fit(a.remainingMs != null ? auraTime(a.remainingMs).trim() : '', 4))} `;
|
|
174
|
+
}
|
|
175
|
+
return rows;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const CAST_NAME_MS = 2000; // a cast just done keeps its name in the cast slot this long (fight time)
|
|
179
|
+
export const CAST_TRAIL_MS = 8000; // and its icon in the trail beside it this long, greyed for the second half
|
|
180
|
+
|
|
181
|
+
/** The cast bar's progress and seconds left, 0..1. */
|
|
182
|
+
function castProgress(casting, now) {
|
|
183
|
+
const total = Math.max(1, casting.endsAt - casting.startedAt);
|
|
184
|
+
const left = Math.max(0, casting.endsAt - now);
|
|
185
|
+
return { prog: Math.max(0, Math.min(1, casting.channeled ? left / total : 1 - left / total)), left };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The last cast while its icon is still in the trail, with `fresh` set while it keeps its name lit. `recent`
|
|
189
|
+
* is the unit's casts, newest first ({ t, spellId, name, instant }, noteCasts in tuiFight). */
|
|
190
|
+
function lastCast(recent, now) {
|
|
191
|
+
const r = recent?.[0];
|
|
192
|
+
return r && now - r.t < CAST_TRAIL_MS ? { ...r, fresh: now - r.t < CAST_NAME_MS } : null;
|
|
193
|
+
}
|
|
194
|
+
const freshCast = (recent, now) => { const r = lastCast(recent, now); return r?.fresh ? r : null; };
|
|
195
|
+
|
|
196
|
+
/** The cast slot's text: a cast bar, else crowd control, else the cast just done (an instant says so, the
|
|
197
|
+
* casts before it trail after it), else stealth, else the last cast gone dim; empty when idle. */
|
|
198
|
+
export function castSlot(v, now, recent) {
|
|
199
|
+
if (v.casting) {
|
|
200
|
+
const { prog, left } = castProgress(v.casting, now);
|
|
201
|
+
return `${fit(yellow(v.casting.name), 14)} ${bar(prog, 1, 10, yellow)} ${dim(`${(left / 1000).toFixed(1)}s`)}`;
|
|
202
|
+
}
|
|
203
|
+
if (v.controlled) return red(v.controlled.control);
|
|
204
|
+
const last = lastCast(recent, now);
|
|
205
|
+
if (!last || (!last.fresh && v.stealthed)) return v.stealthed ? dim('stealthed') : '';
|
|
206
|
+
const before = recent.slice(1).filter((r) => now - r.t < CAST_TRAIL_MS).map((r) => r.name);
|
|
207
|
+
const trail = before.length ? dim(` ‹ ${before.join(' ‹ ')}`) : '';
|
|
208
|
+
return last.fresh ? `${cyan(bold(last.name))}${last.instant ? dim(' instant') : ''}${trail}` : `${dim(last.name)}${trail}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The cast slot two rows tall and `w` wide, where icons draw: the spell's icon, its name beside it and under
|
|
213
|
+
* the name the cast bar (a cast in progress) or "instant" (one just gone off, for CAST_NAME_MS; after that the
|
|
214
|
+
* name stays dim). The icons of the casts before it trail off to the right, newest first, grey once they are
|
|
215
|
+
* half way out, like an aura strip of what you pressed. Crowd control and stealth take the lead as castSlot has them.
|
|
216
|
+
*/
|
|
217
|
+
export function castRows(app, v, recent, now, w) {
|
|
218
|
+
const spell = (id) => app.ds.spell(id);
|
|
219
|
+
let lead = null, top = '', under = '';
|
|
220
|
+
const last = lastCast(recent, now);
|
|
221
|
+
if (v.casting) {
|
|
222
|
+
const { prog, left } = castProgress(v.casting, now);
|
|
223
|
+
lead = { spellId: v.casting.spellId, since: v.casting.startedAt };
|
|
224
|
+
top = yellow(v.casting.name);
|
|
225
|
+
under = `${bar(prog, 1, 10, yellow)} ${dim(`${(left / 1000).toFixed(1)}s`)}`;
|
|
226
|
+
} else if (v.controlled) top = red(v.controlled.control);
|
|
227
|
+
else if (last && (last.fresh || !v.stealthed)) {
|
|
228
|
+
lead = { spellId: last.spellId, since: last.t, gray: now - last.t > CAST_TRAIL_MS / 2 };
|
|
229
|
+
top = !last.fresh ? dim(last.name) : now - last.t > CAST_NAME_MS / 2 ? cyan(last.name) : cyan(bold(last.name));
|
|
230
|
+
under = last.fresh && last.instant ? dim('instant') : '';
|
|
231
|
+
} else if (v.stealthed) top = dim('stealthed');
|
|
232
|
+
const nameW = Math.min(16, Math.max(8, w - 5));
|
|
233
|
+
const art = lead ? iconArt(spell(lead.spellId)?.icon, 4, lead.gray ? 'gray' : '') : null;
|
|
234
|
+
const rows = art ? [`${art[0]} ${fit(cut(top, nameW), nameW)}`, `${art[1]} ${fit(cut(under, nameW), nameW)}`] : [fit(cut(top, nameW + 5), nameW + 5), fit(cut(under, nameW + 5), nameW + 5)];
|
|
235
|
+
// The trail: what went off before the lead (a channel is in `recent` from its first tick, so it is the lead,
|
|
236
|
+
// not its own trail).
|
|
237
|
+
const room = Math.floor((w - vis(rows[0]) - 1) / 5);
|
|
238
|
+
const trail = (recent ?? []).filter((r) => now - r.t < CAST_TRAIL_MS && !(lead && r.spellId === lead.spellId && r.t >= lead.since)).slice(0, Math.max(0, room));
|
|
239
|
+
for (const r of trail) {
|
|
240
|
+
const old = now - r.t > CAST_TRAIL_MS / 2;
|
|
241
|
+
const ic = iconArt(spell(r.spellId)?.icon, 4, old ? 'gray' : '');
|
|
242
|
+
rows[0] += ` ${ic ? ic[0] : dim(fit(r.name, 4))}`;
|
|
243
|
+
rows[1] += ` ${ic ? ic[1] : dim(fit(r.name.slice(4), 4))}`;
|
|
244
|
+
}
|
|
245
|
+
return rows.every((l) => !strip(l).trim()) ? ['', ''] : rows;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Short buffs green then debuffs red, in the order they first showed up, so a refreshed one keeps its place. */
|
|
249
|
+
export function auraText(app, mem, v, key) {
|
|
250
|
+
const order = mem.auraOrder.get(key) ?? new Map();
|
|
251
|
+
mem.auraOrder.set(key, order);
|
|
252
|
+
const placed = (list) => {
|
|
253
|
+
for (const a of list) if (!order.has(a.spellId)) order.set(a.spellId, order.size);
|
|
254
|
+
return [...list].sort((a, b) => order.get(a.spellId) - order.get(b.spellId));
|
|
255
|
+
};
|
|
256
|
+
const aura = (a, paint) => paint(`${app.ds.spell(a.spellId)?.name ?? a.spellId}${a.stacks > 1 ? ` x${a.stacks}` : ''}${a.remainingMs != null ? dim(` ${auraTime(a.remainingMs)}`) : ''}`);
|
|
257
|
+
const short = v.buffs.filter((a) => a.remainingMs != null && a.remainingMs < SHORT_BUFF_MS);
|
|
258
|
+
return [...placed(short).slice(0, 6).map((a) => aura(a, green)), ...placed(v.debuffs).slice(0, 6).map((a) => aura(a, red))].join(' ');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Aura time left at a fixed width: " 9s", " 12s", " 30m". */
|
|
262
|
+
export function auraTime(ms) {
|
|
263
|
+
const s = Math.ceil(ms / 1000);
|
|
264
|
+
return (s < 60 ? `${s}s` : `${Math.ceil(s / 60)}m`).padStart(3);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---- what moves over a stage ------------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The engine's missiles in flight (spells with a travel speed: a Fireball, an arrow; never a strike or a
|
|
271
|
+
* seal): a glyph with a two-dot trail crossing from the caster's column to the target's, the spell's name
|
|
272
|
+
* over it. A column is `{ key, isPlayer, x, plateW, art, ax, aw }`; `laneRow` is the row the glyphs fly on.
|
|
273
|
+
*/
|
|
274
|
+
export function putMissiles(c, s, cols, now, laneRow, spellName) {
|
|
275
|
+
const colOf = (side) => cols.find((x) => x.key === side || (side === 'player' && x.isPlayer));
|
|
276
|
+
for (const m of s.missilesInFlight?.() ?? []) {
|
|
277
|
+
if (now < m.launchAt || m.landAt <= m.launchAt) continue;
|
|
278
|
+
const from = colOf(m.casterSide), to = colOf(m.targetSide);
|
|
279
|
+
if (!from || !to || from === to) continue;
|
|
280
|
+
const edge = (col, side) => (side === 'r' ? (col.art ? col.ax + col.aw : col.x + col.plateW) + 1 : (col.art ? col.ax : col.x) - 1);
|
|
281
|
+
const rightward = from.x < to.x;
|
|
282
|
+
const a = edge(rightward ? from : to, 'r'), b = edge(rightward ? to : from, 'l');
|
|
283
|
+
const glyph = rightward ? `${dim('· ·')} ${yellow(bold('✦'))}` : `${yellow(bold('✦'))} ${dim('· ·')}`;
|
|
284
|
+
const run = Math.max(0, b - a - vis(glyph)), step = Math.floor(Math.min(1, (now - m.launchAt) / (m.landAt - m.launchAt)) * run);
|
|
285
|
+
const x = rightward ? a + step : b - vis(glyph) - step;
|
|
286
|
+
put(c, x, laneRow, glyph);
|
|
287
|
+
const name = spellName(m.spellId);
|
|
288
|
+
if (name && laneRow > 0) put(c, x + (rightward ? 2 : 0), laneRow - 1, dim(cut(name, Math.max(0, b - a))));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The hits and heals still up on a column's unit, rising one row per POP_RISE_MS above its head, centred on
|
|
293
|
+
* the sprite (above the plate when it has none); the older half of a pop's life is dim. */
|
|
294
|
+
export function putPopsAbove(c, col, pops, now, zone) {
|
|
295
|
+
pops.forEach((p, i) => {
|
|
296
|
+
const text = now - p.t > POP_MS / 2 ? dim(p.text) : p.text;
|
|
297
|
+
const top = col.art ? col.top : zone;
|
|
298
|
+
const mid = col.art ? col.ax + col.aw / 2 : col.x + col.plateW / 2;
|
|
299
|
+
put(c, Math.round(mid - vis(text) / 2), top - 1 - Math.floor((now - p.t) / POP_RISE_MS) - i, text);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---- the drops after a win -------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
const TOAST_IN_MS = 200; // a drop slides in from the right over this long (phonecraft's loot toast fades in)
|
|
306
|
+
const TOAST_SLIDE = 6; // columns it slides
|
|
307
|
+
const TOAST_OUT_MS = 400; // and the stack greys out over the last this long of the hold
|
|
308
|
+
const POP_GAP = 7; // columns left for the last hit's number between the stack and the corpse
|
|
309
|
+
// The pack has the copper and silver piles; gold has none and draws as a yellow dot.
|
|
310
|
+
const coinIcon = (copper) => (copper < 100 ? 'inv_misc_coin_05' : copper < 10_000 ? 'inv_misc_coin_03' : null);
|
|
311
|
+
|
|
312
|
+
/** What the foe dropped, items then the coin: each with its icon name and its line in the quality colour. */
|
|
313
|
+
export function lootToasts(app, hold) {
|
|
314
|
+
const out = hold.loot.map((d) => { const it = app.ds.item(d.item); return { icon: it?.icon, text: `${itemText(it, String(d.item))}${d.count > 1 ? dim(` x${d.count}`) : ''}` }; });
|
|
315
|
+
if (hold.copper) out.push({ icon: coinIcon(hold.copper), coin: true, text: coinText(hold.copper) });
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The drops as toasts stacked on the right, like loot in combat text. Each slides out from the right a beat
|
|
320
|
+
* after the one above, icon beside the name, and the stack greys out as the hold ends. Drawn over `lines`
|
|
321
|
+
* (the stage) above the plates, so the 0 under the foe's feet stays in view. */
|
|
322
|
+
export function putLootToasts(app, lines, hold, W, now) {
|
|
323
|
+
const drops = lootToasts(app, hold);
|
|
324
|
+
if (!drops.length) return;
|
|
325
|
+
const icons = iconsOn(), rowH = icons ? 2 : 1, top = 1;
|
|
326
|
+
const textW = Math.min(28, Math.max(...drops.map((d) => vis(d.text))));
|
|
327
|
+
const boxW = (icons ? 5 : 2) + textW, lane = lines.lane;
|
|
328
|
+
const room = Math.max(1, Math.floor(((lane?.rows ?? Math.floor(lines.length * 0.55)) - top) / rowH));
|
|
329
|
+
const shown = drops.length > room ? [...drops.slice(0, room - 1), { more: drops.length - room + 1 }] : drops;
|
|
330
|
+
// Where: the duel's open lane against the corpse, clear of the killing blow's number still rising off its
|
|
331
|
+
// shoulder; else open ground on the stage's right half (not under sixel, whose pictures are blank cells in
|
|
332
|
+
// the text); else the right edge on a cleared card. A win leaves only corpses on the right, so the card
|
|
333
|
+
// covers the dead, never the party.
|
|
334
|
+
const fits = (gap) => lane && lane.right - lane.left - 3 - gap >= boxW;
|
|
335
|
+
const open = fits(POP_GAP) ? lane.right - 1 - POP_GAP - boxW : fits(0) ? lane.right - 1 - boxW : sixelOn() ? -1 : openGround(lines, boxW, shown.length * rowH, top, W);
|
|
336
|
+
const card = open < 0, x0 = card ? W - boxW - 2 : open;
|
|
337
|
+
const fading = now > hold.until - TOAST_OUT_MS;
|
|
338
|
+
shown.forEach((d, i) => {
|
|
339
|
+
const age = now - (i + 1) * hold.stagger;
|
|
340
|
+
if (age < 0) return;
|
|
341
|
+
const sliding = age < TOAST_IN_MS, x = x0 + (sliding ? Math.round((1 - age / TOAST_IN_MS) * TOAST_SLIDE) : 0), y = top + i * rowH;
|
|
342
|
+
const quiet = fading || sliding;
|
|
343
|
+
if (card) for (let r = y - (i ? 0 : 1); r < y + rowH; r++) put(lines, x - 1, r, ' '.repeat(boxW + 2));
|
|
344
|
+
if (d.more) { put(lines, x, y, dim(`+${d.more} more`)); return; }
|
|
345
|
+
const text = cut(quiet ? dim(strip(d.text)) : d.text, textW);
|
|
346
|
+
const dot = d.coin ? (quiet ? dim('●') : yellow('●')) : dim('•');
|
|
347
|
+
const art = icons && d.icon ? iconArt(d.icon, 4, quiet ? 'gray' : '') : null;
|
|
348
|
+
if (art) putLines(lines, x, y, [`${art[0]} ${text}`, art[1]]);
|
|
349
|
+
else put(lines, x + (icons ? 3 : 0), y, `${dot} ${text}`); // no art: the mark sits by the name, the sprite shows round it
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The rightmost column on the stage's right half where a box `w` x `h` from row `top`, with a column of air
|
|
354
|
+
* either side, covers only blank cells; -1 when there is none. */
|
|
355
|
+
function openGround(lines, w, h, top, W) {
|
|
356
|
+
const rows = lines.slice(top, top + h).map(strip);
|
|
357
|
+
if (rows.length < h) return -1;
|
|
358
|
+
for (let x = W - w - 2; x >= Math.floor(W * 0.45); x--) if (rows.every((r) => !r.slice(x - 1, x + w + 1).trim())) return x;
|
|
359
|
+
return -1;
|
|
360
|
+
}
|