azeroth-shell 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +362 -85
  2. package/bin/azsh.mjs +35 -19
  3. package/package.json +12 -1
  4. package/src/autopilot/plan.ts +8 -11
  5. package/src/autopilot/view.ts +12 -11
  6. package/src/cli/combatLogFormat.ts +22 -0
  7. package/src/cli/gzLoader.mjs +47 -6
  8. package/src/cli/hdArt.mjs +81 -0
  9. package/src/cli/iconArt.mjs +145 -0
  10. package/src/cli/iconGrid.mjs +100 -0
  11. package/src/cli/modelArt.mjs +298 -0
  12. package/src/cli/oocPrep.mjs +32 -0
  13. package/src/cli/paperdoll.mjs +76 -0
  14. package/src/cli/perfCheck.mjs +56 -0
  15. package/src/cli/repl.mjs +48 -7
  16. package/src/cli/rotationText.mjs +6 -6
  17. package/src/cli/sixel.mjs +319 -0
  18. package/src/cli/term.mjs +106 -12
  19. package/src/cli/text.mjs +21 -3
  20. package/src/cli/tui.mjs +262 -102
  21. package/src/cli/tuiAutopilot.mjs +356 -0
  22. package/src/cli/tuiBags.mjs +37 -4
  23. package/src/cli/tuiBg.mjs +370 -41
  24. package/src/cli/tuiCheck.mjs +633 -37
  25. package/src/cli/tuiCity.mjs +146 -41
  26. package/src/cli/tuiCompanionKit.mjs +6 -6
  27. package/src/cli/tuiCompanions.mjs +211 -41
  28. package/src/cli/tuiCreate.mjs +230 -0
  29. package/src/cli/tuiDungeon.mjs +57 -21
  30. package/src/cli/tuiFight.mjs +467 -88
  31. package/src/cli/tuiFightPanes.mjs +42 -14
  32. package/src/cli/tuiFloor.mjs +21 -9
  33. package/src/cli/tuiGear.mjs +17 -14
  34. package/src/cli/tuiLoot.mjs +6 -6
  35. package/src/cli/tuiMap.mjs +214 -0
  36. package/src/cli/tuiPaperdoll.mjs +108 -0
  37. package/src/cli/tuiPartyStage.mjs +206 -0
  38. package/src/cli/tuiPet.mjs +16 -8
  39. package/src/cli/tuiProfessions.mjs +27 -25
  40. package/src/cli/tuiRotationCond.mjs +390 -0
  41. package/src/cli/tuiSaves.mjs +24 -1
  42. package/src/cli/tuiSearch.mjs +79 -27
  43. package/src/cli/tuiSettings.mjs +3 -4
  44. package/src/cli/tuiStage.mjs +360 -0
  45. package/src/cli/tuiTalentTree.mjs +124 -0
  46. package/src/cli/tuiTalents.mjs +3 -4
  47. package/src/cli/tuiViews.mjs +633 -172
  48. package/src/cli/tuiWiki.mjs +2 -3
  49. package/src/cli/tuiWorld.mjs +90 -7
  50. package/src/data/gear-art.bin.br +0 -0
  51. package/src/data/hd-art.json +114 -0
  52. package/src/data/icon-art.bin.gz +0 -0
  53. package/src/data/model-art-worn.bin.br +0 -0
  54. package/src/data/model-art.bin.gz +0 -0
  55. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  56. package/src/data/tables/creatures.json.gz +0 -0
  57. package/src/data/world-map-sixel.bin.br +0 -0
  58. package/src/data/world-map.bin.gz +0 -0
  59. package/src/domain/bg/match.ts +85 -24
  60. package/src/domain/bg/strategies.ts +144 -29
  61. package/src/domain/bg/types.ts +2 -0
  62. package/src/domain/bg/wsg.ts +35 -20
  63. package/src/domain/character/character.ts +6 -0
  64. package/src/domain/pet/pet.ts +31 -0
  65. package/src/domain/quests/doQuest.ts +24 -6
  66. package/src/domain/rotation/build.ts +3031 -3031
  67. package/src/domain/rotation/classes/paladin.ts +16 -11
  68. package/src/domain/rotation/evaluate.ts +3 -0
  69. package/src/engine/combat/applySpell.ts +20 -9
  70. package/src/engine/combat/applySpellLate.ts +16 -12
  71. package/src/engine/combat/areaAura.ts +284 -283
  72. package/src/engine/combat/fighterFactory.ts +1 -1
  73. package/src/engine/combat/pickAction.ts +3494 -3449
  74. package/src/engine/combat/procsDamage.ts +31 -1
  75. package/src/engine/combat/sessionChase.ts +19 -2
  76. package/src/engine/combat/sessionMotionKit.ts +40 -2
  77. package/src/engine/game/loadReconcile.ts +21 -1
  78. package/src/engine/game/pets.ts +33 -1
  79. package/src/engine/game/types.ts +4 -0
  80. package/src/quests/quests.ts +60 -0
@@ -2,14 +2,28 @@
2
2
  // quest, a vendor and a trainer, pages through quest text, runs a live fight tick by tick, runs the autopilot
3
3
  // through a live fight, and edits the rotation through its menu. Run with `npm run check:tui`; pass --frames
4
4
  // to see every screen.
5
- import { HeadlessTerm, decodeKeys } from './term.mjs';
5
+ import { HeadlessTerm, decodeKeys, takeMarks } from './term.mjs';
6
+ import { noteDeviceAttributes, imageOf } from './sixel.mjs';
7
+ import { splitCells } from './iconGrid.mjs';
8
+ import { iconCells } from './iconArt.mjs';
6
9
  import { App } from './tui.mjs';
7
10
  import { bootDataStore } from './bootData.mjs';
8
11
  import { newGameState } from '../engine/game.ts';
9
- import { strip } 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';
10
16
  import { visibleBars } from './tuiBars.mjs';
17
+ import { defaultConditionsForSpell } from './tuiRotationCond.mjs';
18
+ import { creatureArt, hasArt, artWidth } from './modelArt.mjs';
19
+ import { nativeDisplayId } from '../domain/character/displayForm.ts';
20
+ import { paperdollLayers, paperdollArt } from './paperdoll.mjs';
21
+ import { playerArt, questDialog } from './tuiViews.mjs';
22
+ import { worldMapLines } from './tuiMap.mjs';
23
+ import { iconsOn, withIcon, wrapIconLines } from './iconArt.mjs';
11
24
  import { addItem, countItem } from '../domain/items/inventory.ts';
12
- import { moneyText as moneyTextOf } from '../domain/economy/money.ts';
25
+ import { offeredQuests } from '../quests/quests.ts';
26
+ import { isTalkToNpcQuest } from '../domain/quests/questlog.ts';
13
27
 
14
28
  const showFrames = process.argv.includes('--frames');
15
29
  let failures = 0;
@@ -40,12 +54,20 @@ const frame = () => term.text();
40
54
  const home = () => { let n = 0; while (app.stack.length && n++ < 10) term.press('escape'); };
41
55
  const show = (label) => { if (showFrames) console.log(`\n=== ${label} ===\n${frame()}`); };
