azeroth-shell 0.2.0 → 0.4.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 (55) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +43 -10
  3. package/bin/azsh.mjs +15 -1
  4. package/package.json +7 -3
  5. package/src/cli/hdArt.mjs +1 -1
  6. package/src/cli/iconArt.mjs +11 -1
  7. package/src/cli/modelArt.mjs +7 -4
  8. package/src/cli/oocPrep.mjs +32 -0
  9. package/src/cli/repl.mjs +16 -0
  10. package/src/cli/sixel.mjs +26 -14
  11. package/src/cli/text.mjs +16 -0
  12. package/src/cli/tui.mjs +40 -13
  13. package/src/cli/tuiAutopilot.mjs +17 -8
  14. package/src/cli/tuiBags.mjs +37 -4
  15. package/src/cli/tuiBulk.mjs +83 -4
  16. package/src/cli/tuiCheck.mjs +328 -19
  17. package/src/cli/tuiCity.mjs +31 -31
  18. package/src/cli/tuiCompanionKit.mjs +26 -8
  19. package/src/cli/tuiCompanions.mjs +189 -29
  20. package/src/cli/tuiCreate.mjs +7 -0
  21. package/src/cli/tuiDungeon.mjs +113 -33
  22. package/src/cli/tuiFight.mjs +63 -21
  23. package/src/cli/tuiFightPanes.mjs +15 -6
  24. package/src/cli/tuiFloor.mjs +20 -8
  25. package/src/cli/tuiGear.mjs +9 -10
  26. package/src/cli/tuiLoot.mjs +5 -6
  27. package/src/cli/tuiMap.mjs +214 -0
  28. package/src/cli/tuiPaperdoll.mjs +6 -5
  29. package/src/cli/tuiPartyStage.mjs +36 -7
  30. package/src/cli/tuiPet.mjs +16 -8
  31. package/src/cli/tuiProfessions.mjs +23 -23
  32. package/src/cli/tuiSaves.mjs +14 -0
  33. package/src/cli/tuiSearch.mjs +4 -5
  34. package/src/cli/tuiSettings.mjs +3 -4
  35. package/src/cli/tuiStage.mjs +72 -16
  36. package/src/cli/tuiTalents.mjs +3 -4
  37. package/src/cli/tuiViews.mjs +374 -85
  38. package/src/cli/tuiWiki.mjs +2 -3
  39. package/src/cli/tuiWorld.mjs +88 -5
  40. package/src/data/gear-art.bin.br +0 -0
  41. package/src/data/hd-art.json +36 -36
  42. package/src/data/model-art-worn.bin.br +0 -0
  43. package/src/data/model-art.bin.gz +0 -0
  44. package/src/data/tables/creatures.json.gz +0 -0
  45. package/src/data/world-map-sixel.bin.br +0 -0
  46. package/src/data/world-map.bin.gz +0 -0
  47. package/src/domain/character/character.ts +6 -0
  48. package/src/domain/pet/pet.ts +31 -0
  49. package/src/engine/combat/pickAction.ts +3494 -3487
  50. package/src/engine/combat/sessionChase.ts +12 -1
  51. package/src/engine/game/loadReconcile.ts +21 -1
  52. package/src/engine/game/pets.ts +33 -1
  53. package/src/engine/game/professions.ts +4 -0
  54. package/src/engine/game/squadQuestShare.ts +13 -1
  55. package/src/engine/game/types.ts +4 -0
@@ -9,16 +9,21 @@ import { iconCells } from './iconArt.mjs';
9
9
  import { App } from './tui.mjs';
10
10
  import { bootDataStore } from './bootData.mjs';
11
11
  import { newGameState } from '../engine/game.ts';
12
- import { strip, wrapLines } from './text.mjs';
12
+ import { newGameStateFromWorldBot } from '../engine/game/newGameState.ts';
13
+ import { maxPower } from '../domain/character/character.ts';
14
+ import { startFight } from './tuiFight.mjs';
15
+ import { strip, vis, wrapLines, coinText } from './text.mjs';
13
16
  import { visibleBars } from './tuiBars.mjs';
14
17
  import { defaultConditionsForSpell } from './tuiRotationCond.mjs';
15
18
  import { creatureArt, hasArt, artWidth } from './modelArt.mjs';
16
19
  import { nativeDisplayId } from '../domain/character/displayForm.ts';
17
20
  import { paperdollLayers, paperdollArt } from './paperdoll.mjs';
21
+ import { playerArt, questDialog } from './tuiViews.mjs';
22
+ import { worldMapLines } from './tuiMap.mjs';
18
23
  import { iconsOn, withIcon, wrapIconLines } from './iconArt.mjs';
19
24
  import { addItem, countItem } from '../domain/items/inventory.ts';
20
- import { moneyText as moneyTextOf } from '../domain/economy/money.ts';
21
25
  import { offeredQuests } from '../quests/quests.ts';
26
+ import { isTalkToNpcQuest } from '../domain/quests/questlog.ts';
22
27
 
23
28
  const showFrames = process.argv.includes('--frames');
24
29
  let failures = 0;
