azeroth-shell 0.2.0 → 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 +43 -10
- package/bin/azsh.mjs +15 -1
- package/package.json +5 -2
- package/src/cli/hdArt.mjs +1 -1
- package/src/cli/iconArt.mjs +11 -1
- package/src/cli/modelArt.mjs +7 -4
- package/src/cli/oocPrep.mjs +32 -0
- package/src/cli/repl.mjs +16 -0
- package/src/cli/sixel.mjs +26 -14
- package/src/cli/text.mjs +16 -0
- package/src/cli/tui.mjs +33 -9
- package/src/cli/tuiAutopilot.mjs +17 -8
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiCheck.mjs +276 -15
- package/src/cli/tuiCity.mjs +31 -31
- package/src/cli/tuiCompanionKit.mjs +6 -6
- package/src/cli/tuiCompanions.mjs +189 -29
- package/src/cli/tuiCreate.mjs +7 -0
- package/src/cli/tuiDungeon.mjs +56 -20
- package/src/cli/tuiFight.mjs +63 -21
- package/src/cli/tuiFightPanes.mjs +15 -6
- package/src/cli/tuiFloor.mjs +20 -8
- package/src/cli/tuiGear.mjs +9 -10
- package/src/cli/tuiLoot.mjs +5 -6
- package/src/cli/tuiMap.mjs +214 -0
- package/src/cli/tuiPaperdoll.mjs +6 -5
- package/src/cli/tuiPartyStage.mjs +36 -7
- package/src/cli/tuiPet.mjs +16 -8
- package/src/cli/tuiProfessions.mjs +23 -23
- package/src/cli/tuiSaves.mjs +14 -0
- package/src/cli/tuiSearch.mjs +4 -5
- package/src/cli/tuiSettings.mjs +3 -4
- package/src/cli/tuiStage.mjs +72 -16
- package/src/cli/tuiTalents.mjs +3 -4
- package/src/cli/tuiViews.mjs +371 -84
- package/src/cli/tuiWiki.mjs +2 -3
- package/src/cli/tuiWorld.mjs +88 -5
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +36 -36
- package/src/data/model-art-worn.bin.br +0 -0
- package/src/data/model-art.bin.gz +0 -0
- 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/character/character.ts +6 -0
- package/src/domain/pet/pet.ts +31 -0
- package/src/engine/combat/pickAction.ts +3494 -3487
- package/src/engine/combat/sessionChase.ts +12 -1
- 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/cli/tuiFight.mjs
CHANGED
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
// your cast bar is centred under the floor and the keyed ability bar runs along the bottom. A party fight or
|
|
5
5
|
// a narrow terminal falls back to the list of unit frames (renderFrames). Auto-play runs the character's
|
|
6
6
|
// rotation; a number key presses an ability over it for one GCD.
|
|
7
|
-
import { bold, dim, cyan, yellow, green, red, gray, inverse, fit, vis, cut, bar, strip, brightBlue, itemText, orange } from './text.mjs';
|
|
7
|
+
import { bold, dim, cyan, yellow, green, red, gray, inverse, fit, vis, cut, bar, strip, brightBlue, itemText, orange, coinText } from './text.mjs';
|
|
8
8
|
import { className } from '../domain/core/ids.ts';
|
|
9
|
-
import { SIZES, youSize, foeSize, canvas, put, putLines, floorLine, POWER_PAINT, POP_MS, POP_RISE_MS, LUNGE_MS, SHORT_BUFF_MS, ROLE_GLYPH, plateLines, auraRows, castSlot, auraText, auraTime, putMissiles, lootToasts, putLootToasts } from './tuiStage.mjs';
|
|
9
|
+
import { SIZES, youSize, foeSize, canvas, put, putLines, floorLine, POWER_PAINT, POP_MS, POP_RISE_MS, LUNGE_MS, SHORT_BUFF_MS, ROLE_GLYPH, plateLines, auraRows, castSlot, castRows, auraText, auraTime, putMissiles, lootToasts, putLootToasts } from './tuiStage.mjs';
|
|
10
10
|
import { partyStage } from './tuiPartyStage.mjs';
|
|
11
11
|
import { combatDistanceYd, DEFAULT_OPEN_YD, MELEE_CONTACT_YD } from '../engine/combat/combatGeometry.ts';
|
|
12
12
|
import { formatCombatLog, combatLogPlainText, dropLogRepeats, POWER_MERGE_MS } from './combatLogFormat.ts';
|
|
13
13
|
import { impliedDest } from './combatLogFilter.ts';
|
|
14
|
-
import { moneyText } from '../domain/economy/money.ts';
|
|
15
14
|
import { maxHealth, maxPower } from '../domain/character/character.ts';
|
|
16
15
|
import { powerType } from '../domain/character/characterStats.ts';
|
|
17
16
|
import { pullOpts, sessionLogNames } from './squad.mjs';
|
|
17
|
+
import { prepOoc, prepText } from './oocPrep.mjs';
|
|
18
18
|
import { slotContent, visibleBars } from './tuiBars.mjs';
|
|
19
19
|
import { autoLootOn, openLoot } from './tuiLoot.mjs';
|
|
20
20
|
import { createCombatMacroHost } from './macroHostCombat.ts';
|
|
@@ -25,6 +25,8 @@ import { creatureArt, artWidth } from './modelArt.mjs';
|
|
|
25
25
|
import { iconArt, iconsOn } from './iconArt.mjs';
|
|
26
26
|
|
|
27
27
|
export const TICK_MS = 100; // one engine tick per 100 ms of real time, like the web client
|
|
28
|
+
// Fight time with every living foe out of combat (an evade, a leash) before the fight ends as a stalemate.
|
|
29
|
+
export const QUIET_END_MS = 3000;
|
|
28
30
|
// A win holds the fight on screen this long (real time), plus a beat for each drop as it slides in.
|
|
29
31
|
const WIN_HOLD_MS = 1000, DROP_HOLD_MS = 450, DROP_STAGGER_MS = 220, WIN_HOLD_MAX_MS = 3500;
|
|
30
32
|
|
|
@@ -38,6 +40,8 @@ const WIN_HOLD_MS = 1000, DROP_HOLD_MS = 450, DROP_STAGGER_MS = 220, WIN_HOLD_MA
|
|
|
38
40
|
export function startFight(app, creatureEntry, { onEnd, label, begin, after, resolve, onTick, why } = {}) {
|
|
39
41
|
if (app.fight) return { ok: false, reason: 'Already in a fight.' };
|
|
40
42
|
if (app.isDead()) { app.flashMsg('You are dead.'); return { ok: false, reason: 'You are dead.' }; }
|
|
43
|
+
// A pull you chose buffs first, standing still; a dungeon stop does it in pullStop, an ambush (begin) cannot.
|
|
44
|
+
if (!begin && !resolve) { const buffed = prepText(prepOoc(app.g, app.ds)); if (buffed) app.say(cyan(buffed)); }
|
|
41
45
|
const opts = begin ? { withSquad: true } : pullOpts(app.g, app.ds, app.cmds.buffPace, creatureEntry, (l) => app.say(cyan(l)));
|
|
42
46
|
const b = begin ? begin() : app.g.beginFight(creatureEntry, opts);
|
|
43
47
|
if (!app.result(b)) { app.render(); return { ok: false, reason: b.reason || 'Cannot fight that.' }; }
|
|
@@ -81,9 +85,24 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
|
|
|
81
85
|
else events = session.tick();
|
|
82
86
|
this.logEvents(events);
|
|
83
87
|
if (!session.ended) this.onTick?.();
|
|
88
|
+
if (!session.ended && !begin && !resolve) this.watchQuiet();
|
|
84
89
|
if (session.ended) this.finish();
|
|
85
90
|
app.renderSoon();
|
|
86
91
|
},
|
|
92
|
+
/** A pull that went quiet: something was in combat, and every living foe has been out of it for QUIET_END_MS.
|
|
93
|
+
* The engine keeps that session open (a reset mob stands passive and the rotation leaves it be, so a Scout
|
|
94
|
+
* that evaded once held the fight for 970 s), so it ends here as a timeout, which settles as a stalemate:
|
|
95
|
+
* the web client's "Enemies evade" settle. Not on a dungeon floor, whose session holds unpulled packs. */
|
|
96
|
+
wasEngaged: false, quietSince: null,
|
|
97
|
+
watchQuiet() {
|
|
98
|
+
const live = session.enemiesView().filter((e) => e.hp > 0);
|
|
99
|
+
if (live.some((e) => e.engaged)) { this.wasEngaged = true; this.quietSince = null; return; }
|
|
100
|
+
if (!this.wasEngaged || !live.length || !session.mirrorOutcome) return;
|
|
101
|
+
this.quietSince ??= session.t;
|
|
102
|
+
if (session.t - this.quietSince < QUIET_END_MS) return;
|
|
103
|
+
app.say(yellow(`${live.length > 1 ? 'Your foes reset' : `${live[0].name} resets`} and the fight is over.`));
|
|
104
|
+
session.mirrorOutcome(true, 'timeout');
|
|
105
|
+
},
|
|
87
106
|
/** A win holds the fight on screen a moment before it gives the screen back: the foe at 0 on its plate and
|
|
88
107
|
* what it dropped sliding in beside it (putLootToasts). The timer keeps ticking through it, so the hold is
|
|
89
108
|
* counted in ticks: the speed keys and the check script drive it like the fight. */
|
|
@@ -102,6 +121,8 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
|
|
|
102
121
|
logEvents(events) {
|
|
103
122
|
app.meter?.ingest(events);
|
|
104
123
|
notePops(this.frames, events);
|
|
124
|
+
let known = null;
|
|
125
|
+
noteCasts(this.frames, events, (id) => (known ??= new Set(session.abilities().map((a) => a.spell.id))).has(id));
|
|
105
126
|
if (this.folded()) return;
|
|
106
127
|
const flush = () => { if (this.pend && this.pend.amount > 0) app.say(line(this.pend.t, this.pend.remake(this.pend.amount))); this.pend = null; };
|
|
107
128
|
const line = (t, row) => `${dim((t / 1000).toFixed(1).padStart(5) + 's')} ${paintLog(row)}`;
|
|
@@ -130,7 +151,7 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
|
|
|
130
151
|
const tag = r.outcome === 'win' ? green(bold('Victory')) : r.outcome === 'defeat' ? red(bold('Defeat')) : yellow(bold('Stalemate'));
|
|
131
152
|
const bits = [`${(r.result.durationMs / 1000).toFixed(1)} s`, `HP ${app.hpText()}`];
|
|
132
153
|
if (r.xpGained) bits.push(`+${r.xpGained} xp`);
|
|
133
|
-
if (r.goldGained && !hold) bits.push(`+${
|
|
154
|
+
if (r.goldGained && !hold) bits.push(`+${coinText(r.goldGained)}`);
|
|
134
155
|
app.say(`${tag} against ${this.enemyName} ${dim(bits.join(' '))}`);
|
|
135
156
|
if (!hold) { noteLoot(app, r.loot, r.goldGained); for (const d of r.loot) app.say(` ${yellow('You receive')} ${app.itemLabel(d.item)}${d.count > 1 ? ` x${d.count}` : ''}`); }
|
|
136
157
|
const gained = (app.g.state.squad ?? []).filter((c) => (c.xp ?? 0) !== squadXp.get(c.id)).map((c) => `${c.name} ${(c.xp ?? 0) > (squadXp.get(c.id) ?? 0) ? `+${(c.xp ?? 0) - (squadXp.get(c.id) ?? 0)} xp` : 'levels up'}`);
|
|
@@ -558,7 +579,21 @@ const CAST_W = 30; // the cast/control slot on a frame's second line, so auras n
|
|
|
558
579
|
function newFrameMemory(app) {
|
|
559
580
|
// The player's own aura order carries across fights, so their frame never reshuffles.
|
|
560
581
|
app.playerAuraOrder ??= new Map();
|
|
561
|
-
return { auraOrder: new Map([['player', app.playerAuraOrder]]), pops: new Map(), used: new Map(), lunge: new Map() };
|
|
582
|
+
return { auraOrder: new Map([['player', app.playerAuraOrder]]), pops: new Map(), used: new Map(), lunge: new Map(), casts: new Map(), barred: new Map() };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Every unit's casts as they go off, newest first, for its cast slot (castRows): a cast_finish with no
|
|
586
|
+
* cast_start before it is an instant. `own(id)` keeps yours to the spells you know, so a proc's triggered
|
|
587
|
+
* spell never shows as something you pressed. */
|
|
588
|
+
function noteCasts(mem, events, own) {
|
|
589
|
+
for (const ev of events) {
|
|
590
|
+
if (!ev.actor || ev.spellId == null) continue;
|
|
591
|
+
if (ev.kind === 'cast_start') { mem.barred.set(ev.actor, ev.spellId); continue; }
|
|
592
|
+
if (ev.kind !== 'cast_finish' || (ev.actor === 'player' && !own(ev.spellId))) continue;
|
|
593
|
+
const instant = mem.barred.get(ev.actor) !== ev.spellId;
|
|
594
|
+
mem.barred.delete(ev.actor);
|
|
595
|
+
mem.casts.set(ev.actor, [{ t: ev.t, spellId: ev.spellId, name: ev.name, instant }, ...(mem.casts.get(ev.actor) ?? [])].slice(0, 8));
|
|
596
|
+
}
|
|
562
597
|
}
|
|
563
598
|
|
|
564
599
|
const POP_W = 18; // a hit or heal stays beside the bars for POP_MS (fight time), in a slot this wide
|
|
@@ -566,12 +601,14 @@ const USED_FLASH_MS = 500; // the ability bar slot of a spell just cast stays in
|
|
|
566
601
|
const MISS_WORD = { miss: 'Miss', dodge: 'Dodge', parry: 'Parry', immune: 'Immune', deflect: 'Deflect', block: 'Block' };
|
|
567
602
|
|
|
568
603
|
/** Damage and healing landing on each unit, newest first, for the numbers that pop up beside its bars; the
|
|
569
|
-
* player's casts, for the flash on the ability bar; and every melee
|
|
604
|
+
* player's casts, for the flash on the ability bar; and every melee auto-attack, for the lunge. */
|
|
570
605
|
function notePops(mem, events) {
|
|
571
606
|
for (const ev of events) {
|
|
572
607
|
let side, text;
|
|
573
608
|
if (ev.actor === 'player' && ev.spellId != null && (ev.kind === 'cast_start' || ev.kind === 'cast_finish' || ev.kind === 'spell_hit' || ev.kind === 'cast_self')) mem.used?.set(ev.spellId, ev.t);
|
|
574
|
-
|
|
609
|
+
// Only a melee auto-attack lunges: spells, strikes and thorns do not, nor a ranged white (Auto Shot, Shoot
|
|
610
|
+
// or Throw), which the engine sends as a melee event tagged with the shoot spell's id and name.
|
|
611
|
+
if (ev.kind === 'melee' && ev.actor && ev.spellId == null && !ev.name) mem.lunge?.set(ev.actor, ev.t);
|
|
575
612
|
if (ev.kind === 'melee' || ev.kind === 'spell_hit' || ev.kind === 'dot_tick') {
|
|
576
613
|
side = ev.target ?? impliedDest(ev.actor);
|
|
577
614
|
if (ev.amount > 0) text = ev.result === 'crit' ? bold(red(`-${ev.amount}!`)) : red(`-${ev.amount}`);
|
|
@@ -603,7 +640,7 @@ function unitFrame(app, mem, v, W, now, isPlayer, focused = false, { compact = f
|
|
|
603
640
|
if (!bars) {
|
|
604
641
|
const key = isPlayer ? 'player' : (v.side ?? v.name);
|
|
605
642
|
const name = `${(dead ? gray : isPlayer ? green : yellow)(bold(v.name))} ${dim(`L${v.level}`)}`;
|
|
606
|
-
const slot = castSlot(v, now);
|
|
643
|
+
const slot = castSlot(v, now, mem.casts?.get(key));
|
|
607
644
|
const auras = auraText(app, mem, v, key);
|
|
608
645
|
return [cut(` ${fit(name, 20)} ${fit(slot, CAST_W)} ${fit(popText(mem, key, now), POP_W)} ${auras}`, W)];
|
|
609
646
|
}
|
|
@@ -619,12 +656,15 @@ function unitFrame(app, mem, v, W, now, isPlayer, focused = false, { compact = f
|
|
|
619
656
|
if (compact) return [cut(`${head}${v.casting && !dead ? ` ${yellow(v.casting.name)}` : after}`, W)];
|
|
620
657
|
// The second line is always there, cast slot first (a cast bar, else crowd control, else stealth, blank when
|
|
621
658
|
// idle) and the auras after it, so gaining a buff or starting a cast never moves this frame or the ones below.
|
|
622
|
-
const
|
|
659
|
+
const recent = mem.casts?.get(key);
|
|
623
660
|
if (iconsOn()) {
|
|
624
|
-
// Icons:
|
|
661
|
+
// Icons: the cast slot two lines tall (the spell's icon, its name, the casts before it), then a strip of
|
|
662
|
+
// 4x4 icons, each aura's time (or stacks) under it on the third line.
|
|
625
663
|
const [top, bottom] = auraIcons(app, mem, v, key);
|
|
626
|
-
|
|
664
|
+
const [slotTop, slotUnder] = dead ? ['', ''] : castRows(app, v, recent, now, CAST_W);
|
|
665
|
+
return [cut(`${head}${after}`, W), cut(` ${fit(slotTop, CAST_W)} ${top}`, W), cut(` ${fit(slotUnder, CAST_W)} ${bottom}`, W)];
|
|
627
666
|
}
|
|
667
|
+
const slot = castSlot(v, now, recent);
|
|
628
668
|
const auras = auraText(app, mem, v, key);
|
|
629
669
|
return [cut(`${head}${after}`, W), cut(` ${fit(slot, CAST_W)} ${auras}`, W)];
|
|
630
670
|
}
|
|
@@ -662,7 +702,7 @@ export function fightArt(app, ctl, W, H) {
|
|
|
662
702
|
const cols = artCols(W);
|
|
663
703
|
const entry = ctl.session?.enemyView()?.creatureEntry ?? ctl.enemyEntry;
|
|
664
704
|
const foe = cols && entry != null ? creatureArt(app.ds.creature(entry)?.displayId, cols, H) : null;
|
|
665
|
-
const me = cols && W >= 130 ? playerArt(app, cols, H)?.lines ?? null : null;
|
|
705
|
+
const me = cols && W >= 130 ? playerArt(app, cols, H, ctl.session?.playerView())?.lines ?? null : null;
|
|
666
706
|
const innerW = W - (foe ? artWidth(foe) + 2 : 0) - (me ? artWidth(me) + 2 : 0);
|
|
667
707
|
return { me, foe, innerW };
|
|
668
708
|
}
|
|
@@ -698,13 +738,14 @@ function duelStage(app, ctl, W, H, { inline }) {
|
|
|
698
738
|
const icons = iconsOn();
|
|
699
739
|
const barW = W >= 140 ? 30 : W >= 110 ? 24 : 18;
|
|
700
740
|
const plateW = barW + PLATE_PAD;
|
|
701
|
-
const auraH = icons ? 3 : 1;
|
|
702
|
-
// Rows the scene keeps whatever the sprites get: the plate (3), the state
|
|
703
|
-
// The state
|
|
741
|
+
const auraH = icons ? 3 : 1, stateH = icons ? 2 : 1;
|
|
742
|
+
// Rows the scene keeps whatever the sprites get: the plate (3), the state rows, the auras and the floor.
|
|
743
|
+
// The state rows are there idle or casting, so a cast never moves the auras under them; with icons they are
|
|
744
|
+
// two, the spell's icon beside its name and the trail of casts before it (castRows). The ability bar goes
|
|
704
745
|
// when rows are short, and only under auto-play.
|
|
705
746
|
const barLines = ctl.abilityRows(W, W >= 140 ? 6 : 4, { live: !inline });
|
|
706
747
|
let barRows = barLines.length;
|
|
707
|
-
const fixed = () => 3 +
|
|
748
|
+
const fixed = () => 3 + stateH + auraH + 1 + barRows;
|
|
708
749
|
if (H - fixed() < 4 && ctl.auto) barRows = 0;
|
|
709
750
|
const artRows = H - fixed();
|
|
710
751
|
if (artRows < 4) return null;
|
|
@@ -718,7 +759,7 @@ function duelStage(app, ctl, W, H, { inline }) {
|
|
|
718
759
|
return { v, key, art, aw: artWidth(art), cw: Math.max(pw, art ? artWidth(art) : 0), plateW: pw, ...o, barW: o.barW ?? barW };
|
|
719
760
|
};
|
|
720
761
|
const cols = [];
|
|
721
|
-
const you = column(pv, 'player', youSize(W), (c, r) => playerArt(app, c, r)?.lines ?? null, { isPlayer: true });
|
|
762
|
+
const you = column(pv, 'player', youSize(W), (c, r) => playerArt(app, c, r, pv)?.lines ?? null, { isPlayer: true });
|
|
722
763
|
const margin = Math.max(GAP, Math.floor(W / 16)); // the actors stand a little in from the edges
|
|
723
764
|
you.x = margin; cols.push(you);
|
|
724
765
|
let left = you.x + you.cw + GAP, right = W - margin, petCol = null;
|
|
@@ -763,7 +804,7 @@ function duelStage(app, ctl, W, H, { inline }) {
|
|
|
763
804
|
}
|
|
764
805
|
cols.push(foe);
|
|
765
806
|
}
|
|
766
|
-
const zone = artRows, stateRow = zone + 3, auraTop =
|
|
807
|
+
const zone = artRows, stateRow = zone + 3, auraTop = stateRow + stateH, floorRow = auraTop + auraH, barTop = floorRow + 1;
|
|
767
808
|
const c = canvas(W, H);
|
|
768
809
|
for (const col of cols) {
|
|
769
810
|
const v = col.v, dead = v.hp <= 0;
|
|
@@ -774,9 +815,10 @@ function duelStage(app, ctl, W, H, { inline }) {
|
|
|
774
815
|
col.ax = col.x + Math.max(0, Math.floor((col.cw - col.aw) / 2)) + lunge; col.top = zone - col.art.length; putLines(c, col.ax, col.top, col.art);
|
|
775
816
|
}
|
|
776
817
|
putLines(c, col.x, zone, plateLines(app, v, { ...col, dead }));
|
|
777
|
-
// Your cast sits where the foe's does: under the power bar, in the state
|
|
778
|
-
const
|
|
779
|
-
|
|
818
|
+
// Your cast sits where the foe's does: under the power bar, in the state rows every plate keeps.
|
|
819
|
+
const recent = mem.casts.get(col.key);
|
|
820
|
+
const slot = dead ? [''] : icons ? castRows(app, v, recent, now, col.plateW) : [castSlot(v, now, recent)];
|
|
821
|
+
if (slot[0] || slot[1]) putLines(c, col.x, stateRow, slot.map((l) => cut(l, col.plateW)));
|
|
780
822
|
else if (col === foe && hidden) put(c, col.x, stateRow, dim(`+${hidden} more in the fight`));
|
|
781
823
|
if (!col.small && !dead) putLines(c, col.x, auraTop, auraRows(app, mem, v, col.key, col.plateW, icons));
|
|
782
824
|
// Hits and heals rise from the chest, the newest lowest; without art they sit beside the name.
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// and the loot taken so far. The log is the game's own log (tui.mjs); the meter is phonecraft's DPSMate
|
|
3
3
|
// aggregator fed every tick's events; threat reads the engine's ThreatManager through the session; loot is a
|
|
4
4
|
// session-long tally of what the "You receive" lines handed over. The choice sticks between fights.
|
|
5
|
-
import { bold, dim, cyan, yellow, green, red, inverse, fit, vis, cut, bar, pct } from './text.mjs';
|
|
5
|
+
import { bold, dim, cyan, yellow, green, red, inverse, fit, vis, cut, bar, pct, coinText } from './text.mjs';
|
|
6
6
|
import { createDpsMateMeter, partyMeterSnapshot } from '../domain/bots/dpsMate.ts';
|
|
7
|
-
import { moneyText } from '../domain/economy/money.ts';
|
|
8
7
|
import { runPaneLines, questPaneLines, bagsPaneLines } from './tuiAutopilot.mjs';
|
|
8
|
+
import { iconsOn, iconArt, iconBlank } from './iconArt.mjs';
|
|
9
9
|
|
|
10
10
|
export const FIGHT_PANES = [
|
|
11
11
|
{ id: 'log', label: 'Log' },
|
|
@@ -230,16 +230,25 @@ function threatLines(app, ctl, L) {
|
|
|
230
230
|
function lootLines(app, L) {
|
|
231
231
|
const t = app.lootTally ?? { rows: [], copper: 0 };
|
|
232
232
|
const n = t.rows.reduce((a, r) => a + r.count, 0);
|
|
233
|
-
const lines = [` ${bold('Loot this sitting')} ${dim(`${n} item${n === 1 ? '' : 's'}${t.copper ? ` · ${
|
|
233
|
+
const lines = [` ${bold('Loot this sitting')} ${dim(`${n} item${n === 1 ? '' : 's'}${t.copper ? ` · ${coinText(t.copper)}` : ''}`)}`];
|
|
234
234
|
if (!t.rows.length && !t.copper) { lines.push(dim(' Nothing looted yet. Whatever you take shows up here.')); return lines; }
|
|
235
235
|
const colW = Math.max(24, Math.floor((L.W - 2) / Math.max(1, Math.min(3, Math.floor(L.W / 30)))));
|
|
236
236
|
const cols = Math.max(1, Math.floor((L.W - 2) / colW));
|
|
237
|
-
|
|
238
|
-
|
|
237
|
+
// With icons each cell is two lines: a 4px icon down its left, the name beside the top and the count and
|
|
238
|
+
// kind beside the bottom.
|
|
239
|
+
const icons = iconsOn() && L.W >= 40;
|
|
240
|
+
const h = icons ? 2 : 1;
|
|
241
|
+
const cells = t.rows.map((r) => {
|
|
242
|
+
if (!icons) return [`${app.itemLabel(r.item)}${r.count > 1 ? dim(` x${r.count}`) : ''}`];
|
|
243
|
+
const it = app.ds.item(r.item);
|
|
244
|
+
const art = iconArt(it?.icon, 4) ?? iconBlank(4);
|
|
245
|
+
return [`${art[0]} ${app.itemLabel(r.item)}`, `${art[1]} ${dim([r.count > 1 ? `x${r.count}` : '', it?.typeName].filter(Boolean).join(' · '))}`];
|
|
246
|
+
});
|
|
247
|
+
const rowsAvail = Math.floor((L.logH - 1) / h);
|
|
239
248
|
for (let i = 0; i < rowsAvail; i++) {
|
|
240
249
|
const slice = cells.slice(i * cols, i * cols + cols);
|
|
241
250
|
if (!slice.length) break;
|
|
242
|
-
lines.push(` ${slice.map((c) => fit(c, colW - 1)).join(' ')}`);
|
|
251
|
+
for (let k = 0; k < h; k++) lines.push(` ${slice.map((c) => fit(c[k], colW - 1)).join(' ')}`);
|
|
243
252
|
}
|
|
244
253
|
const shown = Math.min(cells.length, rowsAvail * cols);
|
|
245
254
|
if (shown < cells.length) lines[lines.length - 1] = fit(lines[lines.length - 1], L.W - 10) + dim(`+${cells.length - shown} more`);
|
package/src/cli/tuiFloor.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { dirname, join } from 'node:path';
|
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { bold, dim, yellow, green, red, cyan, fit } from './text.mjs';
|
|
23
23
|
import { startFight } from './tuiFight.mjs';
|
|
24
|
-
import { pullStop, restBetween } from './tuiDungeon.mjs';
|
|
24
|
+
import { pullStop, restBetween, readyUp } from './tuiDungeon.mjs';
|
|
25
25
|
import { prePull } from './squad.mjs';
|
|
26
26
|
import { decodeNavMesh, registerNavMeshForMap, navMeshForMap } from '../domain/nav/navmesh.ts';
|
|
27
27
|
import { bakeSlugForInstance } from '../world/bakeSlug.ts';
|
|
@@ -82,9 +82,13 @@ function stampTemplates(app, ents) {
|
|
|
82
82
|
/** The floor for the current run, built on first use and kept on the app while the run lasts. */
|
|
83
83
|
function floorFor(app, def) {
|
|
84
84
|
const run = app.g.state.instance;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
// One floor per entry: a hearth or a taxi out never cleared app.floor, so the next run of the dungeon walked the old one.
|
|
86
|
+
const enteredAt = app.g.state.instanceEnteredAtMs ?? null;
|
|
87
|
+
if (app.floor?.dungeonId === run.dungeonId && app.floor.enteredAt === enteredAt) return app.floor;
|
|
88
|
+
// Bodies of stops already cleared stay dead on the rebuilt floor, with any loose kills from before. The kills
|
|
89
|
+
// ride on the run, not the dungeon: a fresh run of the same dungeon stood the last run's dead back up, and an
|
|
90
|
+
// event's body (the Ring of Law's champion, one guid every run) was born dead and paid out unfought.
|
|
91
|
+
const cleared = new Set(run.floorClearedGuids ?? []);
|
|
88
92
|
for (const enc of def.encounters) if (isCleared(run, enc.id)) for (const gd of enc.spawnGuids ?? []) cleared.add(gd);
|
|
89
93
|
const bossEntries = new Set(def.encounters.filter((e) => e.kind === 'boss' && e.creatureEntry > 0).map((e) => e.creatureEntry));
|
|
90
94
|
const world = createContinuousWorld({
|
|
@@ -96,7 +100,7 @@ function floorFor(app, def) {
|
|
|
96
100
|
skipEntry: (entry) => !bossEntries.has(entry) && isTechnicalTriggerCreature(app.ds.creature(entry)),
|
|
97
101
|
});
|
|
98
102
|
stampTemplates(app, world.entities);
|
|
99
|
-
const f = { dungeonId: run.dungeonId, def, world, runMs: 0, patrolIndex: new Map(), target: null, walking: false, follow: true, speed: 1, timer: null, script: null, questScanMs: -Infinity, waiting: [], eventHoldUntil: 0,
|
|
103
|
+
const f = { dungeonId: run.dungeonId, enteredAt, def, world, runMs: 0, patrolIndex: new Map(), target: null, walking: false, follow: true, speed: 1, timer: null, script: null, questScanMs: -Infinity, waiting: [], eventHoldUntil: 0,
|
|
100
104
|
rng: mulberry32(((app.g.state.rngSeed ?? 1) ^ 0x9e3779b9) >>> 0) };
|
|
101
105
|
f.script = scriptContext(app, f);
|
|
102
106
|
app.floor = f;
|
|
@@ -158,8 +162,13 @@ function markEngaged(f, ents) {
|
|
|
158
162
|
|
|
159
163
|
function remember(app, f, guids) {
|
|
160
164
|
resolveKill(f.world, guids);
|
|
161
|
-
app
|
|
162
|
-
|
|
165
|
+
keepKills(app, f);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The floor's dead onto the run, so a step outside keeps them and a fresh run starts without them. */
|
|
169
|
+
function keepKills(app, f) {
|
|
170
|
+
const run = app.g.state.instance;
|
|
171
|
+
if (run?.dungeonId === f.dungeonId) app.g.state.instance = { ...run, floorClearedGuids: [...f.world.clearedGuids] };
|
|
163
172
|
}
|
|
164
173
|
|
|
165
174
|
/** One floor tick: patrols, the walk, the scripts, then what sees you. Returns true when a pull started. */
|
|
@@ -339,6 +348,7 @@ function fightBodies(app, f, ents) {
|
|
|
339
348
|
/** The event is lost: its summoned bodies go, claimed ones stand down, and it waits a minute before it arms again. */
|
|
340
349
|
function failEvent(app, f) {
|
|
341
350
|
f.script.resetGauntletOnWipe();
|
|
351
|
+
keepKills(app, f); // the event's fallen came off the floor's dead; a rebuilt floor must not bring them back dead
|
|
342
352
|
f.eventHoldUntil = f.runMs + EVENT_HOLD_MS;
|
|
343
353
|
app.say(red('The event fails. Rest (z) before you go again; it waits a minute.'));
|
|
344
354
|
}
|
|
@@ -398,6 +408,8 @@ function reopen(app) {
|
|
|
398
408
|
export function walkTo(app, f, enc) {
|
|
399
409
|
const dest = enc.origin;
|
|
400
410
|
if (!dest) { app.flashMsg(`${enc.name} has no place on the floor.`); return; }
|
|
411
|
+
// Buff and drink before setting off: whatever sees you on the way pulls with no time for it.
|
|
412
|
+
readyUp(app);
|
|
401
413
|
const r = setMoveOrder(f.world, dest, f.def.mapId);
|
|
402
414
|
f.target = enc.id;
|
|
403
415
|
f.walking = true;
|
|
@@ -407,7 +419,7 @@ export function walkTo(app, f, enc) {
|
|
|
407
419
|
|
|
408
420
|
/** P on a stop: an event starts where it stands, anything else is pulled from here. */
|
|
409
421
|
function pullFromHere(app, f, enc) {
|
|
410
|
-
if (!floorGauntletWaves(enc)) { pullStop(app, f.def, enc, { floor: true, onTick: () => stepInFight(app, f), onDone: (r) => settle(app, f, r) }); if (app.fight) app.fight.floor = f; return; }
|
|
422
|
+
if (!floorGauntletWaves(enc)) { pullStop(app, f.def, enc, { floor: true, ready: true, onTick: () => stepInFight(app, f), onDone: (r) => settle(app, f, r) }); if (app.fight) app.fight.floor = f; return; }
|
|
411
423
|
const run = app.g.state.instance;
|
|
412
424
|
const need = requiredFlags(enc).filter((fl) => !hasFlag(run, fl));
|
|
413
425
|
if (need.length) { app.flashMsg(`${enc.name} has not been set off yet (${need.join(', ')}).`); return; }
|
package/src/cli/tuiGear.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The Character tab's lower rows: what repairs cost and where, the ammo you shoot, and the food, water and elixirs the
|
|
2
2
|
// game uses for you out of combat. Per-slot durability and r (repair this one) ride on the slot rows themselves.
|
|
3
|
-
import { bold, dim, yellow, green, red, qualityPaint, itemText, secondsText } from './text.mjs';
|
|
4
|
-
import { moneyText } from '../domain/economy/money.ts';
|
|
3
|
+
import { bold, dim, yellow, green, red, qualityPaint, itemText, secondsText, coinText } from './text.mjs';
|
|
5
4
|
import { equippedEntry, equippedInstance } from '../domain/items/itemInstance.ts';
|
|
6
5
|
import { gearEnchantDisplayName } from '../domain/professions/gearEnchant.ts';
|
|
7
6
|
import { hasDurability, maxDurability, currentDurability, repairCostSlot } from '../domain/items/durability.ts';
|
|
@@ -49,23 +48,23 @@ export function repairOne(app, slot) {
|
|
|
49
48
|
const cost = repairCostSlot(app.ch, slot, (e) => app.ds.item(e));
|
|
50
49
|
if (!cost) { app.flashMsg('That piece needs no repair.'); return; }
|
|
51
50
|
const r = app.g.repairSlot(slot, q.vendorEntry ?? undefined);
|
|
52
|
-
app.result(r, r.ok ? `${q.vendorName} repairs it for ${
|
|
51
|
+
app.result(r, r.ok ? `${q.vendorName} repairs it for ${coinText(r.spent)}.` : '');
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
/** Enter on Repair all, or R on the
|
|
54
|
+
/** Enter on Repair all, or R on the Character tab: mend everything you wear at the repairer the quote prices against. */
|
|
56
55
|
export function repairAll(app) {
|
|
57
56
|
const q = app.g.repairQuote();
|
|
58
57
|
if (!q.cost) { app.flashMsg('Nothing needs repair.'); return; }
|
|
59
58
|
if (!q.vendorName) { app.flashMsg('Nobody here repairs.'); return; }
|
|
60
59
|
const r = app.g.repairAll(q.vendorEntry ?? undefined);
|
|
61
|
-
app.result(r, r.ok ? `Repaired for ${
|
|
60
|
+
app.result(r, r.ok ? `Repaired for ${coinText(r.spent)}.` : '');
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
export function slotRepairLines(app, slot) {
|
|
65
64
|
const cost = repairCostSlot(app.ch, slot, (e) => app.ds.item(e));
|
|
66
65
|
if (!cost) return [];
|
|
67
66
|
const q = app.g.repairQuote();
|
|
68
|
-
return [q.vendorName ? dim(`r: repair this piece for ${
|
|
67
|
+
return [q.vendorName ? dim(`r: repair this piece for ${coinText(cost)} at ${q.vendorName}`) : dim(`Repairing this piece costs about ${coinText(cost)}.`)];
|
|
69
68
|
}
|
|
70
69
|
|
|
71
70
|
const rangedWeapon = (app) => { const e = equippedEntry(app.ch.equipped.ranged); return e != null ? app.ds.item(e) : undefined; };
|
|
@@ -127,13 +126,13 @@ export function autoConsumablesDialog(app) {
|
|
|
127
126
|
};
|
|
128
127
|
}
|
|
129
128
|
|
|
130
|
-
/** The rows under the paperdoll on the
|
|
129
|
+
/** The rows under the paperdoll on the Character tab. */
|
|
131
130
|
export function gearExtraRows(app) {
|
|
132
131
|
const rows = [H(''), H('Upkeep')];
|
|
133
132
|
const q = app.g.repairQuote();
|
|
134
133
|
rows.push({
|
|
135
|
-
text: `Repair all ${q.cost ?
|
|
136
|
-
detail: () => [bold('Repair'), '', q.cost ? `Mending everything you wear costs ${
|
|
134
|
+
text: `Repair all ${q.cost ? coinText(q.cost) : dim('nothing to mend')}${q.cost ? dim(q.vendorName ? ` at ${q.vendorName}` : ' nobody here repairs') : ''}`,
|
|
135
|
+
detail: () => [bold('Repair'), '', q.cost ? `Mending everything you wear costs ${coinText(q.cost)}.` : 'Everything you wear is in good shape.', q.vendorName ? dim(`Priced at ${q.vendorName}.`) : dim('A smith or general goods vendor repairs.'), '', dim('Enter: repair all r on a slot: repair one piece')],
|
|
137
136
|
act: (a) => repairAll(a),
|
|
138
137
|
});
|
|
139
138
|
const weapon = rangedWeapon(app);
|
package/src/cli/tuiLoot.mjs
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
// keeps the haul in `state.pendingLoot`; Enter takes one row into the bags, a takes everything that fits, Esc
|
|
3
3
|
// closes it (outdoor leftovers are gone then, an opened bag item keeps its remainder). With auto-loot on (the
|
|
4
4
|
// default) fights never hold their loot and this window does not open.
|
|
5
|
-
import { bold, dim, yellow, green, red, qualityPaint } from './text.mjs';
|
|
5
|
+
import { bold, dim, yellow, green, red, qualityPaint, coinText } from './text.mjs';
|
|
6
6
|
import { itemLines } from './tuiViews.mjs';
|
|
7
|
-
import { moneyText } from '../domain/economy/money.ts';
|
|
8
7
|
import { formatItemName } from '../domain/items/itemDisplayName.ts';
|
|
9
8
|
import { farmNext } from './tuiFight.mjs';
|
|
10
9
|
import { noteLoot } from './tuiFightPanes.mjs';
|
|
@@ -32,7 +31,7 @@ export function takeAll(app) {
|
|
|
32
31
|
if (!app.result(r)) return;
|
|
33
32
|
noteLoot(app, r.taken, r.copper);
|
|
34
33
|
for (const t of r.taken) app.say(` ${yellow('You receive')} ${app.itemLabel(t.item)}${t.count > 1 ? ` x${t.count}` : ''}`);
|
|
35
|
-
if (r.copper) app.say(` ${yellow('You loot')} ${
|
|
34
|
+
if (r.copper) app.say(` ${yellow('You loot')} ${coinText(r.copper)}`);
|
|
36
35
|
if (r.remaining) app.say(red(` ${r.remaining} left in there: your bags are full.`));
|
|
37
36
|
if (r.emptied && app.top()?.id === 'loot') app.pop();
|
|
38
37
|
}
|
|
@@ -69,9 +68,9 @@ export function lootDialog(app) {
|
|
|
69
68
|
const rows = [{ text: `${KIND_LABEL[L.kind] ?? L.kind}: ${L.title}`, header: true }];
|
|
70
69
|
if (L.copper > 0) {
|
|
71
70
|
rows.push({
|
|
72
|
-
text:
|
|
73
|
-
detail: () => [bold('Money'),
|
|
74
|
-
act: (a2) => { const r = a2.g.takeLootMoney(); if (a2.result(r)) { noteLoot(a2, [], r.copper); a2.say(` ${yellow('You loot')} ${
|
|
71
|
+
text: coinText(L.copper),
|
|
72
|
+
detail: () => [bold('Money'), coinText(L.copper)],
|
|
73
|
+
act: (a2) => { const r = a2.g.takeLootMoney(); if (a2.result(r)) { noteLoot(a2, [], r.copper); a2.say(` ${yellow('You loot')} ${coinText(r.copper)}`); if (r.emptied) a2.pop(); } },
|
|
75
74
|
});
|
|
76
75
|
}
|
|
77
76
|
L.rows.forEach((r, i) => {
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// The world map on the Travel tab: phonecraft's combined Azeroth terrain map (the picture every zone's
|
|
2
|
+
// `mapPos` is measured on; scripts/bake-world-map.mjs) drawn in the detail pane with pins for where you
|
|
3
|
+
// stand, the zone under the cursor and the route a journey takes between them. On the road the pin walks its
|
|
4
|
+
// leg. Two packs, like the creature art: src/data/world-map.bin.gz as truecolour half-block cells (two map
|
|
5
|
+
// pixels a row), or src/data/world-map-sixel.bin.br as the terminal's own pixels where it draws sixel
|
|
6
|
+
// (sixel.mjs). No pack, no colour, or no room: no map, never an error.
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { gunzipSync, brotliDecompressSync } from 'node:zlib';
|
|
9
|
+
import { useColor, cyan, yellow, dim, vis } from './text.mjs';
|
|
10
|
+
import { sixelImage, sixelOn, cellSize } from './sixel.mjs';
|
|
11
|
+
|
|
12
|
+
const SMALL_URL = new URL('../data/world-map.bin.gz', import.meta.url);
|
|
13
|
+
const SIXEL_URL = new URL('../data/world-map-sixel.bin.br', import.meta.url);
|
|
14
|
+
|
|
15
|
+
// ---- the packs ----------------------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** A pack as { w, h, pal, idx } (a palette index per pixel, every pixel drawn), or null. */
|
|
18
|
+
function parse(buf) {
|
|
19
|
+
if (buf.toString('ascii', 0, 5) !== 'AZMAP' || buf[5] !== 1) return null;
|
|
20
|
+
const w = buf.readUInt16LE(6), h = buf.readUInt16LE(8), palLen = buf[10], palEnd = 11 + palLen * 3;
|
|
21
|
+
const idx = buf.subarray(palEnd, palEnd + w * h);
|
|
22
|
+
return idx.length === w * h ? { w, h, pal: buf.subarray(11, palEnd), idx } : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let small, big; // undefined: not read yet; null: not there
|
|
26
|
+
function smallMap() {
|
|
27
|
+
if (small === undefined) { try { small = parse(gunzipSync(readFileSync(SMALL_URL))); } catch { small = null; } }
|
|
28
|
+
return small;
|
|
29
|
+
}
|
|
30
|
+
function sixelMap() {
|
|
31
|
+
if (big === undefined) { try { big = parse(brotliDecompressSync(readFileSync(SIXEL_URL))); } catch { big = null; } }
|
|
32
|
+
return big;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Is there a map to draw? */
|
|
36
|
+
export const hasWorldMap = () => useColor && !!smallMap();
|
|
37
|
+
|
|
38
|
+
/** The map area-averaged to tw x th, as packed rgb. */
|
|
39
|
+
function resample(m, tw, th) {
|
|
40
|
+
const out = new Uint8Array(tw * th * 3), sx = m.w / tw, sy = m.h / th;
|
|
41
|
+
for (let ty = 0; ty < th; ty++) {
|
|
42
|
+
const y0 = Math.floor(ty * sy), y1 = Math.max(y0 + 1, Math.floor((ty + 1) * sy));
|
|
43
|
+
for (let tx = 0; tx < tw; tx++) {
|
|
44
|
+
const x0 = Math.floor(tx * sx), x1 = Math.max(x0 + 1, Math.floor((tx + 1) * sx));
|
|
45
|
+
let r = 0, g = 0, b = 0, n = 0;
|
|
46
|
+
for (let y = y0; y < y1 && y < m.h; y++) for (let x = x0; x < x1 && x < m.w; x++) {
|
|
47
|
+
const p = m.idx[y * m.w + x] * 3;
|
|
48
|
+
r += m.pal[p]; g += m.pal[p + 1]; b += m.pal[p + 2]; n++;
|
|
49
|
+
}
|
|
50
|
+
if (!n) continue;
|
|
51
|
+
const d = (ty * tw + tx) * 3;
|
|
52
|
+
out[d] = r / n; out[d + 1] = g / n; out[d + 2] = b / n;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---- what to pin -----------------------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The pins in map fractions (0..1 from the top-left, a zone's `mapPos`): `you`, walking its leg when a journey
|
|
62
|
+
* is under way; `dest`, the zone under the cursor (a journey's destination when none is given); `route`, the
|
|
63
|
+
* zones the trip passes after you, in order. `legs` is a travel preview's plan (or any list of { to }); a
|
|
64
|
+
* journey under way brings its own legs.
|
|
65
|
+
*/
|
|
66
|
+
function marks(app, dest, legs) {
|
|
67
|
+
const ds = app.ds, pos = (id) => ds.zone(id)?.mapPos ?? null;
|
|
68
|
+
const st = app.g.state.char.journey ? app.g.journeyStatus() : null;
|
|
69
|
+
let you = pos(app.ch.zoneId);
|
|
70
|
+
if (st) {
|
|
71
|
+
const i = st.progress.legIndex, leg = st.legs[i];
|
|
72
|
+
const a = leg ? pos(leg.from) : null, b = leg ? pos(leg.to) : null;
|
|
73
|
+
const t = leg?.seconds > 0 ? Math.min(1, Math.max(0, st.progress.legElapsedSeconds / leg.seconds)) : 1;
|
|
74
|
+
// Snapped to 1/250 of the map, so a tick that moves the pin under a pixel does not redraw (or re-encode) it.
|
|
75
|
+
you = a && b ? [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t].map((v) => Math.round(v * 250) / 250) : b ?? a ?? you;
|
|
76
|
+
legs = st.legs.slice(i);
|
|
77
|
+
dest ??= ds.zone(st.destination);
|
|
78
|
+
}
|
|
79
|
+
const route = [];
|
|
80
|
+
for (const l of legs ?? []) { const p = pos(l.to); if (p) route.push(p); }
|
|
81
|
+
return { you, dest: dest?.mapPos ?? null, route };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const at3 = (p) => (p ? `${p[0].toFixed(3)},${p[1].toFixed(3)}` : '-');
|
|
85
|
+
|
|
86
|
+
// ---- half-block cells -------------------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
const RESET = '\x1b[0m';
|
|
89
|
+
const YOU = { glyph: '●', rgb: '255;210;60' }, DEST = { glyph: '◆', rgb: '90;220;255' }, ROUTE = { glyph: '·', rgb: '255;240;190' };
|
|
90
|
+
|
|
91
|
+
/** The cells a straight line crosses between two cells (Bresenham), both ends included. */
|
|
92
|
+
function lineCells([x0, y0], [x1, y1]) {
|
|
93
|
+
const out = [], dx = Math.abs(x1 - x0), dy = -Math.abs(y1 - y0), sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
|
|
94
|
+
let err = dx + dy, x = x0, y = y0;
|
|
95
|
+
for (;;) {
|
|
96
|
+
out.push([x, y]);
|
|
97
|
+
if (x === x1 && y === y1) return out;
|
|
98
|
+
const e2 = 2 * err;
|
|
99
|
+
if (e2 >= dy) { err += dy; x += sx; }
|
|
100
|
+
if (e2 <= dx) { err += dx; y += sy; }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The map as half-block lines that fit maxCols x maxRows keeping its proportions, the pins as glyphs on
|
|
105
|
+
* their cells (the cell's own colour behind them). */
|
|
106
|
+
function halfBlockLines(m, mk, maxCols, maxRows) {
|
|
107
|
+
const scale = Math.min(maxCols / m.w, (maxRows * 2) / m.h);
|
|
108
|
+
const gw = Math.max(2, Math.round(m.w * scale)), gh = Math.max(2, Math.round(m.h * scale) + 1) & ~1, rows = gh / 2;
|
|
109
|
+
const s = resample(m, gw, gh);
|
|
110
|
+
const cell = (p) => [Math.min(gw - 1, Math.floor(p[0] * gw)), Math.min(rows - 1, Math.floor(p[1] * rows))];
|
|
111
|
+
const over = new Map();
|
|
112
|
+
const pts = [mk.you, ...mk.route].filter(Boolean).map(cell);
|
|
113
|
+
for (let i = 0; i + 1 < pts.length; i++) for (const c of lineCells(pts[i], pts[i + 1])) over.set(c.join(), ROUTE);
|
|
114
|
+
if (mk.dest) over.set(cell(mk.dest).join(), DEST);
|
|
115
|
+
if (mk.you) over.set(cell(mk.you).join(), YOU);
|
|
116
|
+
const lines = [];
|
|
117
|
+
for (let y = 0; y < rows; y++) {
|
|
118
|
+
let line = '';
|
|
119
|
+
for (let x = 0; x < gw; x++) {
|
|
120
|
+
const t = (y * 2 * gw + x) * 3, u = ((y * 2 + 1) * gw + x) * 3, o = over.get(`${x},${y}`);
|
|
121
|
+
if (o) line += `\x1b[38;2;${o.rgb}m\x1b[48;2;${(s[t] + s[u]) >> 1};${(s[t + 1] + s[u + 1]) >> 1};${(s[t + 2] + s[u + 2]) >> 1}m${o.glyph}${RESET}`;
|
|
122
|
+
else line += `\x1b[38;2;${s[t]};${s[t + 1]};${s[t + 2]}m\x1b[48;2;${s[u]};${s[u + 1]};${s[u + 2]}m▀${RESET}`;
|
|
123
|
+
}
|
|
124
|
+
lines.push(line);
|
|
125
|
+
}
|
|
126
|
+
return lines;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const cache = new Map();
|
|
130
|
+
const CACHE_MAX = 48;
|
|
131
|
+
function remember(key, lines) {
|
|
132
|
+
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value);
|
|
133
|
+
cache.set(key, lines);
|
|
134
|
+
return lines;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---- sixel pixels ------------------------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
const fitted = new Map(); // `${w}|${tw}x${th}` -> the map resampled to that size, kept for the next pin move
|
|
140
|
+
const FIT_MAX = 4;
|
|
141
|
+
|
|
142
|
+
/** The pins painted on the fitted map: a dashed route, a cyan diamond at the destination, a gold disc on you,
|
|
143
|
+
* each with a dark rim so it reads on any terrain. Sizes follow the picture's width. */
|
|
144
|
+
function paintPixels(base, tw, th, mk) {
|
|
145
|
+
const rgba = new Uint8Array(tw * th * 4);
|
|
146
|
+
for (let i = 0; i < tw * th; i++) { rgba[i * 4] = base[i * 3]; rgba[i * 4 + 1] = base[i * 3 + 1]; rgba[i * 4 + 2] = base[i * 3 + 2]; rgba[i * 4 + 3] = 255; }
|
|
147
|
+
const put = (x, y, c) => { if (x >= 0 && y >= 0 && x < tw && y < th) { const d = (y * tw + x) * 4; rgba[d] = c[0]; rgba[d + 1] = c[1]; rgba[d + 2] = c[2]; } };
|
|
148
|
+
const disc = (cx, cy, r, c) => { for (let y = Math.floor(cy - r); y <= cy + r; y++) for (let x = Math.floor(cx - r); x <= cx + r; x++) if ((x - cx) ** 2 + (y - cy) ** 2 <= r * r) put(x, y, c); };
|
|
149
|
+
const diamond = (cx, cy, r, c) => { for (let y = Math.floor(cy - r); y <= cy + r; y++) for (let x = Math.floor(cx - r); x <= cx + r; x++) if (Math.abs(x - cx) + Math.abs(y - cy) <= r) put(x, y, c); };
|
|
150
|
+
const DARK = [24, 20, 16], r = Math.max(3, tw / 90), px = (p) => [p[0] * tw, p[1] * th];
|
|
151
|
+
const pts = [mk.you, ...mk.route].filter(Boolean).map(px);
|
|
152
|
+
const dash = r * 2.2, gap = r * 1.6;
|
|
153
|
+
for (const [rad, c] of [[r / 2.6 + 1, DARK], [r / 2.6, [255, 240, 190]]]) {
|
|
154
|
+
let along = 0;
|
|
155
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
156
|
+
const [x0, y0] = pts[i], [x1, y1] = pts[i + 1], len = Math.hypot(x1 - x0, y1 - y0);
|
|
157
|
+
for (let d = 0; d <= len; d++) if ((along + d) % (dash + gap) < dash) disc(x0 + ((x1 - x0) * d) / len, y0 + ((y1 - y0) * d) / len, rad, c);
|
|
158
|
+
along += len;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (mk.dest) { const [x, y] = px(mk.dest); diamond(x, y, r * 1.5 + 1.5, DARK); diamond(x, y, r * 1.5, [90, 220, 255]); }
|
|
162
|
+
if (mk.you) { const [x, y] = px(mk.you); disc(x, y, r + 1.5, DARK); disc(x, y, r, [255, 210, 60]); disc(x, y, r / 3, DARK); }
|
|
163
|
+
return rgba;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The map as sixel placeholder lines (sixel.mjs) fitted to maxCols x maxRows, or null when sixel is off. */
|
|
167
|
+
function sixelLines(m, mk, key, maxCols, maxRows) {
|
|
168
|
+
if (!sixelOn()) return null;
|
|
169
|
+
const { w: cw, h: ch } = cellSize();
|
|
170
|
+
const scale = Math.min((maxCols * cw) / m.w, (maxRows * ch) / m.h);
|
|
171
|
+
const tw = Math.max(1, Math.round(m.w * scale)), th = Math.max(1, Math.round(m.h * scale));
|
|
172
|
+
return sixelImage(`map|${m.w}|${tw}x${th}|${key}`, () => {
|
|
173
|
+
const fitKey = `${m.w}|${tw}x${th}`;
|
|
174
|
+
let base = fitted.get(fitKey);
|
|
175
|
+
if (!base) {
|
|
176
|
+
if (fitted.size >= FIT_MAX) fitted.delete(fitted.keys().next().value);
|
|
177
|
+
fitted.set(fitKey, (base = resample(m, tw, th)));
|
|
178
|
+
}
|
|
179
|
+
return { w: tw, h: th, rgba: paintPixels(base, tw, th, mk) };
|
|
180
|
+
}, maxCols, maxRows);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---- the lines a pane draws ---------------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The world map for a detail pane, as lines no wider than `maxCols` and no more than `maxRows` of them (the
|
|
187
|
+
* last a legend): you pinned where you stand, `dest` (a zone) pinned if given, the `legs` of a planned trip
|
|
188
|
+
* (or the journey under way) drawn as a route. Null without colour, a pack, a position, or room.
|
|
189
|
+
*/
|
|
190
|
+
export function worldMapLines(app, { dest = null, legs = null, maxCols, maxRows } = {}) {
|
|
191
|
+
if (!useColor || !(maxCols >= 10) || !(maxRows >= 6)) return null;
|
|
192
|
+
const m = smallMap();
|
|
193
|
+
if (!m) return null;
|
|
194
|
+
const mk = marks(app, dest, legs);
|
|
195
|
+
if (!mk.you && !mk.dest) return null;
|
|
196
|
+
const key = [mk.you, mk.dest, ...mk.route].map(at3).join('|');
|
|
197
|
+
const rows = maxRows - 1;
|
|
198
|
+
let art = sixelLines(sixelMap() ?? m, mk, key, maxCols, rows);
|
|
199
|
+
if (!art) {
|
|
200
|
+
const ck = `${maxCols}x${rows}|${key}`;
|
|
201
|
+
art = cache.get(ck) ?? remember(ck, halfBlockLines(m, mk, maxCols, rows));
|
|
202
|
+
}
|
|
203
|
+
const legend = `${yellow(YOU.glyph)} you${mk.dest ? ` ${cyan(DEST.glyph)} destination` : ''}${mk.route.length ? dim(' ┄ route') : ''}`;
|
|
204
|
+
return [...art, legend];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** `lines` with the map under them, as large as the detail pane has room for; `lines` alone when there is none. */
|
|
208
|
+
export function withWorldMap(app, lines, opts = {}) {
|
|
209
|
+
const L = app.layout?.();
|
|
210
|
+
if (!L || !(L.detailW > 2)) return lines;
|
|
211
|
+
const w = L.detailW - 2, used = lines.reduce((n, l) => n + Math.max(1, Math.ceil(vis(l) / w)), 0);
|
|
212
|
+
const map = worldMapLines(app, { ...opts, maxCols: w, maxRows: L.mainH - used - 1 });
|
|
213
|
+
return map ? [...lines, '', ...map] : lines;
|
|
214
|
+
}
|