42
56
  const rowIndex = (pred) => app.rowsOf(app.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
43
- /** Run a live fight to its end by hand. */
57
+ /** Run a live fight to its end by hand, through the hold a win keeps on screen after it. */
44
58
  const finishFight = () => { let n = 0; while (app.fight && n < 3000) { app.fight.tick(); n++; } return n; };
45
59
 
46
60
  show('world');
47
61
  check('header shows the character', frame().includes('Tuitest') && frame().includes('Elwynn Forest'));
48
62
  check('world lists creatures', rowIndex((r, s) => r.data?.creature && /Kobold/.test(s)) >= 0);
63
+ {
64
+ // The People rows draw their NPC: the art pack bakes every vendor, trainer and innkeeper a zone lists.
65
+ const vendor = rowIndex((r, s) => /<General Supplies>/.test(s));
66
+ const saved = app.top().cursor;
67
+ app.top().cursor = vendor; app.render();
68
+ check('a vendor row draws the merchant beside their title', vendor >= 0 && /[▀▄]/.test(frame()));
69
+ app.top().cursor = saved; app.render();
70
+ }
49
71
  {
50
72
  const rs = app.rowsOf(app.top()).filter((r) => r.header).map((r) => strip(r.text));
51
73
  check('world lists quests last, after creatures', rs.indexOf('Quests') > rs.findIndex((s) => s.startsWith('Creatures')));
@@ -53,15 +75,81 @@ check('world lists creatures', rowIndex((r, s) => r.data?.creature && /Kobold/.t
53
75
 
54
76
  // Tabs 2..9 and 0 all render without throwing; Left/Right and Esc move between them.
55
77
  for (const k of ['2', '3', '4', '5', '6', '7', '8', '9', '0']) { term.press(k); show(`tab ${k}`); }
56
- check('rotation tab shows rules', (term.press('6'), /Combat \(checked/.test(frame())));
78
+ // Rules are two lines each with their icons, so the Combat phase can start past the first window.
79
+ check('rotation tab shows rules', (term.press('6'), /Combat \(checked/.test(frame()) || (term.press('pagedown'), /Combat \(checked/.test(frame()))));
57
80
  check('talents tab renders', (term.press('7'), /Talents unlock|point/.test(frame())));
58
81
  check('0 opens the tenth tab', (term.press('0'), app.tab === 9));
59
82
  const lastTab = app.tabs.length - 1;
60
- check('character tab renders', (app.switchTab(lastTab), /Attributes/.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()));
87
+ {
88
+ // Starting gear is a shirt, pants, boots and a weapon or two: at least three of them have an appearance overlay.
89
+ const p = paperdollLayers(app.ch);
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)));
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
+ if (iconsOn()) {
111
+ // A big window: the Gear tab is the character sheet and the Talents tab the three trees, both icon grids
112
+ // the arrows walk in two dimensions. At the usual 110x34 both list instead (the checks above).
113
+ const [cols, rows] = [term.cols, term.rows];
114
+ term.cols = 160; term.rows = 54;
115
+ const slot = () => app.rowsOf(app.top())[app.top().cursor]?.cell?.slot;
116
+ const rowIndexOfSlot = (s) => app.rowsOf(app.top()).findIndex((r) => r.cell?.slot === s);
117
+ app.switchTab(3);
118
+ check('a big window draws the Character tab as the sheet', !!app.top().gridNav && frame().includes(app.ch.name) && /Head/.test(frame()));
119
+ check('the sheet keeps your stats under the tooltip', /Strength/.test(frame()) && /Attack pwr/.test(frame()));
120
+ app.top().cursor = 0; app.render();
121
+ term.press('shift-right');
122
+ const across = slot();
123
+ term.press('shift-left', 'shift-down', 'shift-down', 'shift-down', 'shift-down', 'shift-down', 'shift-down', 'shift-down');
124
+ const below = slot();
125
+ term.press('down');
126
+ check('Shift+arrows walk the sheet: head across to hands, down the left to the main hand', across === 'hands' && below === 'mainhand' && slot() === 'offhand');
127
+ term.press('right');
128
+ check('Right still changes the tab on the sheet', app.tab === 4);
129
+ app.switchTab(3);
130
+ app.top().cursor = rowIndexOfSlot('mainhand');
131
+ term.press('return');
132
+ check('Enter on a sheet slot opens its picker', app.top().id === 'slot-mainhand');
133
+ home();
134
+ app.switchTab(6);
135
+ const names = app.ds.talentTrees(app.ch.cls).map((t) => t.name);
136
+ check('the Talents tab draws every tree side by side', !!app.top().gridNav && names.every((n) => frame().includes(n)));
137
+ app.top().cursor = 0; app.render();
138
+ const first = app.rowsOf(app.top())[0].node;
139
+ term.press('shift-right');
140
+ const next = app.rowsOf(app.top())[app.top().cursor].node;
141
+ 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));
142
+ term.cols = cols; term.rows = rows;
143
+ app.switchTab(3); term.press('s');
144
+ // A tooltip's long sentence wraps beside its icon while they share rows, not back under the art.
145
+ 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);
146
+ check('tooltip text wraps beside its icon', tip.slice(0, 4).every((l) => l.startsWith('\x1b[38;2;')) && tip.length > 4);
147
+ }
61
148
  {
62
149
  const charRows = app.rowsOf(app.top()).map((r) => strip(r.text)).join('|');
63
- 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));
150
+ 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));
64
151
  }
152
+ home(); app.switchTab(lastTab);
65
153
  term.press('right'); check('Right wraps to the first tab', app.tab === 0);
66
154
  term.press('left'); check('Left wraps to the last tab', app.tab === lastTab);
67
155
  term.press('escape'); check('Esc on a tab returns to World', app.tab === 0);
@@ -88,6 +176,8 @@ if (firstQuest > 0) {
88
176
  check('Enter on Accept closed the dialog', app.stack.length === 0);
89
177
  term.press('2'); show('quest log');
90
178
  check('quest log lists it', /1 of 20/.test(frame()));
179
+ const zoneOffers = offeredQuests(app.ds, app.ch.zoneId, app.ch, app.g.state.questlog).filter((o) => o.canAccept).length;
180
+ check('quest log also lists what the zone offers', !zoneOffers || (/Available in /.test(frame()) && app.rowsOf(app.top()).filter((r) => !r.header).length > 1));
91
181
  }
92
182
 
93
183
  // Esc closes a window; the log window opens and closes.
@@ -130,9 +220,18 @@ const mobRow = app.rowsOf(app.top()).findIndex((r) => r.data?.creature);
130
220
  app.top().cursor = mobRow; term.press('return');
131
221
  check('live fight started', !!app.fight);
132
222
  show('fight start');
223
+ {
224
+ const art = creatureArt(384, 20, 10); // Hogger
225
+ check('creature art scales to the room it gets', !!art && art.length <= 10 && art.every((l) => strip(l).length === strip(art[0]).length && strip(l).length <= 20));
226
+ const foeDisplay = ds.creature(app.fight?.enemyEntry)?.displayId;
227
+ check('the fight shows the foe beside the frames', !hasArt(foeDisplay) || /[▀▄]/.test(frame()));
228
+ }
133
229
  const barName = app.fight?.abilityBar()[0]?.spell.name;
134
230
  const hasBar = () => barName && frame().split('\n').some((l) => /^\s*1\s/.test(strip(l)) && strip(l).includes(barName));
135
- check('auto-play hides the ability bar', !hasBar());
231
+ // The duel stage keeps the ability bar on under auto-play too (its cooldowns are worth watching), with the
232
+ // plates under the sprites' feet: your name, level and class, then the bars; and a floor under it all.
233
+ check('auto-play shows the stage with the ability bar', hasBar() && new RegExp(`^\\s+${app.ch.name} L\\d+ Warrior`, 'm').test(strip(frame())));
234
+ check('the stage stands on a floor', /[▁▂_]{40}/.test(strip(frame())));
136
235
  let ticks = 0;
137
236
  let paneFrames = null;
138
237
  while (app.fight && ticks < 3000) {
@@ -194,34 +293,108 @@ check('command line ran rest', app.log.some((l) => /You rest for 30 s/.test(stri
194
293
 
195
294
  // Autopilot: steps by hand; its fights are live sessions that we tick to the end.
196
295
  home(); term.press('1'); term.press('a');
197
- check('autopilot started', !!app.auto);
296
+ check('a opens the pre-flight card', app.top()?.id === 'autopilot-preflight' && /Stops\s+.*deaths in a row/.test(strip(frame())));
297
+ show('autopilot pre-flight');
298
+ app.fightSpeed = 8; // at 4x and up the combat log folds to one line a fight
299
+ const ticksBefore = app.log.filter((l) => /^\s*\d+\.\ds /.test(strip(l))).length;
300
+ term.press('return');
301
+ check('Enter on the card starts the autopilot', !!app.auto && app.stack.length === 0);
198
302
  const autoCtl = app.auto;
199
- let steps = 0, liveFights = 0, fightTicks = 0;
303
+ let steps = 0, liveFights = 0, fightTicks = 0, heldWin = false;
304
+ /** The first win: the fight stays on the stage with the foe at 0 and the drops beside it, the planner waits,
305
+ * and the screen moves on once the hold's ticks are spent. */
306
+ const checkWinHold = () => {
307
+ const f = app.fight, h = f.holding;
308
+ const stageRows = () => { const rows = strip(frame()).split('\n'); return rows.slice(2, rows.findIndex((l) => /^── Log/.test(l))); };
309
+ check('a win holds the stage with the foe dead at 0', f.ended && stageRows().some((l) => / dead\b/.test(l)) && stageRows().some((l) => / 0\/\d+/.test(l)));
310
+ const stepBefore = autoCtl.planner.step; autoCtl.step();
311
+ check('the planner waits out the hold', autoCtl.planner.step === stepBefore && app.fight === f);
312
+ if (h.loot.length) {
313
+ while (app.fight === f && f.holdMs() < h.stagger + 300) f.tick();
314
+ const name = ds.item(h.loot[0].item)?.name;
315
+ check(`what it dropped slides in on the stage (${name})`, app.fight === f && stageRows().some((l) => l.includes(name)));
316
+ show('autopilot win hold');
317
+ }
318
+ let n = 0; while (app.fight === f && n++ < 200) f.tick();
319
+ check(`the hold lets go on its own (${n} ticks)`, !app.fight && n <= h.ticks);
320
+ };
200
321
  while (app.auto && steps < 60) {
201
322
  if (app.fight) {
202
- if (!liveFights) { show('autopilot fight'); check('an autopilot fight says what it is for', /^ Fighting .* (for .+|hunting for xp)\s*$/m.test(strip(frame()))); }
203
- liveFights++; fightTicks += finishFight(); continue;
323
+ if (!liveFights) {
324
+ show('autopilot fight');
325
+ check('an autopilot fight says what it is for', /(\d+\/\d+|hunting for xp|questing in)/.test(strip(frame()).split('\n')[2] ?? ''));
326
+ check('the run panel shows the foe beside the fight', !hasArt(ds.creature(app.fight.enemyEntry)?.displayId) || /[▀▄]/.test(frame()));
327
+ check('the fight draws inside the run panel, without a head line of its own', app.inlineFight() && app.compactHeader() && / ◀ /.test(strip(frame())) && !/^ Fighting /m.test(strip(frame())));
328
+ check('a run keeps only the XP line for a header', app.compactHeader() && /^ XP /.test(strip(frame())) && !/1 World/.test(strip(frame())));
329
+ check('your plate sits on the stage, once', (strip(frame()).match(new RegExp(`^\\s+${app.ch.name} L\\d+ `, 'mg')) ?? []).length === 1);
330
+ term.press('w'); check('w opens the full fight view', !!app.auto.watchFight && !app.inlineFight() && /^ Fighting /m.test(strip(frame())));
331
+ term.press('escape'); check('Esc comes back to the run panel without running', !!app.fight && !!app.auto && app.inlineFight());
332
+ term.press('3'); check('a tab can be browsed during an autopilot fight, under the run strip', app.auto.browsing && !!app.fight && /Autopilot.*back to the run/.test(strip(frame()).split('\n')[3] ?? ''));
333
+ term.press('w'); check('w returns to the panel with the fight still on', !app.auto.browsing && app.inlineFight());
334
+ }
335
+ liveFights++;
336
+ if (!heldWin) { const f = app.fight; while (app.fight === f && !f.ended) { f.tick(); fightTicks++; } if (f.holding) { heldWin = true; checkWinHold(); } }
337
+ fightTicks += finishFight(); continue;
204
338
  }
205
339
  if (!app.auto.step()) break;
206
340
  steps++;
207
341
  }
342
+ check('the autopilot won a fight to hold on', heldWin);
208
343
  show('autopilot');
209
344
  if (app.auto) {
210
345
  if (app.fight) { liveFights++; fightTicks += finishFight(); }
211
- check('between fights the autopilot keeps its own panel', /^\s*Autopilot\s/m.test(strip(frame())) && strip(frame()).includes(app.ch.name));
346
+ check('between fights the autopilot keeps its own panel', app.compactHeader() && /^ Now\s/m.test(strip(frame())) && strip(frame()).includes(app.ch.name));
212
347
  show('autopilot between fights');
348
+ app.fightPane = 'loot'; app.render();
349
+ check('between fights the autopilot keeps the fight pane', /Loot this sitting/.test(strip(frame())));
350
+ 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())));
351
+ term.press('tab'); check('Tab between autopilot fights cycles the pane, not the tabs', app.fightPane === 'run' && !app.auto.browsing);
352
+ 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))));
353
+ show('autopilot run pane');
354
+ term.press('tab'); check('Tab moves on to the Quests pane', app.fightPane === 'quests');
355
+ term.press('tab'); term.press('tab'); check('Tab wraps back to the log after Bags', app.fightPane === 'log');
356
+ check('the run panel says what is now, what is next and how the quest in hand stands', /^ Now\s/m.test(strip(frame())) && /^ Next\s/m.test(strip(frame())) && /(\d+\/\d+|ready to turn in)/.test(strip(frame()).split('\n')[2] ?? '') && !/stops at/.test(strip(frame())));
357
+ check('the panel is five lines at most between fights', strip(frame()).split('\n').slice(2, 12).filter((l) => l.trim()).length <= 5);
358
+ app.fightPane = 'quests'; app.render();
359
+ 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)));
360
+ show('autopilot quests pane');
361
+ app.fightPane = 'bags'; app.render();
362
+ check('the Bags pane shows the bags with a cursor', /^ Bags\s/m.test(strip(frame())) && /^▶/m.test(strip(frame())));
363
+ if (iconsOn()) check('the Bags pane draws an icon beside each item', /^▶▀▀▀▀ \S/m.test(strip(frame())));
364
+ const bagCursor = app.bagsPane.cursor; term.press('down');
365
+ check('Down moves the Bags pane cursor', app.bagsPane.cursor > bagCursor && !!app.auto);
366
+ const logBefore = app.log.length; term.press('return');
367
+ check('Enter on a bag row acts while the run plays on', (app.log.length > logBefore || app.stack.length > 0) && !!app.auto);
368
+ home(); show('autopilot bags pane');
369
+ app.fightPane = 'log';
370
+ term.press('space'); const stepBefore = autoCtl.planner.step; autoCtl.step();
371
+ check('space pauses the run between fights', autoCtl.paused && autoCtl.planner.step === stepBefore && /PAUSED/.test(strip(frame())));
372
+ term.press('space'); check('space resumes it', !autoCtl.paused);
213
373
  term.press('3'); check('tabs can be viewed while the autopilot runs', app.tab === 2 && !!app.auto && /Bags|bag/i.test(strip(frame())));