@@ -75,13 +80,38 @@ check('rotation tab shows rules', (term.press('6'), /Combat \(checked/.test(fram
75
80
  check('talents tab renders', (term.press('7'), /Talents unlock|point/.test(frame())));
76
81
  check('0 opens the tenth tab', (term.press('0'), app.tab === 9));
77
82
  const lastTab = app.tabs.length - 1;
78
- check('character tab renders', (app.switchTab(lastTab), /Attributes/.test(frame())));
79
- check('the character tab draws your body', !hasArt(nativeDisplayId(app.ch.race, app.ch.gender)) || /[▀▄]/.test(frame()));
83
+ check('the last tab is Settings', (app.switchTab(lastTab), app.top().id === 'settings' && /Auto-loot/.test(frame())));
84
+ check('the character tab heads with your name and lists your gear', (app.switchTab(3), frame().includes(app.ch.name) && /Chest/.test(strip(frame()))));
85
+ check('s on the character tab opens your stats', (term.press('s'), app.top().id === 'char' && /Attributes/.test(frame())));
86
+ check('the stats page draws your body', !hasArt(nativeDisplayId(app.ch.race, app.ch.gender)) || /[▀▄]/.test(frame()));
80
87
  {
81
88
  // Starting gear is a shirt, pants, boots and a weapon or two: at least three of them have an appearance overlay.
82
89
  const p = paperdollLayers(app.ch);
83
90
  check('your starting gear stacks on your body', !p || (p.layers.filter((l) => /^\d/.test(l.key)).length >= 3 && !!paperdollArt(app.ch, 20, 12)));
84
91
  }
92
+ {
93
+ // A form or a costume is the model you are drawn as: each one's model is in the pack, and a fight's live
94
+ // view (the form, a Polymorph debuff) beats the saved character. Cite: displayForm.ts wornDisplay.
95
+ const drawn = (view) => playerArt(app, 20, 12, view)?.lines.join('\n');
96
+ const as = (displayId, height = 1) => creatureArt(displayId, 20, Math.round(12 * height))?.join('\n');
97
+ const view = (o) => ({ buffs: [], debuffs: [], ...o });
98
+ const flipOut = app.ds.creature(app.ds.spell(8219)?.effects.find((e) => e.kind === 'transform')?.creatureEntry)?.displayId;
99
+ const saved = app.ch.activeAuras;
100
+ app.ch.activeAuras = [...(saved ?? []), { spellId: 8219, expiresAt: Date.now() + 60_000 }];
101
+ const costumed = drawn(null) === as(flipOut);
102
+ app.ch.activeAuras = saved;
103
+ check('every form and costume model is in the art pack', [892, 8571, 2281, 2289, 632, 2428, 4613, 15374, 15375, 864, 857, flipOut].every((id) => hasArt(id)));
104
+ check('cat form in a fight draws you as the cat', drawn(view({ form: 1 })) === as(892, 0.6));
105
+ check('the cat stands lower than you do on two legs', playerArt(app, 20, 12, view({ form: 1 })).lines.length < playerArt(app, 20, 12, view({ form: 0 })).lines.length);
106
+ check('a Polymorph on you draws the sheep over your form, critter-sized', drawn(view({ form: 1, debuffs: [{ spellId: 118 }] })) === as(857, 0.35));
107
+ check('a costume aura draws its model', costumed);
108
+ check('out of form you are in your gear again', drawn(view({ form: 0 })) === paperdollArt(app.ch, 20, 12)?.join('\n'));
109
+ }
110
+ {
111
+ // What a fight turns into or brings in is drawn too: Dathrohan's morph to Balnazzar, a shaman's totem, a
112
+ // witch doctor's ward. None is spawned anywhere, so the bake follows each kit. Cite: bake-model-art.mjs addFightCreatures.
113
+ check('a boss morph, a totem and a ward have art', [10813, 3903, 3560].every((e) => hasArt(app.ds.creature(e)?.displayId)));
114
+ }
85
115
  if (iconsOn()) {
86
116
  // A big window: the Gear tab is the character sheet and the Talents tab the three trees, both icon grids
87
117
  // the arrows walk in two dimensions. At the usual 110x34 both list instead (the checks above).
@@ -90,7 +120,8 @@ if (iconsOn()) {
90
120
  const slot = () => app.rowsOf(app.top())[app.top().cursor]?.cell?.slot;
91
121
  const rowIndexOfSlot = (s) => app.rowsOf(app.top()).findIndex((r) => r.cell?.slot === s);
92
122
  app.switchTab(3);
93
- check('a big window draws the Gear tab as the sheet', !!app.top().gridNav && /Equipment/.test(frame()) && /Head/.test(frame()));
123
+ check('a big window draws the Character tab as the sheet', !!app.top().gridNav && frame().includes(app.ch.name) && /Head/.test(frame()));
124
+ check('the sheet keeps your stats under the tooltip', /Strength/.test(frame()) && /Attack pwr/.test(frame()));
94
125
  app.top().cursor = 0; app.render();
95
126
  term.press('shift-right');
96
127
  const across = slot();
@@ -114,15 +145,16 @@ if (iconsOn()) {
114
145
  const next = app.rowsOf(app.top())[app.top().cursor].node;
115
146
  check('Shift+Right walks to the next talent on the tier', next.tree === first.tree && next.t.tier === first.t.tier && next.t.col > first.t.col && frame().includes(next.t.name));
116
147
  term.cols = cols; term.rows = rows;
117
- app.switchTab(lastTab);
148
+ app.switchTab(3); term.press('s');
118
149
  // A tooltip's long sentence wraps beside its icon while they share rows, not back under the art.
119
150
  const tip = wrapIconLines(withIcon('ability_rogue_ambush', ['Ambush', 'Ambush the target, causing 250% weapon damage plus 230 to the target. Must be stealthed and behind the target.']), 36);
120
151
  check('tooltip text wraps beside its icon', tip.slice(0, 4).every((l) => l.startsWith('\x1b[38;2;')) && tip.length > 4);
121
152
  }
122
153
  {
123
154
  const charRows = app.rowsOf(app.top()).map((r) => strip(r.text)).join('|');
124
- check('the character tab lists weapon skills with Defense and the hand in use', /Weapon skills\|\s+Defense\s+\d+\/\d+\|/.test(charRows) && /\d+\/\d+( \+\d+)? +main hand/.test(charRows));
155
+ check('the stats page lists weapon skills with Defense and the hand in use', /Weapon skills\|\s+Defense\s+\d+\/\d+\|/.test(charRows) && /\d+\/\d+( \+\d+)? +main hand/.test(charRows));
125
156
  }
157
+ home(); app.switchTab(lastTab);
126
158
  term.press('right'); check('Right wraps to the first tab', app.tab === 0);
127
159
  term.press('left'); check('Left wraps to the last tab', app.tab === lastTab);
128
160
  term.press('escape'); check('Esc on a tab returns to World', app.tab === 0);
@@ -320,6 +352,7 @@ if (app.auto) {
320
352
  show('autopilot between fights');
321
353
  app.fightPane = 'loot'; app.render();
322
354
  check('between fights the autopilot keeps the fight pane', /Loot this sitting/.test(strip(frame())));
355
+ if (iconsOn() && app.lootTally?.rows.length) check('the Loot pane draws an icon beside each item', /Loot this sitting[^\n]*\n ▀▀▀▀ \S/.test(strip(frame())));
323
356
  term.press('tab'); check('Tab between autopilot fights cycles the pane, not the tabs', app.fightPane === 'run' && !app.auto.browsing);
324
357
  check('the Run pane lists the planner steps with the fights folded', /^ Run\s/m.test(strip(frame())) && autoCtl.ledger.some((r) => r.kind === 'fight' && /won|lost|stalemate/.test(strip(r.body))));
325
358
  show('autopilot run pane');
@@ -331,7 +364,8 @@ if (app.auto) {
331
364
  check('the Quests pane lists the quests the run works on', /^ Quests\s+next: /m.test(strip(frame())) && app.g.state.questlog.quests.some((qs) => qs.status !== 'turnedIn' && strip(frame()).includes(ds.quest(qs.entry).title)));
332
365
  show('autopilot quests pane');
333
366
  app.fightPane = 'bags'; app.render();
334
- check('the Bags pane shows the bags with a cursor', /^ Bags\s/m.test(strip(frame())) && /▶ /.test(strip(frame())));
367
+ check('the Bags pane shows the bags with a cursor', /^ Bags\s/m.test(strip(frame())) && /^▶/m.test(strip(frame())));
368
+ if (iconsOn()) check('the Bags pane draws an icon beside each item', /^▶▀▀▀▀ \S/m.test(strip(frame())));
335
369
  const bagCursor = app.bagsPane.cursor; term.press('down');
336
370
  check('Down moves the Bags pane cursor', app.bagsPane.cursor > bagCursor && !!app.auto);
337
371
  const logBefore = app.log.length; term.press('return');
@@ -406,6 +440,13 @@ if (doRow >= 0) {
406
440
  // Rotation editing through the menu: toggle a rule, add one, give it a condition, delete it.
407
441
  home(); term.press('6');
408
442
  show('rotation');
443
+ check('rotation phases start closed', !app.rowsOf(app.top()).some((r) => r.data?.rule));
444
+ app.top().cursor = rowIndex((r) => r.data?.toggle && r.data.phase === 'pull'); term.press('return');
445
+ check('Enter on Pull opens its rules', app.rowsOf(app.top()).some((r) => r.data?.rule && r.data.phase === 'pull') && rowIndex((r, t) => /\+ Add a rule to pull/.test(t)) > 0);
446
+ term.press('return');
447
+ check('Enter again closes it', !app.rowsOf(app.top()).some((r) => r.data?.rule));
448
+ app.top().cursor = rowIndex((r) => r.data?.toggle && r.data.phase === 'combat'); term.press('return');
449
+ show('rotation, combat open');
409
450
  const ruleRow = app.rowsOf(app.top()).findIndex((r) => r.data?.rule);
410
451
  const ruleData = app.rowsOf(app.top())[ruleRow].data;
411
452
  app.top().cursor = ruleRow; term.press('return');
@@ -655,7 +696,18 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
655
696
  if (hire >= 0) {
656
697
  app.top().cursor = hire; term.press('return'); show('hire list');
657
698
  check('the hire list opened', app.top().id === 'hire' && rowIndex(() => true) >= 0);
658
- check('hire rows name their trades', /(Mining|Herb|Skin|Tailor|Smith|Engi|Alchemy|Enchant|Leather) \d+/.test(strip(app.rowsOf(app.top()).find((r) => !r.header)?.text ?? '')));
699
+ check('hire rows name their trades', /(Mining|Herb|Skin|Tailor|Smith|Engi|Alchemy|Enchant|Leather) \d+/.test(strip(app.rowsOf(app.top())[app.top().cursor]?.text ?? '')));
700
+ const hireRows = () => app.rowsOf(app.top()).filter((r) => !r.header && r.data);
701
+ check('the hire list is the whole faction, every level', hireRows().length > 1000 && hireRows().some((r) => /^\S+\s+L1 /.test(strip(r.text))) && hireRows().some((r) => /^\S+\s+L60 /.test(strip(r.text))));
702
+ check('the hire list opens on your level', /^\S+\s+L(8|9|10|11|12) /.test(strip(app.rowsOf(app.top())[app.top().cursor]?.text ?? '')));
703
+ term.press('right');
704
+ check('Right steps to the DPS tab', app.top().title === 'Hire · DPS' && hireRows().every((r) => /^\S+\s+L\d+ \S+\s+dps /.test(strip(r.text))));
705
+ term.press('t'); term.press('t');
706
+ check('t narrows to one kit', app.top().title === 'Hire · DPS · Shabby' && hireRows().length > 0 && hireRows().every((r) => / dps\s+Shabby /.test(strip(r.text))));
707
+ term.press('left'); term.press('left'); term.press('right');
708
+ check('the role tabs wrap, All is the start', app.top().title === 'Hire · Shabby');
709
+ term.press('T'); term.press('T');
710
+ check('T steps the kit back to any', app.top().title === 'Hire');
659
711
  term.press('return'); show('candidate page');
660
712
  check('Enter opens the candidate page', app.top().id.startsWith('cand-'));
661
713
  check('the candidate page lists professions and stats', app.rowsOf(app.top()).some((r) => r.header && strip(r.text) === 'Professions') && rowIndex((r, t) => /^Health \d+/.test(t)) >= 0);
@@ -743,6 +795,16 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
743
795
  }
744
796
  const squad = g.state.squad ?? [];
745
797
  check(`two companions are seated (${squad.length})`, squad.length >= 2);
798
+ if (iconsOn() && squad[0]) {
799
+ // c on a companion's page opens their character sheet: the Character tab's paperdoll, for them.
800
+ const [cols, rows] = [term.cols, term.rows];
801
+ term.cols = 160; term.rows = 54;
802
+ home(); app.switchTab(tabOf('companions'));
803
+ app.top().cursor = rowIndex((r, t) => t.startsWith(squad[0].name)); term.press('return'); term.press('c'); show('companion sheet');
804
+ check('c on a companion opens their sheet as the paperdoll grid', app.top().id === `sheet-${squad[0].id}` && !!app.top().gridNav && frame().includes(squad[0].name) && /Attack pwr/.test(frame()));
805
+ term.cols = cols; term.rows = rows; home();
806
+ }
807
+ check('money reads in coins, an empty copper left off', strip(coinText(250000)) === (iconsOn() ? '25●' : '25g 0c') && strip(coinText(0, 'free')) === 'free');
746
808
  home(); app.switchTab(tabOf('companions'));
747
809
  term.press('m'); show('squad mode picker');
748
810
  check('m on Companions opens the squad mode picker', app.top().id === 'pick' && /How do your companions fight/.test(app.top().title));
@@ -814,7 +876,7 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
814
876
  // ---- dungeons: the Deadmines as a text run ------------------------------------------------------------------
815
877
  {
816
878
  const g = app.g;
817
- const { hasFlag } = await import('../world/instances.ts');
879
+ const { hasFlag, isComplete } = await import('../world/instances.ts');
818
880
  g.state.char.level = 20; g.state.char.gold = 10_000_000;
819
881
  const bag = ds.items().find((i) => i.slot === 'bag' && i.itemClass !== 11 && (i.bagSlots ?? 0) >= 10 && (i.requiredLevel ?? 1) <= 20 && !i.allowClass?.length);
820
882
  for (let i = 0; i < 4; i++) { g.state.inventory = addItem(g.state.inventory, bag.entry, 1, 1); g.equip(bag.entry); }
@@ -856,6 +918,15 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
856
918
  }
857
919
  check('a cast shows as a bar under the caster', !cast || strip(frame()).includes(cast.casting.name.slice(0, 8)));
858
920
  if (cast) show('party stage cast');
921
+ // The last foe falls and the win holds: the party stays where it fought, it does not walk back to its start.
922
+ const plates = () => strip(frame()).split('\n').find((l) => /\bT \S/.test(l) && l.includes(app.ch.name))?.replace(/ ◀/, '').replace(/\s+$/, '') ?? ''; // the dead foe loses its mark
923
+ let before = '';
924
+ for (let k = 0; k < 3000 && app.fight && !app.fight.ended; k++) {
925
+ const left = app.fight.session.enemiesView().filter((v) => v.hp > 0);
926
+ if (left.length === 1 && left[0].hp < left[0].maxHp * 0.25) before = plates();
927
+ app.fight.tick();
928
+ }
929
+ if (app.fight?.holding && before) { const after = plates(); check(`the party holds its ground over the win${after === before ? '' : `\n ${before}\n ${after}`}`, after === before); }
859
930
  }
860
931
  finishFight();
861
932
  check(`stop ${n} is cleared and the run window comes back`, g.state.instance.defeated.length === n && app.top()?.id === 'dungeon');
@@ -876,12 +947,22 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
876
947
  check('finish instantly ends on the run report', app.top()?.id === 'run-summary' && /Run report|Cleared|Partial|Wiped/.test(strip(frame())) && (app.dungeonPulls?.length ?? 0) > 2);
877
948
  term.press('escape');
878
949
  const cleared = g.state.instance.defeated.length;
950
+ const allDown = isComplete(ds.instance('deadmines'), g.state.instance);
951
+ {
952
+ const { portalRows } = await import('./tuiDungeon.mjs');
953
+ const texts = portalRows(app).map((r) => strip(r.text));
954
+ check('inside a run, the World tab still lists the zone\'s dungeons, the one you are in marked', texts.some((t) => /The Deadmines .*you are in it/.test(t)) && !texts.some((t) => /Back into/.test(t)));
955
+ }
879
956
  term.press('L');
880
957
  check('L leaves and the run is held', !g.state.instance && !!g.state.instanceSaves?.deadmines);
881
- check('the portal row shows the held run', /your run is held/.test(strip(frame())));
958
+ check('a 5-man holds the run for two hours', Math.abs(g.state.instanceSaves.deadmines.expiresAtMs - Date.now() - 2 * 3600_000) < 60_000);
959
+ check('the portal row shows the held run', allDown ? /cleared, resets in/.test(strip(frame())) : /your run is held/.test(strip(frame())));
882
960
  app.top().cursor = rowIndex((r) => r.data?.dungeon?.id === 'deadmines'); term.press('return');
883
- check('the entry card offers to go back in', /Go back in/.test(strip(frame())));
884
- app.top().cursor = rowIndex((r, t) => /^Go back in/.test(t)); term.press('return');
961
+ if (allDown) {
962
+ check('the entry card of a cleared run offers to go back in', /Go back in/.test(strip(frame())) && /You cleared it/.test(strip(frame())));
963
+ app.top().cursor = rowIndex((r, t) => /^Go back in/.test(t)); term.press('return');
964
+ }
965
+ check('a held run is walked straight back into', g.state.instance?.dungeonId === 'deadmines');
885
966
  check('going back in keeps the cleared stops', g.state.instance?.defeated.length === cleared);
886
967
  term.press('R'); term.press('return');
887
968
  check('R resets the run', g.state.instance?.defeated.length === 0);
@@ -927,6 +1008,16 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
927
1008
  app.top().cursor = rowIndex((r, t) => /^Raid group/.test(t)); term.press('return'); term.press('f');
928
1009
  check('f fills the 20-man group', g.state.raidLineup?.size === 20 && g.state.raidLineup.ids.length > 0);
929
1010
  term.press('c'); home();
1011
+ // Travel splits a zone's neighbors by how you get there: Grom'gol's zeppelins are not a walk to Tirisfal.
1012
+ app.switchTab(app.tabs.findIndex((t) => t.view.id === 'travel')); app.render();
1013
+ {
1014
+ const texts = app.rowsOf(app.top()).map((r) => strip(r.text));
1015
+ const foot = texts.indexOf('On foot'), boats = texts.indexOf('Boats and zeppelins');
1016
+ const tirisfal = texts.findIndex((t) => t.startsWith('Tirisfal Glades'));
1017
+ check('Travel lists the zeppelin to Tirisfal under boats, Duskwood on foot', foot >= 0 && boats > foot && tirisfal > boats
1018
+ && /Undercity/.test(texts[tirisfal]) && texts.findIndex((t) => t.startsWith('Duskwood')) < boats);
1019
+ }
1020
+ home();
930
1021
  g.state.char.zoneId = 'westfall';
931
1022
  }
932
1023
 
@@ -974,6 +1065,27 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
974
1065
  check('p adds a named companion rotation', g.companionRotationProfiles(comp().id).some((p) => p.name === 'Second' && p.active));
975
1066
  term.press('w'); term.press('return');
976
1067
  check('w back to your own rotation', !app.tabs[5].view.whose);
1068
+ // Spellbook tabs: General, then one per talent tree; [ ] and Left Right on the strip turn them.
1069
+ home(); term.press('5'); show('spellbook');
1070
+ {
1071
+ const rs = () => app.rowsOf(app.top());
1072
+ const stripAt = () => rs().findIndex((r) => r.data?.strip);
1073
+ const spells = () => rs().slice(stripAt() + 1).filter((r) => !r.header).map((r) => strip(r.text));
1074
+ const trees = ds.talentTrees(app.ch.cls).map((t) => t.name);
1075
+ check('spellbook has General and a tab per talent tree', stripAt() > 0 && ['General', ...trees].every((n) => strip(rs()[stripAt()].text).includes(n)));
1076
+ const first = spells();
1077
+ check(`the book opens on a tab with spells (${first.join(', ')})`, first.length > 0);
1078
+ // ] through every tab and back: each spell sits on exactly one tab, and the class spells are not on General.
1079
+ const seen = [];
1080
+ for (let i = 0; i <= trees.length; i++) { seen.push(...spells()); term.press(']'); }
1081
+ check('] walks every tab and comes back round', JSON.stringify(spells()) === JSON.stringify(first) && new Set(seen).size === seen.length);
1082
+ // This warrior's General is empty, so the book opens on Arms.
1083
+ check('Heroic Strike is filed under Arms, not General', /General 0\s+Arms [1-9]/.test(strip(rs()[stripAt()].text)) && first.some((n) => n.startsWith('Heroic Strike')));
1084
+ app.top().cursor = stripAt(); term.press('left');
1085
+ const back = spells();
1086
+ term.press('right');
1087
+ check('Left Right on the strip turn the book, not the window', app.top().id === 'spells' && JSON.stringify(spells()) === JSON.stringify(first) && JSON.stringify(back) !== JSON.stringify(first));
1088
+ }
977
1089
  // Player action bars: bar 1 from the Spells tab, and the fight's number row reads it.
978
1090
  home(); term.press('5'); term.press('b'); show('player bars');
979
1091
  check('b on Spells opens your action bars', app.top().id === 'bars' && /Bar 1/.test(strip(frame())));
@@ -1032,7 +1144,21 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1032
1144
  g2.state.char.level = 12; g2.state.char.gold = 1_000_000;
1033
1145
  app2.render();
1034
1146
  const at = (pred) => app2.rowsOf(app2.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
1035
- app2.top().cursor = at((r) => r.data?.creature?.tameable && r.data.creature.level <= 12); term2.press('t');
1147
+ // One with an innate ability, so the pet page has an autocast row to toggle.
1148
+ const beast = () => at((r) => r.data?.creature?.tameable && r.data.creature.level <= 12 && r.data.creature.petDefaultSpells?.length);
1149
+ app2.top().cursor = beast(); term2.press('t');
1150
+ check('t refuses a hunter who has not learned Tame Beast', !g2.state.char.pet);
1151
+ // Taming the Beast (Dun Morogh): the rod goes on the Large Crag Boar the quest names, and t says so on its row.
1152
+ g2.state.char.level = 10; app2.result(g2.acceptQuest(6064));
1153
+ const boar = at((r) => r.data?.creature?.entry === 1126);
1154
+ check('the taming quest puts its rod on the beast it names', boar >= 0 && /t: use Taming Rod/.test(strip(app2.rowsOf(app2.top())[boar].text)));
1155
+ app2.top().cursor = boar; term2.press('t');
1156
+ check('t uses the rod and the quest completes', g2.state.questlog.quests.find((q) => q.entry === 6064)?.status === 'complete');
1157
+ g2.state.char.level = 12;
1158
+ g2.state.spellbook = { known: [...g2.state.spellbook.known, 1515] };
1159
+ app2.render();
1160
+ check('a hunter with Tame Beast sees t: tame on a beast', /t: tame/.test(strip(app2.rowsOf(app2.top())[beast()].text)));
1161
+ app2.top().cursor = beast(); term2.press('t');
1036
1162
  check('t on a tameable beast tames it', !!g2.state.char.pet);
1037
1163
  app2.top().cursor = at((r, t) => /^Pet:/.test(t)); term2.press('return');
1038
1164
  if (showFrames) console.log(`\n=== pet page ===\n${term2.text()}`);
@@ -1057,13 +1183,56 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1057
1183
  check('Dun Morogh lists a pet trainer', trainer > 0);
1058
1184
  if (trainer > 0) {
1059
1185
  app2.top().cursor = trainer; term2.press('return');
1060
- const rank = at((r, t) => !/level \d+/.test(t) && !/^✓/.test(t));
1186
+ check('the pet trainer offers to untrain the pet', at((r, t) => /^Untrain /.test(t)) > 0);
1187
+ const rank = at((r, t) => !/level \d+/.test(t) && !/^✓/.test(t) && !/^Untrain /.test(t));
1061
1188
  app2.top().cursor = rank; term2.press('return');
1062
1189
  check('a pet trainer rank can be bought', (g2.state.char.petSpells ?? []).length > 0);
1063
1190
  }
1064
1191
  term2.close?.();
1065
1192
  }
1066
1193
 
1194
+ // ---- what phonecraft's World tab, quest card and bag menu offer: weapon masters, ring picker, hearth, quest steps --
1195
+ {
1196
+ const term2 = new HeadlessTerm(110, 34);
1197
+ const app2 = new App({ term: term2, ds, repo });
1198
+ term2.open();
1199
+ const dwarf = ds.races().find((r) => r.name === 'Dwarf');
1200
+ app2.startGame(newGameState(ds, dwarf.id, 1, 'Portcheck', 98, 0), 'portcheck', Date.now(), true);
1201
+ const g2 = app2.g;
1202
+ g2.state.char.level = 20; g2.state.char.zoneId = 'ironforge';
1203
+ app2.switchTab(0); app2.render();
1204
+ const at = (pred) => app2.rowsOf(app2.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
1205
+ check('Ironforge lists its weapon masters on the World tab', at((r, t) => /Weapon Master|Weapon Trainer/i.test(t)) >= 0);
1206
+ check('no Horde trainer or vendor in an Alliance capital', at((r, t) => /Orgrimmar|Thunder Bluff/.test(t) && /</.test(t)) < 0);
1207
+ // Two rings on, a third from the bags asks which to replace instead of overwriting the first.
1208
+ const rings = ds.allItems().filter((it) => it.slot === 'finger' && (it.requiredLevel ?? 0) <= 20 && !it.unique).slice(0, 3);
1209
+ if (rings.length === 3) {
1210
+ for (const r of rings.slice(0, 2)) { g2.state.inventory = addItem(g2.state.inventory, r.entry, 1, 1); g2.equip(r.entry); }
1211
+ g2.state.inventory = addItem(g2.state.inventory, rings[2].entry, 1, 1);
1212
+ app2.switchTab(2); app2.render();
1213
+ app2.top().cursor = at((r, t) => t.includes(rings[2].name)); term2.press('return');
1214
+ check('a third ring asks which ring to replace', app2.top().id === 'pick' && /replace which/.test(strip(app2.top().title)));
1215
+ term2.press('down'); term2.press('return');
1216
+ check('the picked ring slot takes it', Object.values(g2.state.char.equipped).some((e) => (e?.entry ?? e) === rings[2].entry));
1217
+ } else check('three plain rings exist for the slot picker check', false);
1218
+ // The Hearthstone from the bags takes you home, as the Travel tab's row does.
1219
+ g2.state.char.hearthBind = { zone: 'dunmorogh', name: 'Thunderbrew Distillery' };
1220
+ app2.stack = []; app2.switchTab(2); app2.render();
1221
+ const hs = at((r, t) => /Hearthstone/.test(t));
1222
+ if (hs >= 0) {
1223
+ app2.top().cursor = hs; term2.press('return');
1224
+ check('Enter on the Hearthstone in the bags hearths you', g2.state.char.zoneId !== 'ironforge' || !!g2.state.char.journey);
1225
+ } else check('a new dwarf carries a Hearthstone', false);
1226
+ // A speak-to quest in the log gets its Speak row on the quest page.
1227
+ const talk = ds.quests().find((q) => isTalkToNpcQuest(q) && !q.goEnder && !(q.goEnderEntries ?? []).some((e) => e > 0) && (q.enderName || q.starterName));
1228
+ if (talk) {
1229
+ g2.state.questlog = { ...g2.state.questlog, quests: [...g2.state.questlog.quests, { entry: talk.entry, status: 'active', kills: {} }] };
1230
+ app2.stack = []; app2.push(questDialog(app2, talk, 'log')); app2.render();
1231
+ check('a speak-to quest offers Speak to its NPC on the quest page', at((r, t) => /^Speak to /.test(t)) >= 0);
1232
+ }
1233
+ term2.close?.();
1234
+ }
1235
+
1067
1236
  // Loot window: auto-loot off holds the corpse's drops and coin; Enter takes a row, a takes the rest, Esc leaves it.
1068
1237
  {
1069
1238
  home(); term.press('1');
@@ -1197,6 +1366,39 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1197
1366
  home();
1198
1367
  }
1199
1368
 
1369
+ // Destroy several: X marks stacks and G destroys them after a yes; Q opens on the quest leftovers, already marked.
1370
+ {
1371
+ const g = app.g;
1372
+ const { isRepeatableQuest, leftoverQuestItems } = await import('../domain/quests/questlog.ts');
1373
+ // Two grey items the bags do not already hold, so one stack each.
1374
+ const [linen, wool] = ds.allItems().filter((it) => it.quality === 0 && !it.slot && (it.stackable ?? 1) > 1 && countItem(g.state.inventory, it.entry) === 0).slice(0, 2);
1375
+ g.state.inventory = addItem(addItem(g.state.inventory, linen.entry, 3, 20), wool.entry, 2, 20);
1376
+ home(); app.switchTab(2); term.press('X'); show('destroy several');
1377
+ check('X opens Destroy with nothing marked', app.top().id === 'destroy-many' && /Nothing marked/.test(strip(frame())));
1378
+ for (const name of [linen.name, wool.name]) { app.top().cursor = rowIndex((r, t) => t.includes(name)); term.press('return'); }
1379
+ check('Enter marks two stacks', /2 stacks .* marked/.test(strip(frame())));
1380
+ term.press('G');
1381
+ check('G asks first', app.top().id === 'pick' && /Destroy 2 stacks/.test(strip(frame())));
1382
+ term.press('return');
1383
+ check('a yes destroys both marked stacks', countItem(g.state.inventory, linen.entry) === 0 && countItem(g.state.inventory, wool.entry) === 0 && app.top().id === 'destroy-many');
1384
+ term.press('escape');
1385
+ const open = new Set(g.state.questlog.quests.map((x) => x.entry));
1386
+ const q = ds.quests().find((d) => !isRepeatableQuest(d) && !open.has(d.entry) && d.itemTargets?.some((t) => ds.item(t.item)?.questItem) && !ds.quests().some((o) => o !== d && o.itemTargets?.some((t) => t.item === d.itemTargets[0].item)));
1387
+ const relic = q && q.itemTargets.find((t) => ds.item(t.item)?.questItem).item;
1388
+ if (relic) {
1389
+ g.state.questlog.quests.push({ entry: q.entry, status: 'turnedIn', kills: {}, objects: {} });
1390
+ g.state.inventory = addItem(g.state.inventory, relic, 1, 20);
1391
+ const left = leftoverQuestItems(g.state.questlog, g.state.inventory, (e) => ds.quest(e), (e) => ds.item(e), ds.quests());
1392
+ check('the turned-in quest leaves its item behind', left.some((r) => r.item === relic));
1393
+ term.press('Q'); show('quest leftovers');
1394
+ check('Q lists the leftover, marked', app.top().id === 'destroy-many' && rowIndex((r, t) => t.startsWith('✗') && t.includes(ds.item(relic).name)) > 0);
1395
+ term.press('G'); term.press('return');
1396
+ check('G and a yes destroy it', countItem(g.state.inventory, relic) === 0);
1397
+ term.press('escape');
1398
+ } else check('the data has a finished quest with a quest item to leave behind', false);
1399
+ home();
1400
+ }
1401
+
1200
1402
  // Gear upkeep: durability on the slots, repair one piece, repair all, load ammo, auto consumables.
1201
1403
  {
1202
1404
  const g = app.g;
@@ -1208,7 +1410,7 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1208
1410
  const slotRow = rowIndex((r) => r.data?.slot === worn);
1209
1411
  check('a worn-down slot shows its durability', / 1\/\d+/.test(strip(app.rowsOf(app.top())[slotRow].text)));
1210
1412
  const quote = g.repairQuote();
1211
- check('the Repair all row prices the bill', rowIndex((r, t) => t.startsWith('Repair all') && t.includes(moneyTextOf(quote.cost))) > 0);
1413
+ check('the Repair all row prices the bill', rowIndex((r, t) => t.startsWith('Repair all') && t.includes(strip(coinText(quote.cost)))) > 0);
1212
1414
  if (quote.vendorName) {
1213
1415
  app.top().cursor = slotRow; term.press('r');
1214
1416
  check('r on a slot repairs that piece', g.state.char.durability?.[worn] == null);
@@ -1372,15 +1574,15 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1372
1574
  home();
1373
1575
  }
1374
1576
 
1375
- // Cancel an aura: from the Character tab, and with c inside a fight.
1577
+ // Cancel an aura: from the Character tab's stats page, and with c inside a fight.
1376
1578
  {
1377
1579
  const g = app.g;
1378
1580
  const fort = ds.spell(1243);
1379
1581
  const give = () => { g.state.char.activeAuras = [...(g.state.char.activeAuras ?? []).filter((x) => x.spellId !== 1243), { spellId: 1243, expiresAt: Date.now() + 600000 }]; };
1380
1582
  give();
1381
- home(); app.switchTab(app.tabs.length - 1); show('character auras');
1583
+ home(); app.switchTab(3); term.press('s'); show('character auras');
1382
1584
  const at = rowIndex((r, t) => t.includes(fort.name));
1383
- check('the Character tab lists your auras', at > 0);
1585
+ check('the stats page lists your auras', at > 0);
1384
1586
  app.top().cursor = at; term.press('return');
1385
1587
  check('Enter on a buff cancels it', !(g.state.char.activeAuras ?? []).some((x) => x.spellId === 1243));
1386
1588
  give(); home(); term.press('1');
@@ -1512,6 +1714,21 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1512
1714
  home();
1513
1715
  }
1514
1716
 
1717
+ // The Travel tab's detail pane carries the world map (tuiMap.mjs): the terrain as half-block cells, you and the
1718
+ // destination pinned on it, the planned route dotted between them.
1719
+ {
1720
+ home(); term.press('8');
1721
+ const here = app.g.state.char.zoneId;
1722
+ const dest = (ds.zone(here)?.neighbors ?? []).map((id) => ds.zone(id)).find((z) => z && app.g.previewTravel(z.id).ok);
1723
+ app.top().cursor = rowIndex((r, t) => t.startsWith(dest.name)); app.render();
1724
+ const pane = strip(frame()).split('\n').map((l) => l.split('│')[1] ?? '').join('\n');
1725
+ check('the Travel tab draws the world map under a destination, you pinned on it', /▀/.test(pane) && pane.includes('●') && /you .*destination/.test(pane));
1726
+ const far = ds.zones().find((z) => z.continent !== ds.zone(here).continent && app.g.previewTravel(z.id).ok);
1727
+ const lines = worldMapLines(app, { dest: far, legs: app.g.previewTravel(far.id).legs, maxCols: 60, maxRows: 24 });
1728
+ check('a trip to the other continent draws its route, every line inside the room given', lines.length === 24 && lines.every((l) => vis(l) <= 60) && lines.slice(0, -1).join('').includes('·') && lines.join('').includes('◆'));
1729
+ home();
1730
+ }
1731
+
1515
1732
  // Real-time travel: with a 1x pace a trip takes time; the header counts down, the World tab carries the journey
1516
1733
  // card (speed, pause, arrive now, turn back), and the game loop lands it.
1517
1734
  {
@@ -2247,6 +2464,94 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
2247
2464
  term.press('?'); check('help overlay', app.top().id === 'help'); term.press('escape');
2248
2465
  check('help closed', app.stack.length === 0);
2249
2466
 
2467
+ // Pre-pull buffs: a mage buffs standing still, the autopilot drinks back what that cost, and the pull opens at
2468
+ // full mana on an attack. Casting the buffs phase on the fight's first ticks sent it in at three quarters.
2469
+ {
2470
+ const termB = new HeadlessTerm(110, 34);
2471
+ const appB = new App({ term: termB, ds, repo });
2472
+ termB.open();
2473
+ appB.startGame(newGameStateFromWorldBot(ds, { id: 'buffcheck', index: 0, name: 'Buffcheck', gender: 0, race: 1, cls: 8, faction: undefined, level: 20, specIdx: 0, seed: 7 }, 7), 'buffcheck', Date.now(), true);
2474
+ const gB = appB.g;
2475
+ const wolf = ds.zone(gB.state.char.zoneId).huntable[0];
2476
+ const hasAI = () => (gB.state.char.activeAuras ?? []).some((a) => ds.spell(a.spellId)?.name === 'Arcane Intellect');
2477
+ const full = () => gB.state.char.power === maxPower(gB.state.char, ds);
2478
+ let pulledFull = null, pulledBuffed = null;
2479
+ const ap = appB.cmds.createAutopilot(`farm ${ds.creature(wolf).name}`, { maxSteps: 6, liveFight: (step, settle) => { pulledFull ??= full(); pulledBuffed ??= hasAI(); settle({ outcome: 'win' }); return { ok: true }; } });
2480
+ ap.next();
2481
+ check('autopilot buffs before the pull, then rests instead of pulling', ap.lastKind === 'buff' && hasAI() && !full());
2482
+ for (let i = 0; i < 5 && pulledFull == null; i++) ap.next();
2483
+ check('autopilot pulls buffed and at full mana', pulledFull === true && pulledBuffed === true);
2484
+ gB.state.char.activeAuras = (gB.state.char.activeAuras ?? []).filter((a) => ds.spell(a.spellId)?.name !== 'Arcane Intellect');
2485
+ gB.state.char.activeBuffs = (gB.state.char.activeBuffs ?? []).filter((id) => ds.spell(id)?.name !== 'Arcane Intellect');
2486
+ const from = appB.log.length;
2487
+ startFight(appB, wolf);
2488
+ const buffedFirst = hasAI();
2489
+ for (let i = 0; i < 2000 && appB.fight; i++) appB.fight.tick();
2490
+ const casts = appB.log.slice(from).map(strip).filter((l) => /^\s*[\d.]+s You cast /.test(l));
2491
+ check('a plain fight buffs before it starts and opens on an attack', buffedFirst && casts.length > 0 && !casts.some((l) => /Arcane Intellect/.test(l)));
2492
+ // A dungeon stop: the same buff, then the party drinks back to the rest line before the pull, and again before
2493
+ // the next one. Pulling straight after the armour and conjures opened Rhahk'Zor at 761 of 1966.
2494
+ const { openRun, pullStop } = await import('./tuiDungeon.mjs');
2495
+ const { nextEncounter } = await import('../world/instances.ts');
2496
+ for (const [cls, role] of [[1, 'tank'], [2, 'healer']]) gB.recruitCompanion(cls, role, 'twink');
2497
+ for (const c of gB.state.squad) c.level = 20;
2498
+ gB.state.char.activeAuras = []; gB.state.char.activeBuffs = [];
2499
+ gB.regenTick(600, true);
2500
+ const dm = ds.instance('deadmines');
2501
+ check('a level 20 mage party can enter the Deadmines', gB.enterInstance('deadmines', Date.now()).ok);
2502
+ appB.stack = []; openRun(appB);
2503
+ const openPct = [];
2504
+ for (let i = 0; i < 2; i++) {
2505
+ pullStop(appB, dm, nextEncounter(dm, gB.state.instance));
2506
+ const pv = appB.fight?.session.playerView();
2507
+ openPct.push(pv ? pv.power / maxPower(gB.state.char, ds) : 0);
2508
+ for (let n = 0; n < 8000 && appB.fight; n++) appB.fight.tick();
2509
+ appB.fight?.release?.();
2510
+ }
2511
+ check('a dungeon stop buffs, then drinks back before the pull', hasAI() && openPct[0] >= 0.9);
2512
+ check('the next stop rests back up too', openPct[1] >= 0.9);
2513
+ }
2514
+
2515
+ // A foe that resets ends the fight. The engine keeps the session open with the mob passive at full HP and the
2516
+ // rotation idle, which held a Razormane Scout fight for 970 s. Here a Quilboar is pinned 50 yd out once it is
2517
+ // in combat, so it cannot close and evades; the fight must settle as a stalemate a few seconds later.
2518
+ {
2519
+ const termQ = new HeadlessTerm(110, 34);
2520
+ const appQ = new App({ term: termQ, ds, repo });
2521
+ termQ.open();
2522
+ appQ.startGame(newGameStateFromWorldBot(ds, { id: 'quietcheck', index: 0, name: 'Quietcheck', gender: 0, race: 2, cls: 7, faction: undefined, level: 11, specIdx: 0, seed: 5 }, 5), 'quietcheck', Date.now(), true);
2523
+ const boar = ds.creatures().find((c) => c.name === 'Razormane Quilboar');
2524
+ const from = appQ.log.length;
2525
+ startFight(appQ, boar.entry);
2526
+ const s = appQ.fight.session, side = s.enemiesView()[0].side;
2527
+ let engagedAt = null, ticks = 0;
2528
+ for (; ticks < 1200 && appQ.fight && !appQ.fight.ended; ticks++) {
2529
+ if (engagedAt == null && s.enemiesView()[0].engaged) engagedAt = s.t;
2530
+ s.setUnitPosYd('player', { x: 0, y: 0 });
2531
+ if (engagedAt != null) s.setUnitPosYd(side, { x: 50, y: 0 });
2532
+ appQ.fight.tick();
2533
+ }
2534
+ const said = appQ.log.slice(from).map(strip);
2535
+ const reset = said.some((l) => /cannot reach you and resets/.test(l));
2536
+ check(`a foe that resets ends the fight as a stalemate (${(s.t / 1000).toFixed(1)} s, reset ${reset})`,
2537
+ reset && said.some((l) => /resets and the fight is over/.test(l)) && said.some((l) => /^Stalemate against/.test(l)) && s.t < 20_000);
2538
+ appQ.fight?.release?.();
2539
+ }
2540
+
2541
+ // c on the World tab saves, lets go of the character and hands the screen back to the picker.
2542
+ {
2543
+ const termL = new HeadlessTerm(110, 34);
2544
+ const repoL = { saved: 0, unlocked: [], async save() { this.saved++; }, async list() { return []; }, async load() { return null; }, unlock(s) { this.unlocked.push(s); } };
2545
+ const appL = new App({ term: termL, ds, repo: repoL });
2546
+ termL.open();
2547
+ appL.startGame(newGameState(ds, human.id, 1, 'Logouttest', 777, 0), 'logouttest', Date.now(), true);
2548
+ let picked = 0;
2549
+ appL.toPicker = async () => { picked++; };
2550
+ termL.press('c');
2551
+ await new Promise((r) => setImmediate(r));
2552
+ check('c on World saves, unlocks and goes back to the character list', !appL.g && picked === 1 && repoL.saved > 0 && repoL.unlocked.includes('logouttest') && !appL.timers.length);
2553
+ }
2554
+
2250
2555
  // Sixel art, last: once on it stays on for the process. The terminal says it draws sixel (DA1 parameter 4);
2251
2556
  // art then travels as placeholders whose markers the terminal layer takes out and places.
2252
2557
  {
@@ -2254,10 +2559,14 @@ check('help closed', app.stack.length === 0);
2254
2559
  const art = creatureArt(2332, 28, 14);
2255
2560
  const mark = art?.[0]?.match(/\x1b\[9999;(\d+);0m/);
2256
2561
  check('sixel on: a creature is a placeholder of marked blank rows', !!mark && art.every((l, r) => l.includes(`\x1b[9999;${mark[1]};${r}m`)) && /^\x1bP0;1;0q"1;1;\d+;\d+#/.test(imageOf(+mark[1])?.sixel ?? ''));
2562
+ // The shipped worn pack fills what the downloaded hd pack lacks: forms and costumes draw sharp too.
2563
+ check('sixel on: cat and bear form draw as pixels, not half-blocks', [892, 2281].every((id) => /\x1b\[9999;/.test(creatureArt(id, 28, 14)?.[0] ?? '')));
2257
2564
  const placed = new Map();
2258
2565
  const rows = art.map((l, r) => takeMarks(`ab${l}${l}`, 5 + r, placed));
2259
2566
  check('the same picture twice on a row is two placements, at their columns, markers gone', placed.size === 2 && [...placed.values()].every((p) => p.y === 5 && p.bottom) && [...placed.values()].map((p) => p.x).join() === `2,${2 + artWidth(art)}` && rows.every((l) => !l.includes('\x1b[9999')));
2260
2567
  check('a marker rides one cell in a grid, not every cell after it', splitCells(art[0]).filter((c) => c.includes('\x1b[9999')).length === 1);
2568
+ const map = worldMapLines(app, { maxCols: 40, maxRows: 12 });
2569
+ check('the world map is a placeholder too, with its legend under it', !!map && map.length === 12 && /\x1b\[9999;/.test(map[0]) && /^\x1bP0;1;0q"1;1;\d+;\d+#/.test(imageOf(+map[0].match(/\x1b\[9999;(\d+);/)[1])?.sixel ?? '') && /you/.test(strip(map[11])));
2261
2570
  check('a wrapped line does not carry a marker to the next', wrapLines(`${art[0]} ${'word '.repeat(20)}`, 40).slice(1).every((l) => !l.includes('\x1b[9999')));
2262
2571
  const icon = iconCells('spell_holy_holysmite', 6, '', [{ x: 4, y: 2, w: 2 }]);
2263
2572
  check('an icon is a placeholder too, with its badge corner see-through', !!icon && icon.length === 3 && icon[0][0].includes('\x1b[9999') && /#\d+;2;/.test(imageOf(+icon[0][0].match(/\x1b\[9999;(\d+);/)[1])?.sixel ?? ''));