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.
Files changed (51) hide show
  1. package/README.md +43 -10
  2. package/bin/azsh.mjs +15 -1
  3. package/package.json +5 -2
  4. package/src/cli/hdArt.mjs +1 -1
  5. package/src/cli/iconArt.mjs +11 -1
  6. package/src/cli/modelArt.mjs +7 -4
  7. package/src/cli/oocPrep.mjs +32 -0
  8. package/src/cli/repl.mjs +16 -0
  9. package/src/cli/sixel.mjs +26 -14
  10. package/src/cli/text.mjs +16 -0
  11. package/src/cli/tui.mjs +33 -9
  12. package/src/cli/tuiAutopilot.mjs +17 -8
  13. package/src/cli/tuiBags.mjs +37 -4
  14. package/src/cli/tuiCheck.mjs +276 -15
  15. package/src/cli/tuiCity.mjs +31 -31
  16. package/src/cli/tuiCompanionKit.mjs +6 -6
  17. package/src/cli/tuiCompanions.mjs +189 -29
  18. package/src/cli/tuiCreate.mjs +7 -0
  19. package/src/cli/tuiDungeon.mjs +56 -20
  20. package/src/cli/tuiFight.mjs +63 -21
  21. package/src/cli/tuiFightPanes.mjs +15 -6
  22. package/src/cli/tuiFloor.mjs +20 -8
  23. package/src/cli/tuiGear.mjs +9 -10
  24. package/src/cli/tuiLoot.mjs +5 -6
  25. package/src/cli/tuiMap.mjs +214 -0
  26. package/src/cli/tuiPaperdoll.mjs +6 -5
  27. package/src/cli/tuiPartyStage.mjs +36 -7
  28. package/src/cli/tuiPet.mjs +16 -8
  29. package/src/cli/tuiProfessions.mjs +23 -23
  30. package/src/cli/tuiSaves.mjs +14 -0
  31. package/src/cli/tuiSearch.mjs +4 -5
  32. package/src/cli/tuiSettings.mjs +3 -4
  33. package/src/cli/tuiStage.mjs +72 -16
  34. package/src/cli/tuiTalents.mjs +3 -4
  35. package/src/cli/tuiViews.mjs +371 -84
  36. package/src/cli/tuiWiki.mjs +2 -3
  37. package/src/cli/tuiWorld.mjs +88 -5
  38. package/src/data/gear-art.bin.br +0 -0
  39. package/src/data/hd-art.json +36 -36
  40. package/src/data/model-art-worn.bin.br +0 -0
  41. package/src/data/model-art.bin.gz +0 -0
  42. package/src/data/tables/creatures.json.gz +0 -0
  43. package/src/data/world-map-sixel.bin.br +0 -0
  44. package/src/data/world-map.bin.gz +0 -0
  45. package/src/domain/character/character.ts +6 -0
  46. package/src/domain/pet/pet.ts +31 -0
  47. package/src/engine/combat/pickAction.ts +3494 -3487
  48. package/src/engine/combat/sessionChase.ts +12 -1
  49. package/src/engine/game/loadReconcile.ts +21 -1
  50. package/src/engine/game/pets.ts +33 -1
  51. package/src/engine/game/types.ts +4 -0
@@ -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(`+${moneyText(r.goldGained)}`);
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 swing, for the lunge. */
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
- if (ev.kind === 'melee' && ev.actor) mem.lunge?.set(ev.actor, ev.t); // only a weapon swing lunges; spells, strikes and thorns do not
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 slot = castSlot(v, now);
659
+ const recent = mem.casts?.get(key);
623
660
  if (iconsOn()) {
624
- // Icons: a strip of 4x4 icons beside the cast slot, each one's time (or stacks) under it on a third line.
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
- return [cut(`${head}${after}`, W), cut(` ${fit(slot, CAST_W)} ${top}`, W), cut(` ${' '.repeat(CAST_W)} ${bottom}`, W)];
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 row, the auras and the floor.
703
- // The state row is there idle or casting, so a cast never moves the auras under it. The ability bar goes
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 + 1 + auraH + 1 + barRows;
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 = zone + 4, floorRow = auraTop + auraH, barTop = floorRow + 1;
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 row every plate keeps.
778
- const slot = castSlot(v, now);
779
- if (slot && !dead) put(c, col.x, stateRow, cut(slot, col.plateW));
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 ? ` · ${moneyText(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
- const cells = t.rows.map((r) => `${app.itemLabel(r.item)}${r.count > 1 ? dim(` x${r.count}`) : ''}`);
238
- const rowsAvail = L.logH - 1;
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`);
@@ -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
- if (app.floor?.dungeonId === run.dungeonId) return app.floor;
86
- // Bodies of stops already cleared stay dead on the rebuilt floor, with any loose kills from before.
87
- const cleared = new Set(app.floorKills?.[run.dungeonId] ?? []);
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.floorKills ??= {};
162
- app.floorKills[f.dungeonId] = [...new Set([...(app.floorKills[f.dungeonId] ?? []), ...guids])];
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; }
@@ -1,7 +1,6 @@
1
- // The Gear tab's lower rows: what repairs cost and where, the ammo you shoot, and the food, water and elixirs 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 ${moneyText(r.spent)}.` : '');
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 Gear tab: mend everything you wear at the repairer the quote prices against. */
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 ${moneyText(r.spent)}.` : '');
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 ${moneyText(cost)} at ${q.vendorName}`) : dim(`Repairing this piece costs about ${moneyText(cost)}.`)];
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 Gear tab. */
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 ? yellow(moneyText(q.cost)) : dim('nothing to mend')}${q.cost ? dim(q.vendorName ? ` at ${q.vendorName}` : ' nobody here repairs') : ''}`,
136
- detail: () => [bold('Repair'), '', q.cost ? `Mending everything you wear costs ${moneyText(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')],
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);
@@ -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')} ${moneyText(r.copper)}`);
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: yellow(moneyText(L.copper)),
73
- detail: () => [bold('Money'), moneyText(L.copper)],
74
- act: (a2) => { const r = a2.g.takeLootMoney(); if (a2.result(r)) { noteLoot(a2, [], r.copper); a2.say(` ${yellow('You loot')} ${moneyText(r.copper)}`); if (r.emptied) a2.pop(); } },
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
+ }