214
- term.press('w'); check('w goes back to watching the run', !app.auto.browsing && /^\s*Autopilot\s/m.test(strip(frame())));
374
+ check('the run strip sits under the tabs while browsing', /Autopilot.*fights.*back to the run/.test(strip(frame()).split('\n')[3] ?? ''));
375
+ show('autopilot run strip');
376
+ // A browsed tab works during the run: Enter acts on the row (here a bag item) instead of jumping back.
377
+ { const logBefore = app.log.length; app.top().cursor = 1; term.press('return'); check('Enter acts on a row while browsing during the run', !!app.auto && app.auto.browsing && (app.log.length > logBefore || app.stack.length > 0)); home(); }
378
+ term.press('7'); check('the Talents tab opens during the run', app.tab === 6 && app.auto.browsing);
379
+ term.press('escape'); check('Esc on a tab comes back to the run instead of stopping it', !!app.auto && !app.auto.browsing);
380
+ term.press('3');
381
+ term.press('w'); check('w goes back to watching the run', !app.auto.browsing && app.compactHeader() && /^ Now\s/m.test(strip(frame())));
215
382
  term.press('l'); check('log window opens over the autopilot', app.top().id === 'log');
216
383
  term.press('escape'); check('Esc closes the log and leaves the autopilot running', app.stack.length === 0 && !!app.auto);
217
- term.press('1');
384
+ term.press('1'); term.press('w'); // back on the panel, where Esc stops the run
218
385
  }
219
386
  check(`autopilot fought ${liveFights} live fights over ${steps} steps (${fightTicks} ticks)`, liveFights > 0 && app.log.some((l) => /hunting:|quest target:/.test(strip(l))));
220
387
  check('autopilot counted its fights', autoCtl.fights === liveFights);
221
- if (app.auto) { term.press('escape'); check('Esc stops the autopilot', !app.auto); }
388
+ check('at 8x the combat log folds to one line a fight', app.log.filter((l) => /^\s*\d+\.\ds /.test(strip(l))).length === ticksBefore && app.log.some((l) => /^Victory|^Defeat/.test(strip(l))));
389
+ if (app.auto) {
390
+ term.press('escape'); check('Esc stops the autopilot', !app.auto);
391
+ check('the end card sums the run up', app.top()?.id === 'autopilot-done' && /Autopilot done/.test(strip(frame())) && /Stopped: you stopped it/.test(strip(frame())));
392
+ show('autopilot end card');
393
+ term.press('return'); check('Enter closes the end card', app.stack.length === 0);
394
+ }
222
395
 
223
396
  // Autopilot fight interrupted by Esc stops the run.
224
- term.press('a');
397
+ term.press('a'); term.press('return');
225
398
  while (app.auto && !app.fight && app.auto.step()) { /* reach a fight */ }
226
399
  if (app.fight) { term.press('escape'); check('Esc in an autopilot fight stops the run', !app.fight && !app.auto); }
227
400
  else { if (app.auto) app.auto.stop(); check('autopilot reached a fight to interrupt', false); }
@@ -235,6 +408,13 @@ else { if (app.auto) app.auto.stop(); check('autopilot reached a fight to interr
235
408
  check('dying on a quest did not stop the run', !lost.done);
236
409
  }
237
410
 
411
+ // The windowed run has no step cap: the Stop after setting ends it, on the run's real clock.
412
+ {
413
+ const timed = app.cmds.createAutopilot('', { maxSteps: Infinity, clock: () => 25 * 3_600_000, liveFight: () => ({ ok: true }) });
414
+ timed.next();
415
+ check('a run stops on the Stop after clock, not a step count', timed.steps === Infinity && timed.stopReason?.reason === 'timeUp');
416
+ }
417
+
238
418
  // Do Quest: d on a quest log row runs the autopilot pinned to that quest, and clears its blacklist entry.
239
419
  home(); term.press('2');
240
420
  const doRow = rowIndex((r) => r.data?.qs?.status === 'active');
@@ -255,6 +435,13 @@ if (doRow >= 0) {
255
435
  // Rotation editing through the menu: toggle a rule, add one, give it a condition, delete it.
256
436
  home(); term.press('6');
257
437
  show('rotation');
438
+ check('rotation phases start closed', !app.rowsOf(app.top()).some((r) => r.data?.rule));
439
+ app.top().cursor = rowIndex((r) => r.data?.toggle && r.data.phase === 'pull'); term.press('return');
440
+ 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);
441
+ term.press('return');
442
+ check('Enter again closes it', !app.rowsOf(app.top()).some((r) => r.data?.rule));
443
+ app.top().cursor = rowIndex((r) => r.data?.toggle && r.data.phase === 'combat'); term.press('return');
444
+ show('rotation, combat open');
258
445
  const ruleRow = app.rowsOf(app.top()).findIndex((r) => r.data?.rule);
259
446
  const ruleData = app.rowsOf(app.top())[ruleRow].data;
260
447
  app.top().cursor = ruleRow; term.press('return');
@@ -272,11 +459,38 @@ check('add-rule picker opened', app.top().id === 'pick');
272
459
  term.press('return');
273
460
  check('rule added to combat', app.g.rotation().phases.combat.length === before + 1);
274
461
  check('cursor landed on the new rule', app.rowsOf(app.top())[app.top().cursor]?.data?.rule?.id?.startsWith('tui-'));
462
+ {
463
+ const added = app.rowsOf(app.top())[app.top().cursor].data.rule;
464
+ const want = defaultConditionsForSpell(ds.spell(added.action.spellId), app.ch);
465
+ check('new rule starts with the smart default conditions', JSON.stringify(added.conditions) === JSON.stringify(want));
466
+ }
275
467
  term.press('return'); term.press('down'); term.press('return'); // menu → change when it fires
276
- check('condition picker opened', app.top().id === 'pick' && /fire only when/.test(app.top().title));
277
- term.press('down'); term.press('return'); term.type('50');
278
- check('condition applied', JSON.stringify(app.g.rotation().phases.combat).includes('"healthPct"'));
468
+ check('condition editor opened', app.top().id === 'conds');
469
+ show('condition editor');
470
+ app.top().cursor = rowIndex((r, t) => /\+ Add a condition/.test(t)); term.press('return');
471
+ check('metric catalog opened', app.top().id === 'pick' && /What to check/.test(app.top().title));
472
+ app.top().cursor = rowIndex((r, t) => /living enemies/.test(t)); term.press('return');
473
+ check('condition added from the catalog', app.g.rotation().phases.combat.some((r) => r.id.startsWith('tui-') && r.conditions.some((c) => c.metric === 'enemyCount')));
474
+ check('one condition menu opened', app.top().id === 'cond');
475
+ app.top().cursor = rowIndex((r, t) => /^Value:/.test(t)); term.press('return'); term.type('3');
476
+ check('value changed through the prompt', app.g.rotation().phases.combat.some((r) => r.id.startsWith('tui-') && r.conditions.some((c) => c.metric === 'enemyCount' && c.value === 3)));
477
+ show('one condition');
478
+ app.top().cursor = rowIndex((r, t) => /^Check:/.test(t)); term.press('return'); term.press('/'); term.type('ally');
479
+ check('/ searches the catalog', app.top().id === 'pick' && /matching "ally"/.test(app.top().title));
480
+ app.top().cursor = rowIndex((r, t) => /^\s*health %$/.test(t)); term.press('return');
481
+ app.top().cursor = rowIndex((r, t) => /^Who:/.test(t)); term.press('return');
482
+ app.top().cursor = rowIndex((r, t) => /an ally/.test(t)); term.press('return');
483
+ check('an ally subject shows the ally filter', rowIndex((r, t) => /Which ally/.test(t)) >= 0);
484
+ app.top().cursor = rowIndex((r, t) => /Which ally/.test(t)); term.press('return');
485
+ app.top().cursor = rowIndex((r, t) => /a healer/.test(t)); term.press('return');
486
+ check('ally filter stored on the condition', app.g.rotation().phases.combat.find((r) => r.id.startsWith('tui-')).conditions.some((c) => c.subject === 'ally' && c.ally?.role === 'healer'));
487
+ show('ally condition');
488
+ term.press('escape');
489
+ app.top().cursor = rowIndex((r, t) => /Quick presets/.test(t)); term.press('return');
490
+ app.top().cursor = rowIndex((r, t) => /My health is below/.test(t)); term.press('return'); term.type('50');
491
+ check('preset replaced the conditions', JSON.stringify(app.g.rotation().phases.combat.find((r) => r.id.startsWith('tui-')).conditions) === JSON.stringify([{ metric: 'healthPct', subject: 'self', op: '<', value: 50 }]));
279
492
  show('rotation edited');
493
+ term.press('escape'); // leave the condition editor
280
494
  term.press('return'); term.press('end'); term.press('return'); // menu → delete
281
495
  check('rule deleted through the menu', app.g.rotation().phases.combat.length === before);
282
496
 
@@ -385,6 +599,24 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
385
599
  check('auction dialog opened', app.top().id === 'auction');
386
600
  const listings = app.g.browseAuctions(Date.now()).length;
387
601
  check(`auction house has bot listings (${listings})`, listings > 0);
602
+ // Categories: Left/Right picks an item class, [ ] a subclass inside it; Left back round to All shows everything.
603
+ const shown = () => app.rowsOf(app.top()).filter((r) => r.data?.auction).map((r) => app.ds.item(r.data.auction.item));
604
+ term.press('right'); show('auction category');
605
+ const inCat = shown(), cls = inCat[0]?.itemClass;
606
+ check(`Right picks a category (${inCat.length} of ${listings})`, inCat.length > 0 && inCat.length < listings && inCat.every((it) => it.itemClass === cls));
607
+ const onStrip = () => app.rowsOf(app.top())[app.top().cursor]?.data?.strip;
608
+ check('the cursor starts on a listing, not a strip', !onStrip());
609
+ term.press('up');
610
+ check(`Up from the first listing lands on a strip (${onStrip()})`, !!onStrip());
611
+ if (onStrip() === 'sub') {
612
+ term.press('right'); show('auction subcategory');
613
+ const inSub = shown(), subOf = (it) => it.itemSubclass ?? it.weaponSubclass ?? it.armorSubclass;
614
+ check(`Right on the subcategory strip picks one (${inSub.length} of ${inCat.length})`, inSub.length > 0 && inSub.length <= inCat.length && inSub.every((it) => it.itemClass === cls && subOf(it) === subOf(inSub[0])));
615
+ check('the cursor stays on the strip', onStrip() === 'sub');
616
+ term.press('down');
617
+ check('Down from the strip goes to the listings', !onStrip() && !!app.rowsOf(app.top())[app.top().cursor]?.data?.auction);
618
+ } else term.press('down');
619
+ term.press('left'); check('Left back to All lists every listing', shown().length === listings);
388
620
  term.press('s'); term.type('cloth'); show('auction search');
389
621
  check('search filters the boards', /"cloth"/.test(strip(frame())));
390
622
  term.press('tab'); show('auction sell');
@@ -459,11 +691,24 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
459
691
  if (hire >= 0) {
460
692
  app.top().cursor = hire; term.press('return'); show('hire list');
461
693
  check('the hire list opened', app.top().id === 'hire' && rowIndex(() => true) >= 0);
462
- 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 ?? '')));
694
+ 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 ?? '')));
695
+ const hireRows = () => app.rowsOf(app.top()).filter((r) => !r.header && r.data);
696
+ 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))));
697
+ 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 ?? '')));
698
+ term.press('right');
699
+ 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))));
700
+ term.press('t'); term.press('t');
701
+ 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))));
702
+ term.press('left'); term.press('left'); term.press('right');
703
+ check('the role tabs wrap, All is the start', app.top().title === 'Hire · Shabby');
704
+ term.press('T'); term.press('T');
705
+ check('T steps the kit back to any', app.top().title === 'Hire');
463
706
  term.press('return'); show('candidate page');
464
707
  check('Enter opens the candidate page', app.top().id.startsWith('cand-'));
465
708
  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);
466
- app.top().cursor = rowIndex((r, t) => /^Hire as/.test(t)); term.press('return'); show('after hire');
709
+ app.top().cursor = rowIndex((r, t) => /^Hire as/.test(t)); app.render();
710
+ check('the hire row draws the candidate in their gear beside the cost', !hasArt(nativeDisplayId(human.id, 0)) || /[▀▄]/.test(frame()));
711
+ term.press('return'); show('after hire');
467
712
  check('hiring returns to the hire list, not the tab', app.top()?.id === 'hire');
468
713
  term.press('escape'); check('Esc closes the hire list', app.stack.length === 0);
469
714
  const comp = (g.state.squad ?? [])[0];
@@ -472,6 +717,7 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
472
717
  const row = rowIndex((r, t) => t.startsWith(comp.name));
473
718
  app.top().cursor = row; term.press('return'); show('companion page');
474
719
  check('Enter opens their page', app.top().id === `comp-${comp.id}`);
720
+ check('their page draws them beside the Role note', !hasArt(nativeDisplayId(comp.race, comp.gender)) || /[▀▄]/.test(frame()));
475
721
  // Make room for Tailoring: drop one primary when they hold two.
476
722
  const lines = g.companionProfessionsOf(comp.id)?.lines ?? [];
477
723
  const primaries = lines.filter((l) => l.kind !== 'secondary');
@@ -544,6 +790,16 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
544
790
  }
545
791
  const squad = g.state.squad ?? [];
546
792
  check(`two companions are seated (${squad.length})`, squad.length >= 2);
793
+ if (iconsOn() && squad[0]) {
794
+ // c on a companion's page opens their character sheet: the Character tab's paperdoll, for them.
795
+ const [cols, rows] = [term.cols, term.rows];
796
+ term.cols = 160; term.rows = 54;
797
+ home(); app.switchTab(tabOf('companions'));
798
+ app.top().cursor = rowIndex((r, t) => t.startsWith(squad[0].name)); term.press('return'); term.press('c'); show('companion sheet');
799
+ 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()));
800
+ term.cols = cols; term.rows = rows; home();
801
+ }
802
+ check('money reads in coins, an empty copper left off', strip(coinText(250000)) === (iconsOn() ? '25●' : '25g 0c') && strip(coinText(0, 'free')) === 'free');
547
803
  home(); app.switchTab(tabOf('companions'));
548
804
  term.press('m'); show('squad mode picker');
549
805
  check('m on Companions opens the squad mode picker', app.top().id === 'pick' && /How do your companions fight/.test(app.top().title));
@@ -585,9 +841,31 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
585
841
  check('a solo pull brings nobody', !!app.fight && app.fight.session.alliesView().length === 0);
586
842
  finishFight();
587
843
  term.press(':'); term.type('squad party');
588
- const quickFrom = app.log.length;
589
- term.press(':'); term.type(`fight ${app.rowsOf(app.top())[mobAt].data.creature.entry}`);
590
- check(':fight brings the party in Party mode', app.log.slice(quickFrom).some((l) => squad.some((c) => strip(l).includes(c.name))));
844
+ const quickEntry = app.rowsOf(app.top())[mobAt].data.creature.entry;
845
+ term.press(':'); term.type(`fight ${quickEntry}`);
846
+ // The log is capped at 2000 lines (say()), so read from the command's echo, not from a length taken before it.
847
+ const quickLog = app.log.map(strip);
848
+ const quickAt = quickLog.lastIndexOf(`> fight ${quickEntry}`);
849
+ check(':fight brings the party in Party mode', quickAt >= 0 && quickLog.slice(quickAt + 1).some((l) => squad.some((c) => l.includes(c.name))));
850
+ }
851
+
852
+ // ---- gear: a BoE a companion carries (a crafter's product) goes on from the Equipment tab, no trade first ----
853
+ {
854
+ const g = app.g, comp = g.state.squad[0];
855
+ const cloak = ds.items().find((i) => i.slot === 'back' && i.bonding === 2 && (i.requiredLevel ?? 1) <= app.ch.level && !i.allowClass?.length && i.entry > 100);
856
+ check('a BoE cloak exists to hand a companion', !!cloak && !!comp);
857
+ if (cloak && comp) {
858
+ comp.bags = addItem(comp.bags ?? { stacks: [] }, cloak.entry, 1, 1);
859
+ home(); app.switchTab(app.tabs.findIndex((t) => t.view.id === 'gear'));
860
+ app.top().cursor = rowIndex((r) => r.data?.slot === 'back' && !r.data?.upgrade); term.press('return'); show('back slot picker with companion bags');
861
+ const row = rowIndex((r, t) => t.includes(cloak.name) && t.includes(comp.name));
862
+ check("the slot picker lists the cloak from the companion's bags", row > 0);
863
+ if (row > 0) {
864
+ app.top().cursor = row; term.press('return');
865
+ const back = app.g.state.char.equipped.back;
866
+ check("Enter takes the companion's cloak and wears it", (back?.item ?? back) === cloak.entry && countItem(comp.bags, cloak.entry) === 0);
867
+ }
868
+ }
591
869
  }
592
870
 
593
871
  // ---- dungeons: the Deadmines as a text run ------------------------------------------------------------------
@@ -614,6 +892,37 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
614
892
  for (let n = 1; n <= 2; n++) {
615
893
  app.top().cursor = rowIndex((r, t) => /◀ next/.test(t)); term.press('return');
616
894
  check(`stop ${n} starts a live fight with the party`, !!app.fight?.withSquad && app.fight.session.alliesView().length >= 4);
895
+ if (n === 1 && app.fight) {
896
+ // The party stage: every body on one floor, the party's plates on one row (role letter, name) left of the
897
+ // pack's, an HP percent under each, the foe you face marked, and a cast bar under a caster while it casts.
898
+ for (let k = 0; k < 8 && app.fight; k++) app.fight.tick();
899
+ show('party stage');
900
+ const rows = strip(frame()).split('\n');
901
+ const plateRow = rows.findIndex((l) => /\bT \S/.test(l) && /\bH \S/.test(l) && l.includes(app.ch.name));
902
+ check('the party stage puts the tank, the healer and you on one plate row', plateRow > 0);
903
+ const pct = (rows[plateRow + 1] ?? '').match(/\d+%/g) ?? [];
904
+ const bodies = 1 + app.fight.session.alliesView().length + 1; // you, the party, at least the foe you face
905
+ check(`an HP percent sits under every sprite (${pct.length} of ${bodies}+)`, pct.length >= bodies);
906
+ check('the pack stands right of the party with the faced foe marked', plateRow > 0 && rows[plateRow].indexOf('◀') > rows[plateRow].lastIndexOf(app.ch.name));
907
+ check('the party stage stands on a floor', /[▁▂_]{40}/.test(strip(frame())));
908
+ let cast = null;
909
+ for (let k = 0; k < 400 && app.fight && !cast; k++) {
910
+ app.fight.tick();
911
+ const s = app.fight.session;
912
+ cast = [...s.alliesView(), ...s.enemiesView()].find((v) => v.hp > 0 && v.casting && v.casting.endsAt - s.t >= 300) ?? null;
913
+ }
914
+ check('a cast shows as a bar under the caster', !cast || strip(frame()).includes(cast.casting.name.slice(0, 8)));
915
+ if (cast) show('party stage cast');
916
+ // The last foe falls and the win holds: the party stays where it fought, it does not walk back to its start.
917
+ 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
918
+ let before = '';
919
+ for (let k = 0; k < 3000 && app.fight && !app.fight.ended; k++) {
920
+ const left = app.fight.session.enemiesView().filter((v) => v.hp > 0);
921
+ if (left.length === 1 && left[0].hp < left[0].maxHp * 0.25) before = plates();
922
+ app.fight.tick();
923
+ }
924
+ 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); }
925
+ }
617
926
  finishFight();
618
927
  check(`stop ${n} is cleared and the run window comes back`, g.state.instance.defeated.length === n && app.top()?.id === 'dungeon');
619
928
  }
@@ -684,6 +993,16 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
684
993
  app.top().cursor = rowIndex((r, t) => /^Raid group/.test(t)); term.press('return'); term.press('f');
685
994
  check('f fills the 20-man group', g.state.raidLineup?.size === 20 && g.state.raidLineup.ids.length > 0);
686
995
  term.press('c'); home();
996
+ // Travel splits a zone's neighbors by how you get there: Grom'gol's zeppelins are not a walk to Tirisfal.
997
+ app.switchTab(app.tabs.findIndex((t) => t.view.id === 'travel')); app.render();
998
+ {
999
+ const texts = app.rowsOf(app.top()).map((r) => strip(r.text));
1000
+ const foot = texts.indexOf('On foot'), boats = texts.indexOf('Boats and zeppelins');
1001
+ const tirisfal = texts.findIndex((t) => t.startsWith('Tirisfal Glades'));
1002
+ check('Travel lists the zeppelin to Tirisfal under boats, Duskwood on foot', foot >= 0 && boats > foot && tirisfal > boats
1003
+ && /Undercity/.test(texts[tirisfal]) && texts.findIndex((t) => t.startsWith('Duskwood')) < boats);
1004
+ }
1005
+ home();
687
1006
  g.state.char.zoneId = 'westfall';
688
1007
  }
689
1008
 
@@ -731,6 +1050,27 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
731
1050
  check('p adds a named companion rotation', g.companionRotationProfiles(comp().id).some((p) => p.name === 'Second' && p.active));
732
1051
  term.press('w'); term.press('return');
733
1052
  check('w back to your own rotation', !app.tabs[5].view.whose);
1053
+ // Spellbook tabs: General, then one per talent tree; [ ] and Left Right on the strip turn them.
1054
+ home(); term.press('5'); show('spellbook');
1055
+ {
1056
+ const rs = () => app.rowsOf(app.top());
1057
+ const stripAt = () => rs().findIndex((r) => r.data?.strip);
1058
+ const spells = () => rs().slice(stripAt() + 1).filter((r) => !r.header).map((r) => strip(r.text));
1059
+ const trees = ds.talentTrees(app.ch.cls).map((t) => t.name);
1060
+ check('spellbook has General and a tab per talent tree', stripAt() > 0 && ['General', ...trees].every((n) => strip(rs()[stripAt()].text).includes(n)));
1061
+ const first = spells();
1062
+ check(`the book opens on a tab with spells (${first.join(', ')})`, first.length > 0);
1063
+ // ] through every tab and back: each spell sits on exactly one tab, and the class spells are not on General.
1064
+ const seen = [];
1065
+ for (let i = 0; i <= trees.length; i++) { seen.push(...spells()); term.press(']'); }
1066
+ check('] walks every tab and comes back round', JSON.stringify(spells()) === JSON.stringify(first) && new Set(seen).size === seen.length);
1067
+ // This warrior's General is empty, so the book opens on Arms.
1068
+ 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')));
1069
+ app.top().cursor = stripAt(); term.press('left');
1070
+ const back = spells();
1071
+ term.press('right');
1072
+ 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));
1073
+ }
734
1074
  // Player action bars: bar 1 from the Spells tab, and the fight's number row reads it.
735
1075
  home(); term.press('5'); term.press('b'); show('player bars');
736
1076
  check('b on Spells opens your action bars', app.top().id === 'bars' && /Bar 1/.test(strip(frame())));
@@ -789,7 +1129,21 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
789
1129
  g2.state.char.level = 12; g2.state.char.gold = 1_000_000;
790
1130
  app2.render();
791
1131
  const at = (pred) => app2.rowsOf(app2.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
792
- app2.top().cursor = at((r) => r.data?.creature?.tameable && r.data.creature.level <= 12); term2.press('t');
1132
+ // One with an innate ability, so the pet page has an autocast row to toggle.
1133
+ const beast = () => at((r) => r.data?.creature?.tameable && r.data.creature.level <= 12 && r.data.creature.petDefaultSpells?.length);
1134
+ app2.top().cursor = beast(); term2.press('t');
1135
+ check('t refuses a hunter who has not learned Tame Beast', !g2.state.char.pet);
1136
+ // Taming the Beast (Dun Morogh): the rod goes on the Large Crag Boar the quest names, and t says so on its row.
1137
+ g2.state.char.level = 10; app2.result(g2.acceptQuest(6064));
1138
+ const boar = at((r) => r.data?.creature?.entry === 1126);
1139
+ 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)));
1140
+ app2.top().cursor = boar; term2.press('t');
1141
+ check('t uses the rod and the quest completes', g2.state.questlog.quests.find((q) => q.entry === 6064)?.status === 'complete');
1142
+ g2.state.char.level = 12;
1143
+ g2.state.spellbook = { known: [...g2.state.spellbook.known, 1515] };
1144
+ app2.render();
1145
+ check('a hunter with Tame Beast sees t: tame on a beast', /t: tame/.test(strip(app2.rowsOf(app2.top())[beast()].text)));
1146
+ app2.top().cursor = beast(); term2.press('t');
793
1147
  check('t on a tameable beast tames it', !!g2.state.char.pet);
794
1148
  app2.top().cursor = at((r, t) => /^Pet:/.test(t)); term2.press('return');
795
1149
  if (showFrames) console.log(`\n=== pet page ===\n${term2.text()}`);
@@ -814,13 +1168,56 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
814
1168
  check('Dun Morogh lists a pet trainer', trainer > 0);
815
1169
  if (trainer > 0) {
816
1170
  app2.top().cursor = trainer; term2.press('return');
817
- const rank = at((r, t) => !/level \d+/.test(t) && !/^✓/.test(t));
1171
+ check('the pet trainer offers to untrain the pet', at((r, t) => /^Untrain /.test(t)) > 0);
1172
+ const rank = at((r, t) => !/level \d+/.test(t) && !/^✓/.test(t) && !/^Untrain /.test(t));
818
1173
  app2.top().cursor = rank; term2.press('return');
819
1174
  check('a pet trainer rank can be bought', (g2.state.char.petSpells ?? []).length > 0);
820
1175
  }
821
1176
  term2.close?.();
822
1177
  }
823
1178
 
1179
+ // ---- what phonecraft's World tab, quest card and bag menu offer: weapon masters, ring picker, hearth, quest steps --
1180
+ {
1181
+ const term2 = new HeadlessTerm(110, 34);
1182
+ const app2 = new App({ term: term2, ds, repo });
1183
+ term2.open();
1184
+ const dwarf = ds.races().find((r) => r.name === 'Dwarf');
1185
+ app2.startGame(newGameState(ds, dwarf.id, 1, 'Portcheck', 98, 0), 'portcheck', Date.now(), true);
1186
+ const g2 = app2.g;
1187
+ g2.state.char.level = 20; g2.state.char.zoneId = 'ironforge';
1188
+ app2.switchTab(0); app2.render();
1189
+ const at = (pred) => app2.rowsOf(app2.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
1190
+ check('Ironforge lists its weapon masters on the World tab', at((r, t) => /Weapon Master|Weapon Trainer/i.test(t)) >= 0);
1191
+ check('no Horde trainer or vendor in an Alliance capital', at((r, t) => /Orgrimmar|Thunder Bluff/.test(t) && /</.test(t)) < 0);
1192
+ // Two rings on, a third from the bags asks which to replace instead of overwriting the first.
1193
+ const rings = ds.allItems().filter((it) => it.slot === 'finger' && (it.requiredLevel ?? 0) <= 20 && !it.unique).slice(0, 3);
1194
+ if (rings.length === 3) {
1195
+ for (const r of rings.slice(0, 2)) { g2.state.inventory = addItem(g2.state.inventory, r.entry, 1, 1); g2.equip(r.entry); }
1196
+ g2.state.inventory = addItem(g2.state.inventory, rings[2].entry, 1, 1);
1197
+ app2.switchTab(2); app2.render();
1198
+ app2.top().cursor = at((r, t) => t.includes(rings[2].name)); term2.press('return');
1199
+ check('a third ring asks which ring to replace', app2.top().id === 'pick' && /replace which/.test(strip(app2.top().title)));
1200
+ term2.press('down'); term2.press('return');
1201
+ check('the picked ring slot takes it', Object.values(g2.state.char.equipped).some((e) => (e?.entry ?? e) === rings[2].entry));
1202
+ } else check('three plain rings exist for the slot picker check', false);
1203
+ // The Hearthstone from the bags takes you home, as the Travel tab's row does.
1204
+ g2.state.char.hearthBind = { zone: 'dunmorogh', name: 'Thunderbrew Distillery' };
1205
+ app2.stack = []; app2.switchTab(2); app2.render();
1206
+ const hs = at((r, t) => /Hearthstone/.test(t));
1207
+ if (hs >= 0) {
1208
+ app2.top().cursor = hs; term2.press('return');
1209
+ check('Enter on the Hearthstone in the bags hearths you', g2.state.char.zoneId !== 'ironforge' || !!g2.state.char.journey);
1210
+ } else check('a new dwarf carries a Hearthstone', false);
1211
+ // A speak-to quest in the log gets its Speak row on the quest page.
1212
+ const talk = ds.quests().find((q) => isTalkToNpcQuest(q) && !q.goEnder && !(q.goEnderEntries ?? []).some((e) => e > 0) && (q.enderName || q.starterName));
1213
+ if (talk) {
1214
+ g2.state.questlog = { ...g2.state.questlog, quests: [...g2.state.questlog.quests, { entry: talk.entry, status: 'active', kills: {} }] };
1215
+ app2.stack = []; app2.push(questDialog(app2, talk, 'log')); app2.render();
1216
+ check('a speak-to quest offers Speak to its NPC on the quest page', at((r, t) => /^Speak to /.test(t)) >= 0);
1217
+ }
1218
+ term2.close?.();
1219
+ }
1220
+
824
1221
  // Loot window: auto-loot off holds the corpse's drops and coin; Enter takes a row, a takes the rest, Esc leaves it.
825
1222
  {
826
1223
  home(); term.press('1');
@@ -965,7 +1362,7 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
965
1362
  const slotRow = rowIndex((r) => r.data?.slot === worn);
966
1363
  check('a worn-down slot shows its durability', / 1\/\d+/.test(strip(app.rowsOf(app.top())[slotRow].text)));
967
1364
  const quote = g.repairQuote();
968
- check('the Repair all row prices the bill', rowIndex((r, t) => t.startsWith('Repair all') && t.includes(moneyTextOf(quote.cost))) > 0);
1365
+ check('the Repair all row prices the bill', rowIndex((r, t) => t.startsWith('Repair all') && t.includes(strip(coinText(quote.cost)))) > 0);
969
1366
  if (quote.vendorName) {
970
1367
  app.top().cursor = slotRow; term.press('r');
971
1368
  check('r on a slot repairs that piece', g.state.char.durability?.[worn] == null);
@@ -1073,6 +1470,8 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1073
1470
  check('a global bar slot takes a bag item', (g.state.globalActionBar?.items ?? []).includes(potion.entry));
1074
1471
  g.state.char.health = Math.max(1, Math.floor(g.state.char.health / 2));
1075
1472
  const potions = countItem(g.state.inventory, potion.entry);
1473
+ // The kobold fight above may have drunk one, and the potion cooldown runs on the wall clock now.
1474
+ g.state.spellCooldowns = {}; g.state.categoryCooldowns = {};
1076
1475
  app.top().cursor = rowIndex((r, t) => /Fire the whole global bar/.test(t)); term.press('return');
1077
1476
  check('firing the global bar drinks the potion', countItem(g.state.inventory, potion.entry) === potions - 1);
1078
1477
  app.top().cursor = rowIndex((r, t) => t.includes(potion.name) && !('b' in (r.data ?? {}))); term.press('return');
@@ -1127,15 +1526,15 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1127
1526
  home();
1128
1527
  }
1129
1528
 
1130
- // Cancel an aura: from the Character tab, and with c inside a fight.
1529
+ // Cancel an aura: from the Character tab's stats page, and with c inside a fight.
1131
1530
  {
1132
1531
  const g = app.g;
1133
1532
  const fort = ds.spell(1243);
1134
1533
  const give = () => { g.state.char.activeAuras = [...(g.state.char.activeAuras ?? []).filter((x) => x.spellId !== 1243), { spellId: 1243, expiresAt: Date.now() + 600000 }]; };
1135
1534
  give();
1136
- home(); app.switchTab(app.tabs.length - 1); show('character auras');
1535
+ home(); app.switchTab(3); term.press('s'); show('character auras');
1137
1536
  const at = rowIndex((r, t) => t.includes(fort.name));
1138
- check('the Character tab lists your auras', at > 0);
1537
+ check('the stats page lists your auras', at > 0);
1139
1538
  app.top().cursor = at; term.press('return');
1140
1539
  check('Enter on a buff cancels it', !(g.state.char.activeAuras ?? []).some((x) => x.spellId === 1243));
1141
1540
  give(); home(); term.press('1');
@@ -1267,6 +1666,21 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1267
1666
  home();
1268
1667
  }
1269
1668
 
1669
+ // The Travel tab's detail pane carries the world map (tuiMap.mjs): the terrain as half-block cells, you and the
1670
+ // destination pinned on it, the planned route dotted between them.
1671
+ {
1672
+ home(); term.press('8');
1673
+ const here = app.g.state.char.zoneId;
1674
+ const dest = (ds.zone(here)?.neighbors ?? []).map((id) => ds.zone(id)).find((z) => z && app.g.previewTravel(z.id).ok);
1675
+ app.top().cursor = rowIndex((r, t) => t.startsWith(dest.name)); app.render();
1676
+ const pane = strip(frame()).split('\n').map((l) => l.split('│')[1] ?? '').join('\n');
1677
+ check('the Travel tab draws the world map under a destination, you pinned on it', /▀/.test(pane) && pane.includes('●') && /you .*destination/.test(pane));
1678
+ const far = ds.zones().find((z) => z.continent !== ds.zone(here).continent && app.g.previewTravel(z.id).ok);
1679
+ const lines = worldMapLines(app, { dest: far, legs: app.g.previewTravel(far.id).legs, maxCols: 60, maxRows: 24 });
1680
+ 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('◆'));
1681
+ home();
1682
+ }
1683
+
1270
1684
  // Real-time travel: with a 1x pace a trip takes time; the header counts down, the World tab carries the journey
1271
1685
  // card (speed, pause, arrive now, turn back), and the game loop lands it.
1272
1686
  {
@@ -1654,9 +2068,49 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1654
2068
  const app9 = new App({ term: term9, ds, repo });
1655
2069
  term9.open();
1656
2070
  const { characterCreation } = await import('./tui.mjs');
1657
- let created = null;
1658
- characterCreation(app9, (state) => { created = state; });
1659
- app9.top().cursor = app9.rowsOf(app9.top()).length - 1; term9.press('return');
2071
+ let created = null, cancelled = 0;
2072
+ characterCreation(app9, (state) => { created = state; }, { onCancel: () => { cancelled++; } });
2073
+ const row9 = (id) => app9.rowsOf(app9.top()).findIndex((r) => r.id === id);
2074
+ const text9 = () => term9.text();
2075
+ check('the create screen opens on the faction row with the hero drawn', app9.top().id === 'create' && app9.top().cursor === row9('faction') && row9('faction') < row9('race') && /Faction\s+Alliance/.test(text9()) && /Unnamed\s+Male Human Warrior/.test(text9()) && /Look/.test(text9()) && /New character/.test(text9()));
2076
+ app9.top().cursor = row9('race'); term9.press('right');
2077
+ check('Right on Race steps to the next race of the faction', /Race\s+Dwarf/.test(text9()));
2078
+ app9.top().cursor = row9('faction'); term9.press('right');
2079
+ check('Right on Faction switches to its first race', /Faction\s+Horde/.test(text9()) && /Race\s+Orc/.test(text9()) && /Orc · Horde/.test(text9()));
2080
+ term9.press('left');
2081
+ check('flipping back returns to the race last chosen there', /Faction\s+Alliance/.test(text9()) && /Race\s+Dwarf/.test(text9()));
2082
+ term9.press('right');
2083
+ app9.top().cursor = row9('race'); term9.press('left');
2084
+ check('Left on Race wraps to the faction\'s last race', /Race\s+Troll/.test(text9()));
2085
+ // A troll cannot be a paladin or a druid: the class row only offers what the race may roll.
2086
+ app9.top().cursor = row9('class');
2087
+ const classes9 = new Set();
2088
+ for (let i = 0; i < 12; i++) { term9.press('right'); classes9.add(text9().match(/Class\s+(\w+)/)[1]); }
2089
+ check('Class cycles through the troll classes only', classes9.has('Hunter') && classes9.has('Shaman') && !classes9.has('Paladin') && !classes9.has('Druid'));
2090
+ term9.press('return');
2091
+ check('Enter on Class opens the class list over the screen', app9.top().title === 'Class' && app9.stack.length === 2 && /Hunter/.test(text9()));
2092
+ term9.press('escape');
2093
+ check('Esc closes the list before a game is open', app9.top().id === 'create' && app9.stack.length === 1);
2094
+ app9.top().cursor = row9('gender'); term9.press('right');
2095
+ check('Right on Gender flips it', /Gender\s+Female/.test(text9()) && /Female Troll/.test(text9()));
2096
+ app9.top().cursor = row9('skin');
2097
+ const skin9 = text9().match(/Skin Color\s+(\d+) of/)[1]; term9.press('right');
2098
+ check('Right on a look row changes it', text9().match(/Skin Color\s+(\d+) of/)[1] !== skin9);
2099
+ app9.top().cursor = row9('create'); term9.press('return');
2100
+ check('Create without a name asks for one and moves to the Name row', !created && app9.top().cursor === row9('name') && /Give them a name/.test(text9()));
2101
+ term9.press('n');
2102
+ check('n rolls a random name', /Name\s+[A-Z][a-z]+/.test(text9()));
2103
+ app9.top().cursor = row9('name'); term9.press('return'); term9.type('x1');
2104
+ check('a bad name is refused', !!app9.cmd && /Letters only/.test(text9()));
2105
+ term9.type('thrallina');
2106
+ check('typing a name lands on the Create row', !app9.cmd && app9.top().cursor === row9('create') && /Create Thrallina, Female Troll/.test(text9()));
2107
+ term9.press('return');
2108
+ check('Enter on Create makes the character as chosen', created?.char?.name === 'Thrallina' && created.char.race === 8 && created.char.gender === 1 && created.char.level === 1 && created.char.appearance != null);
2109
+ created = null; app9.stack = [];
2110
+ characterCreation(app9, (state) => { created = state; }, { onCancel: () => { cancelled++; } });
2111
+ term9.press('escape');
2112
+ check('Esc on the create screen goes back to the character list', cancelled === 1);
2113
+ app9.top().cursor = row9('random'); term9.press('return');
1660
2114
  check('the create screen rolls a random world bot', !!created?.char?.name && created.char.level >= 1);
1661
2115
  term9.close?.();
1662
2116
  home();
@@ -1794,15 +2248,42 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1794
2248
  app12.top().cursor = at((r, t) => /Warsong Gulch/.test(t)); term12.press('return');
1795
2249
  if (showFrames) console.log(`\n=== warsong gulch ===\n${term12.text()}`);
1796
2250
  check('the honor window starts Warsong Gulch', app12.top()?.id === 'bg' && !!g12.state.battleground && /flag/.test(strip(term12.text())));
1797
- app12.top().cursor = at((r, t) => /Your job/.test(t)); term12.press('return');
2251
+ app12.top().cursor = at((r, t) => /Autopilot:/.test(t)); term12.press('return'); // the orders picker
2252
+ app12.top().cursor = at((r, t) => /Autopilot:/.test(t)); term12.press('return'); // its job option
1798
2253
  app12.top().cursor = at((r, t) => /guard our flag/.test(t)); term12.press('return');
1799
2254
  check('you can change your job', app12.bg.world.units.get(app12.bg.selfId).role === 'defense');
1800
2255
  const bg = app12.bg;
2256
+ check('the gulch is drawn as a strip with you on it', /Horde.*mid.*Alliance/.test(strip(term12.text())) && /@/.test(strip(term12.text())));
2257
+ // Your own seat by place: an order takes you off autopilot, and once the gates open you walk toward mid.
2258
+ const { WSG_GEOMETRY } = await import('../domain/bg/wsg.ts');
2259
+ const me12 = bg.world.units.get(bg.selfId);
2260
+ const toMid = () => Math.hypot(me12.pos.x - WSG_GEOMETRY.mid.x, me12.pos.y - WSG_GEOMETRY.mid.y);
2261
+ const d0 = toMid();
2262
+ term12.press('o');
2263
+ app12.top().cursor = at((r, t) => /Go to mid/.test(t)); term12.press('return');
2264
+ check('an order by place takes your seat off autopilot', me12.human === true && me12.order.kind === 'attack-move');
2265
+ stepBg(app12, bg, 300);
2266
+ check('and you walk there yourself', toMid() < d0 - 50 && me12.order.kind === 'attack-move');
2267
+ // A team call holds your side against the strategy layer for a while, then hands them back.
2268
+ term12.press('t');
2269
+ app12.top().cursor = at((r, t) => /Everyone to mid/.test(t)); term12.press('return');
2270
+ const called = [...bg.commanded.keys()];
2271
+ stepBg(app12, bg, 25); // two strategy cycles
2272
+ const stillOnIt = called.filter((id) => { const u = bg.world.units.get(id); return u.order.kind === 'attack-move' && Math.abs(u.order.to.x - WSG_GEOMETRY.mid.x) < 1; });
2273
+ check('a team call holds your side past the strategy cycle', called.length >= 5 && stillOnIt.length === called.filter((id) => bg.world.alive(bg.world.units.get(id))).length);
2274
+ stepBg(app12, bg, 500);
2275
+ check('and lapses after its time', bg.commanded.size === 0);
2276
+ bg.speed = 8;
2277
+ term12.press('j');
2278
+ app12.top().cursor = at((r, t) => /guard our flag/.test(t)); term12.press('return');
2279
+ check('autopilot takes your seat back', me12.human === false && bg.my == null);
1801
2280
  const marks0 = g12.state.inventory.stacks.length;
1802
2281
  let n = 0;
1803
2282
  while (!bg.world.ended && n < 40000) { stepBg(app12, bg, 200); n += 200; }
1804
2283
  app12.render();
1805
2284
  check('the match plays to an end', bg.world.ended && bg.settled);
2285
+ check('inside its 25 minute clock, with a winner', bg.world.t <= 25 * 60_000 + 10_000 && bg.world.winner != null);
2286
+ check('both sides scored kills and the header counts them', bg.teamKills.alliance > 0 && bg.teamKills.horde > 0 && /kills [0-9]+-[0-9]+/.test(strip(term12.text())));
1806
2287
  check('honor and marks of honor settle', bg.marks > 0 && g12.state.inventory.stacks.length > marks0 && (g12.state.char.honor?.cp ?? 0) > 0);
1807
2288
  check('and you are out of the battleground', !g12.state.battleground);
1808
2289
  app12.top().cursor = at((r, t) => /Leave the battleground/.test(t)); term12.press('return');
@@ -1904,9 +2385,16 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1904
2385
  // World search: s on the World tab, a vendor item lists who sells it and where, a node its zones, a dungeon its door.
1905
2386
  {
1906
2387
  const txt = () => app.rowsOf(app.top()).map((r) => strip(r.text)).join('|');
1907
- const search = (q) => { home(); term.press('1'); term.press('s'); for (const ch of q) term.press(ch === ' ' ? 'space' : ch); term.press('return'); };
2388
+ const find = (key, q) => { home(); term.press('1'); term.press(key); for (const ch of q) term.press(ch === ' ' ? 'space' : ch); term.press('return'); };
2389
+ const search = (q) => find('S', q);
2390
+ find('s', 'hogger'); show('zone search');
2391
+ check('s searches the zone you stand in', app.top().id === 'world-search' && /Search Elwynn Forest/.test(strip(frame())) && /Hogger L11.*here/.test(txt()));
2392
+ find('s', 'deadmines');
2393
+ check('the zone search leaves out what is elsewhere', /Nothing by that name in Elwynn Forest/.test(txt()));
2394
+ term.press('a');
2395
+ check('a widens it to the whole world', /Deadmines.*Dungeon.*entrance in Westfall/.test(txt()));
1908
2396
  search('mining pick'); show('world search');
1909
- check('s opens the world search', app.top().id === 'world-search' && /Mining Pick/.test(txt()));
2397
+ check('S opens the world search', app.top().id === 'world-search' && /Mining Pick/.test(txt()));
1910
2398
  check('an item lists the vendors that sell it and where they stand', /sold by .*<.*>.*(here|Stormwind|Westfall|Loch Modan|Redridge)/.test(txt()));
1911
2399
  search('copper vein'); show('world search node');
1912
2400
  check('a gather node lists the zones it grows in', /Copper Vein.*Mining.*(here|Dun Morogh|Durotar|Mulgore|Westfall|Loch Modan)/.test(txt()));
@@ -1928,6 +2416,114 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1928
2416
  term.press('?'); check('help overlay', app.top().id === 'help'); term.press('escape');
1929
2417
  check('help closed', app.stack.length === 0);
1930
2418
 
2419
+ // Pre-pull buffs: a mage buffs standing still, the autopilot drinks back what that cost, and the pull opens at
2420
+ // full mana on an attack. Casting the buffs phase on the fight's first ticks sent it in at three quarters.
2421
+ {
2422
+ const termB = new HeadlessTerm(110, 34);
2423
+ const appB = new App({ term: termB, ds, repo });
2424
+ termB.open();
2425
+ 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);
2426
+ const gB = appB.g;
2427
+ const wolf = ds.zone(gB.state.char.zoneId).huntable[0];
2428
+ const hasAI = () => (gB.state.char.activeAuras ?? []).some((a) => ds.spell(a.spellId)?.name === 'Arcane Intellect');
2429
+ const full = () => gB.state.char.power === maxPower(gB.state.char, ds);
2430
+ let pulledFull = null, pulledBuffed = null;
2431
+ 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 }; } });
2432
+ ap.next();
2433
+ check('autopilot buffs before the pull, then rests instead of pulling', ap.lastKind === 'buff' && hasAI() && !full());
2434
+ for (let i = 0; i < 5 && pulledFull == null; i++) ap.next();
2435
+ check('autopilot pulls buffed and at full mana', pulledFull === true && pulledBuffed === true);
2436
+ gB.state.char.activeAuras = (gB.state.char.activeAuras ?? []).filter((a) => ds.spell(a.spellId)?.name !== 'Arcane Intellect');
2437
+ gB.state.char.activeBuffs = (gB.state.char.activeBuffs ?? []).filter((id) => ds.spell(id)?.name !== 'Arcane Intellect');
2438
+ const from = appB.log.length;
2439
+ startFight(appB, wolf);
2440
+ const buffedFirst = hasAI();
2441
+ for (let i = 0; i < 2000 && appB.fight; i++) appB.fight.tick();
2442
+ const casts = appB.log.slice(from).map(strip).filter((l) => /^\s*[\d.]+s You cast /.test(l));
2443
+ check('a plain fight buffs before it starts and opens on an attack', buffedFirst && casts.length > 0 && !casts.some((l) => /Arcane Intellect/.test(l)));
2444
+ // A dungeon stop: the same buff, then the party drinks back to the rest line before the pull, and again before
2445
+ // the next one. Pulling straight after the armour and conjures opened Rhahk'Zor at 761 of 1966.
2446
+ const { openRun, pullStop } = await import('./tuiDungeon.mjs');
2447
+ const { nextEncounter } = await import('../world/instances.ts');
2448
+ for (const [cls, role] of [[1, 'tank'], [2, 'healer']]) gB.recruitCompanion(cls, role, 'twink');
2449
+ for (const c of gB.state.squad) c.level = 20;
2450
+ gB.state.char.activeAuras = []; gB.state.char.activeBuffs = [];
2451
+ gB.regenTick(600, true);
2452
+ const dm = ds.instance('deadmines');
2453
+ check('a level 20 mage party can enter the Deadmines', gB.enterInstance('deadmines', Date.now()).ok);
2454
+ appB.stack = []; openRun(appB);
2455
+ const openPct = [];
2456
+ for (let i = 0; i < 2; i++) {
2457
+ pullStop(appB, dm, nextEncounter(dm, gB.state.instance));
2458
+ const pv = appB.fight?.session.playerView();
2459
+ openPct.push(pv ? pv.power / maxPower(gB.state.char, ds) : 0);
2460
+ for (let n = 0; n < 8000 && appB.fight; n++) appB.fight.tick();
2461
+ appB.fight?.release?.();
2462
+ }
2463
+ check('a dungeon stop buffs, then drinks back before the pull', hasAI() && openPct[0] >= 0.9);
2464
+ check('the next stop rests back up too', openPct[1] >= 0.9);
2465
+ }
2466
+
2467
+ // A foe that resets ends the fight. The engine keeps the session open with the mob passive at full HP and the
2468
+ // rotation idle, which held a Razormane Scout fight for 970 s. Here a Quilboar is pinned 50 yd out once it is
2469
+ // in combat, so it cannot close and evades; the fight must settle as a stalemate a few seconds later.
2470
+ {
2471
+ const termQ = new HeadlessTerm(110, 34);
2472
+ const appQ = new App({ term: termQ, ds, repo });
2473
+ termQ.open();
2474
+ 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);
2475
+ const boar = ds.creatures().find((c) => c.name === 'Razormane Quilboar');
2476
+ const from = appQ.log.length;
2477
+ startFight(appQ, boar.entry);
2478
+ const s = appQ.fight.session, side = s.enemiesView()[0].side;
2479
+ let engagedAt = null, ticks = 0;
2480
+ for (; ticks < 1200 && appQ.fight && !appQ.fight.ended; ticks++) {
2481
+ if (engagedAt == null && s.enemiesView()[0].engaged) engagedAt = s.t;
2482
+ s.setUnitPosYd('player', { x: 0, y: 0 });
2483
+ if (engagedAt != null) s.setUnitPosYd(side, { x: 50, y: 0 });
2484
+ appQ.fight.tick();
2485
+ }
2486
+ const said = appQ.log.slice(from).map(strip);
2487
+ const reset = said.some((l) => /cannot reach you and resets/.test(l));
2488
+ check(`a foe that resets ends the fight as a stalemate (${(s.t / 1000).toFixed(1)} s, reset ${reset})`,
2489
+ reset && said.some((l) => /resets and the fight is over/.test(l)) && said.some((l) => /^Stalemate against/.test(l)) && s.t < 20_000);
2490
+ appQ.fight?.release?.();
2491
+ }
2492
+
2493
+ // c on the World tab saves, lets go of the character and hands the screen back to the picker.
2494
+ {
2495
+ const termL = new HeadlessTerm(110, 34);
2496
+ const repoL = { saved: 0, unlocked: [], async save() { this.saved++; }, async list() { return []; }, async load() { return null; }, unlock(s) { this.unlocked.push(s); } };
2497
+ const appL = new App({ term: termL, ds, repo: repoL });
2498
+ termL.open();
2499
+ appL.startGame(newGameState(ds, human.id, 1, 'Logouttest', 777, 0), 'logouttest', Date.now(), true);
2500
+ let picked = 0;
2501
+ appL.toPicker = async () => { picked++; };
2502
+ termL.press('c');
2503
+ await new Promise((r) => setImmediate(r));
2504
+ 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);
2505
+ }
2506
+
2507
+ // Sixel art, last: once on it stays on for the process. The terminal says it draws sixel (DA1 parameter 4);
2508
+ // art then travels as placeholders whose markers the terminal layer takes out and places.
2509
+ {
2510
+ noteDeviceAttributes('?62;4;22');
2511
+ const art = creatureArt(2332, 28, 14);
2512
+ const mark = art?.[0]?.match(/\x1b\[9999;(\d+);0m/);
2513
+ 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 ?? ''));
2514
+ // The shipped worn pack fills what the downloaded hd pack lacks: forms and costumes draw sharp too.
2515
+ 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] ?? '')));
2516
+ const placed = new Map();
2517
+ const rows = art.map((l, r) => takeMarks(`ab${l}${l}`, 5 + r, placed));
2518
+ 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')));
2519
+ 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);
2520
+ const map = worldMapLines(app, { maxCols: 40, maxRows: 12 });
2521
+ 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])));
2522
+ 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')));
2523
+ const icon = iconCells('spell_holy_holysmite', 6, '', [{ x: 4, y: 2, w: 2 }]);
2524
+ 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 ?? ''));
2525
+ }
2526
+
1931
2527
  check('frames were drawn', term.frames > 20);
1932
2528
  check('saves happened', repo.saved > 0);
1933
2529
  console.log(failures ? `\n${failures} check(s) failed` : '\nall TUI checks passed');