azeroth-shell 0.1.1 → 0.2.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 (66) hide show
  1. package/README.md +326 -82
  2. package/bin/azsh.mjs +21 -19
  3. package/package.json +19 -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/fileRepository.mjs +46 -1
  8. package/src/cli/gzLoader.mjs +47 -6
  9. package/src/cli/hdArt.mjs +81 -0
  10. package/src/cli/iconArt.mjs +135 -0
  11. package/src/cli/iconGrid.mjs +100 -0
  12. package/src/cli/modelArt.mjs +295 -0
  13. package/src/cli/paperdoll.mjs +76 -0
  14. package/src/cli/perfCheck.mjs +56 -0
  15. package/src/cli/repl.mjs +36 -7
  16. package/src/cli/rotationText.mjs +6 -6
  17. package/src/cli/sixel.mjs +307 -0
  18. package/src/cli/term.mjs +106 -12
  19. package/src/cli/text.mjs +5 -3
  20. package/src/cli/tui.mjs +252 -98
  21. package/src/cli/tuiAutopilot.mjs +347 -0
  22. package/src/cli/tuiBg.mjs +371 -42
  23. package/src/cli/tuiCheck.mjs +415 -28
  24. package/src/cli/tuiCity.mjs +118 -13
  25. package/src/cli/tuiCompanions.mjs +25 -15
  26. package/src/cli/tuiCreate.mjs +223 -0
  27. package/src/cli/tuiDungeon.mjs +1 -1
  28. package/src/cli/tuiFight.mjs +425 -88
  29. package/src/cli/tuiFightPanes.mjs +27 -8
  30. package/src/cli/tuiFloor.mjs +2 -2
  31. package/src/cli/tuiGear.mjs +10 -6
  32. package/src/cli/tuiLoot.mjs +1 -0
  33. package/src/cli/tuiPaperdoll.mjs +107 -0
  34. package/src/cli/tuiPartyStage.mjs +177 -0
  35. package/src/cli/tuiProfessions.mjs +4 -2
  36. package/src/cli/tuiRotationCond.mjs +390 -0
  37. package/src/cli/tuiSaves.mjs +22 -3
  38. package/src/cli/tuiSearch.mjs +75 -22
  39. package/src/cli/tuiSettings.mjs +15 -0
  40. package/src/cli/tuiSimLab.mjs +1 -1
  41. package/src/cli/tuiStage.mjs +304 -0
  42. package/src/cli/tuiTalentTree.mjs +124 -0
  43. package/src/cli/tuiViews.mjs +296 -115
  44. package/src/cli/tuiWorld.mjs +4 -5
  45. package/src/data/gear-art.bin.br +0 -0
  46. package/src/data/hd-art.json +114 -0
  47. package/src/data/icon-art.bin.gz +0 -0
  48. package/src/data/model-art.bin.gz +0 -0
  49. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  50. package/src/domain/bg/match.ts +85 -24
  51. package/src/domain/bg/strategies.ts +144 -29
  52. package/src/domain/bg/types.ts +2 -0
  53. package/src/domain/bg/wsg.ts +35 -20
  54. package/src/domain/quests/doQuest.ts +24 -6
  55. package/src/domain/rotation/build.ts +3031 -3031
  56. package/src/domain/rotation/classes/paladin.ts +16 -11
  57. package/src/domain/rotation/evaluate.ts +3 -0
  58. package/src/engine/combat/applySpell.ts +20 -9
  59. package/src/engine/combat/applySpellLate.ts +16 -12
  60. package/src/engine/combat/areaAura.ts +284 -283
  61. package/src/engine/combat/fighterFactory.ts +1 -1
  62. package/src/engine/combat/pickAction.ts +39 -1
  63. package/src/engine/combat/procsDamage.ts +31 -1
  64. package/src/engine/combat/sessionChase.ts +7 -1
  65. package/src/engine/combat/sessionMotionKit.ts +40 -2
  66. package/src/quests/quests.ts +60 -0
@@ -2,14 +2,23 @@
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 { strip, wrapLines } from './text.mjs';
10
13
  import { visibleBars } from './tuiBars.mjs';
14
+ import { defaultConditionsForSpell } from './tuiRotationCond.mjs';
15
+ import { creatureArt, hasArt, artWidth } from './modelArt.mjs';
16
+ import { nativeDisplayId } from '../domain/character/displayForm.ts';
17
+ import { paperdollLayers, paperdollArt } from './paperdoll.mjs';
18
+ import { iconsOn, withIcon, wrapIconLines } from './iconArt.mjs';
11
19
  import { addItem, countItem } from '../domain/items/inventory.ts';
12
20
  import { moneyText as moneyTextOf } from '../domain/economy/money.ts';
21
+ import { offeredQuests } from '../quests/quests.ts';
13
22
 
14
23
  const showFrames = process.argv.includes('--frames');
15
24
  let failures = 0;
@@ -40,12 +49,20 @@ const frame = () => term.text();
40
49
  const home = () => { let n = 0; while (app.stack.length && n++ < 10) term.press('escape'); };
41
50
  const show = (label) => { if (showFrames) console.log(`\n=== ${label} ===\n${frame()}`); };
42
51
  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. */
52
+ /** Run a live fight to its end by hand, through the hold a win keeps on screen after it. */
44
53
  const finishFight = () => { let n = 0; while (app.fight && n < 3000) { app.fight.tick(); n++; } return n; };
45
54
 
46
55
  show('world');
47
56
  check('header shows the character', frame().includes('Tuitest') && frame().includes('Elwynn Forest'));
48
57
  check('world lists creatures', rowIndex((r, s) => r.data?.creature && /Kobold/.test(s)) >= 0);
58
+ {
59
+ // The People rows draw their NPC: the art pack bakes every vendor, trainer and innkeeper a zone lists.
60
+ const vendor = rowIndex((r, s) => /<General Supplies>/.test(s));
61
+ const saved = app.top().cursor;
62
+ app.top().cursor = vendor; app.render();
63
+ check('a vendor row draws the merchant beside their title', vendor >= 0 && /[▀▄]/.test(frame()));
64
+ app.top().cursor = saved; app.render();
65
+ }
49
66
  {
50
67
  const rs = app.rowsOf(app.top()).filter((r) => r.header).map((r) => strip(r.text));
51
68
  check('world lists quests last, after creatures', rs.indexOf('Quests') > rs.findIndex((s) => s.startsWith('Creatures')));
@@ -53,11 +70,55 @@ check('world lists creatures', rowIndex((r, s) => r.data?.creature && /Kobold/.t
53
70
 
54
71
  // Tabs 2..9 and 0 all render without throwing; Left/Right and Esc move between them.
55
72
  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())));
73
+ // Rules are two lines each with their icons, so the Combat phase can start past the first window.
74
+ check('rotation tab shows rules', (term.press('6'), /Combat \(checked/.test(frame()) || (term.press('pagedown'), /Combat \(checked/.test(frame()))));
57
75
  check('talents tab renders', (term.press('7'), /Talents unlock|point/.test(frame())));
58
76
  check('0 opens the tenth tab', (term.press('0'), app.tab === 9));
59
77
  const lastTab = app.tabs.length - 1;
60
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()));
80
+ {
81
+ // Starting gear is a shirt, pants, boots and a weapon or two: at least three of them have an appearance overlay.
82
+ const p = paperdollLayers(app.ch);
83
+ 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
+ }
85
+ if (iconsOn()) {
86
+ // A big window: the Gear tab is the character sheet and the Talents tab the three trees, both icon grids
87
+ // the arrows walk in two dimensions. At the usual 110x34 both list instead (the checks above).
88
+ const [cols, rows] = [term.cols, term.rows];
89
+ term.cols = 160; term.rows = 54;
90
+ const slot = () => app.rowsOf(app.top())[app.top().cursor]?.cell?.slot;
91
+ const rowIndexOfSlot = (s) => app.rowsOf(app.top()).findIndex((r) => r.cell?.slot === s);
92
+ app.switchTab(3);
93
+ check('a big window draws the Gear tab as the sheet', !!app.top().gridNav && /Equipment/.test(frame()) && /Head/.test(frame()));
94
+ app.top().cursor = 0; app.render();
95
+ term.press('shift-right');
96
+ const across = slot();
97
+ term.press('shift-left', 'shift-down', 'shift-down', 'shift-down', 'shift-down', 'shift-down', 'shift-down', 'shift-down');
98
+ const below = slot();
99
+ term.press('down');
100
+ check('Shift+arrows walk the sheet: head across to hands, down the left to the main hand', across === 'hands' && below === 'mainhand' && slot() === 'offhand');
101
+ term.press('right');
102
+ check('Right still changes the tab on the sheet', app.tab === 4);
103
+ app.switchTab(3);
104
+ app.top().cursor = rowIndexOfSlot('mainhand');
105
+ term.press('return');
106
+ check('Enter on a sheet slot opens its picker', app.top().id === 'slot-mainhand');
107
+ home();
108
+ app.switchTab(6);
109
+ const names = app.ds.talentTrees(app.ch.cls).map((t) => t.name);
110
+ check('the Talents tab draws every tree side by side', !!app.top().gridNav && names.every((n) => frame().includes(n)));
111
+ app.top().cursor = 0; app.render();
112
+ const first = app.rowsOf(app.top())[0].node;
113
+ term.press('shift-right');
114
+ const next = app.rowsOf(app.top())[app.top().cursor].node;
115
+ 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
+ term.cols = cols; term.rows = rows;
117
+ app.switchTab(lastTab);
118
+ // A tooltip's long sentence wraps beside its icon while they share rows, not back under the art.
119
+ 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
+ check('tooltip text wraps beside its icon', tip.slice(0, 4).every((l) => l.startsWith('\x1b[38;2;')) && tip.length > 4);
121
+ }
61
122
  {
62
123
  const charRows = app.rowsOf(app.top()).map((r) => strip(r.text)).join('|');
63
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));
@@ -88,6 +149,8 @@ if (firstQuest > 0) {
88
149
  check('Enter on Accept closed the dialog', app.stack.length === 0);
89
150
  term.press('2'); show('quest log');
90
151
  check('quest log lists it', /1 of 20/.test(frame()));
152
+ const zoneOffers = offeredQuests(app.ds, app.ch.zoneId, app.ch, app.g.state.questlog).filter((o) => o.canAccept).length;
153
+ 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
154
  }
92
155
 
93
156
  // Esc closes a window; the log window opens and closes.
@@ -130,9 +193,18 @@ const mobRow = app.rowsOf(app.top()).findIndex((r) => r.data?.creature);
130
193
  app.top().cursor = mobRow; term.press('return');
131
194
  check('live fight started', !!app.fight);
132
195
  show('fight start');
196
+ {
197
+ const art = creatureArt(384, 20, 10); // Hogger
198
+ 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));
199
+ const foeDisplay = ds.creature(app.fight?.enemyEntry)?.displayId;
200
+ check('the fight shows the foe beside the frames', !hasArt(foeDisplay) || /[▀▄]/.test(frame()));
201
+ }
133
202
  const barName = app.fight?.abilityBar()[0]?.spell.name;
134
203
  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());
204
+ // The duel stage keeps the ability bar on under auto-play too (its cooldowns are worth watching), with the
205
+ // plates under the sprites' feet: your name, level and class, then the bars; and a floor under it all.
206
+ check('auto-play shows the stage with the ability bar', hasBar() && new RegExp(`^\\s+${app.ch.name} L\\d+ Warrior`, 'm').test(strip(frame())));
207
+ check('the stage stands on a floor', /[▁▂_]{40}/.test(strip(frame())));
136
208
  let ticks = 0;
137
209
  let paneFrames = null;
138
210
  while (app.fight && ticks < 3000) {
@@ -194,34 +266,106 @@ check('command line ran rest', app.log.some((l) => /You rest for 30 s/.test(stri
194
266
 
195
267
  // Autopilot: steps by hand; its fights are live sessions that we tick to the end.
196
268
  home(); term.press('1'); term.press('a');
197
- check('autopilot started', !!app.auto);
269
+ check('a opens the pre-flight card', app.top()?.id === 'autopilot-preflight' && /Stops\s+.*deaths in a row/.test(strip(frame())));
270
+ show('autopilot pre-flight');
271
+ app.fightSpeed = 8; // at 4x and up the combat log folds to one line a fight
272
+ const ticksBefore = app.log.filter((l) => /^\s*\d+\.\ds /.test(strip(l))).length;
273
+ term.press('return');
274
+ check('Enter on the card starts the autopilot', !!app.auto && app.stack.length === 0);
198
275
  const autoCtl = app.auto;
199
- let steps = 0, liveFights = 0, fightTicks = 0;
276
+ let steps = 0, liveFights = 0, fightTicks = 0, heldWin = false;
277
+ /** The first win: the fight stays on the stage with the foe at 0 and the drops beside it, the planner waits,
278
+ * and the screen moves on once the hold's ticks are spent. */
279
+ const checkWinHold = () => {
280
+ const f = app.fight, h = f.holding;
281
+ const stageRows = () => { const rows = strip(frame()).split('\n'); return rows.slice(2, rows.findIndex((l) => /^── Log/.test(l))); };
282
+ 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)));
283
+ const stepBefore = autoCtl.planner.step; autoCtl.step();
284
+ check('the planner waits out the hold', autoCtl.planner.step === stepBefore && app.fight === f);
285
+ if (h.loot.length) {
286
+ while (app.fight === f && f.holdMs() < h.stagger + 300) f.tick();
287
+ const name = ds.item(h.loot[0].item)?.name;
288
+ check(`what it dropped slides in on the stage (${name})`, app.fight === f && stageRows().some((l) => l.includes(name)));
289
+ show('autopilot win hold');
290
+ }
291
+ let n = 0; while (app.fight === f && n++ < 200) f.tick();
292
+ check(`the hold lets go on its own (${n} ticks)`, !app.fight && n <= h.ticks);
293
+ };
200
294
  while (app.auto && steps < 60) {
201
295
  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;
296
+ if (!liveFights) {
297
+ show('autopilot fight');
298
+ check('an autopilot fight says what it is for', /(\d+\/\d+|hunting for xp|questing in)/.test(strip(frame()).split('\n')[2] ?? ''));
299
+ check('the run panel shows the foe beside the fight', !hasArt(ds.creature(app.fight.enemyEntry)?.displayId) || /[▀▄]/.test(frame()));
300
+ 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())));
301
+ check('a run keeps only the XP line for a header', app.compactHeader() && /^ XP /.test(strip(frame())) && !/1 World/.test(strip(frame())));
302
+ check('your plate sits on the stage, once', (strip(frame()).match(new RegExp(`^\\s+${app.ch.name} L\\d+ `, 'mg')) ?? []).length === 1);
303
+ term.press('w'); check('w opens the full fight view', !!app.auto.watchFight && !app.inlineFight() && /^ Fighting /m.test(strip(frame())));
304
+ term.press('escape'); check('Esc comes back to the run panel without running', !!app.fight && !!app.auto && app.inlineFight());
305
+ 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] ?? ''));
306
+ term.press('w'); check('w returns to the panel with the fight still on', !app.auto.browsing && app.inlineFight());
307
+ }
308
+ liveFights++;
309
+ if (!heldWin) { const f = app.fight; while (app.fight === f && !f.ended) { f.tick(); fightTicks++; } if (f.holding) { heldWin = true; checkWinHold(); } }
310
+ fightTicks += finishFight(); continue;
204
311
  }
205
312
  if (!app.auto.step()) break;
206
313
  steps++;
207
314
  }
315
+ check('the autopilot won a fight to hold on', heldWin);
208
316
  show('autopilot');
209
317
  if (app.auto) {
210
318
  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));
319
+ check('between fights the autopilot keeps its own panel', app.compactHeader() && /^ Now\s/m.test(strip(frame())) && strip(frame()).includes(app.ch.name));
212
320
  show('autopilot between fights');
321
+ app.fightPane = 'loot'; app.render();
322
+ check('between fights the autopilot keeps the fight pane', /Loot this sitting/.test(strip(frame())));
323
+ term.press('tab'); check('Tab between autopilot fights cycles the pane, not the tabs', app.fightPane === 'run' && !app.auto.browsing);
324
+ 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
+ show('autopilot run pane');
326
+ term.press('tab'); check('Tab moves on to the Quests pane', app.fightPane === 'quests');
327
+ term.press('tab'); term.press('tab'); check('Tab wraps back to the log after Bags', app.fightPane === 'log');
328
+ 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())));
329
+ check('the panel is five lines at most between fights', strip(frame()).split('\n').slice(2, 12).filter((l) => l.trim()).length <= 5);
330
+ app.fightPane = 'quests'; app.render();
331
+ 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
+ show('autopilot quests pane');
333
+ 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())));
335
+ const bagCursor = app.bagsPane.cursor; term.press('down');
336
+ check('Down moves the Bags pane cursor', app.bagsPane.cursor > bagCursor && !!app.auto);
337
+ const logBefore = app.log.length; term.press('return');
338
+ check('Enter on a bag row acts while the run plays on', (app.log.length > logBefore || app.stack.length > 0) && !!app.auto);
339
+ home(); show('autopilot bags pane');
340
+ app.fightPane = 'log';
341
+ term.press('space'); const stepBefore = autoCtl.planner.step; autoCtl.step();
342
+ check('space pauses the run between fights', autoCtl.paused && autoCtl.planner.step === stepBefore && /PAUSED/.test(strip(frame())));
343
+ term.press('space'); check('space resumes it', !autoCtl.paused);
213
344
  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())));
345
+ check('the run strip sits under the tabs while browsing', /Autopilot.*fights.*back to the run/.test(strip(frame()).split('\n')[3] ?? ''));
346
+ show('autopilot run strip');
347
+ // A browsed tab works during the run: Enter acts on the row (here a bag item) instead of jumping back.
348
+ { 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(); }
349
+ term.press('7'); check('the Talents tab opens during the run', app.tab === 6 && app.auto.browsing);
350
+ term.press('escape'); check('Esc on a tab comes back to the run instead of stopping it', !!app.auto && !app.auto.browsing);
351
+ term.press('3');
352
+ term.press('w'); check('w goes back to watching the run', !app.auto.browsing && app.compactHeader() && /^ Now\s/m.test(strip(frame())));
215
353
  term.press('l'); check('log window opens over the autopilot', app.top().id === 'log');
216
354
  term.press('escape'); check('Esc closes the log and leaves the autopilot running', app.stack.length === 0 && !!app.auto);
217
- term.press('1');
355
+ term.press('1'); term.press('w'); // back on the panel, where Esc stops the run
218
356
  }
219
357
  check(`autopilot fought ${liveFights} live fights over ${steps} steps (${fightTicks} ticks)`, liveFights > 0 && app.log.some((l) => /hunting:|quest target:/.test(strip(l))));
220
358
  check('autopilot counted its fights', autoCtl.fights === liveFights);
221
- if (app.auto) { term.press('escape'); check('Esc stops the autopilot', !app.auto); }
359
+ 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))));
360
+ if (app.auto) {
361
+ term.press('escape'); check('Esc stops the autopilot', !app.auto);
362
+ 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())));
363
+ show('autopilot end card');
364
+ term.press('return'); check('Enter closes the end card', app.stack.length === 0);
365
+ }
222
366
 
223
367
  // Autopilot fight interrupted by Esc stops the run.
224
- term.press('a');
368
+ term.press('a'); term.press('return');
225
369
  while (app.auto && !app.fight && app.auto.step()) { /* reach a fight */ }
226
370
  if (app.fight) { term.press('escape'); check('Esc in an autopilot fight stops the run', !app.fight && !app.auto); }
227
371
  else { if (app.auto) app.auto.stop(); check('autopilot reached a fight to interrupt', false); }
@@ -235,6 +379,13 @@ else { if (app.auto) app.auto.stop(); check('autopilot reached a fight to interr
235
379
  check('dying on a quest did not stop the run', !lost.done);
236
380
  }
237
381
 
382
+ // The windowed run has no step cap: the Stop after setting ends it, on the run's real clock.
383
+ {
384
+ const timed = app.cmds.createAutopilot('', { maxSteps: Infinity, clock: () => 25 * 3_600_000, liveFight: () => ({ ok: true }) });
385
+ timed.next();
386
+ check('a run stops on the Stop after clock, not a step count', timed.steps === Infinity && timed.stopReason?.reason === 'timeUp');
387
+ }
388
+
238
389
  // Do Quest: d on a quest log row runs the autopilot pinned to that quest, and clears its blacklist entry.
239
390
  home(); term.press('2');
240
391
  const doRow = rowIndex((r) => r.data?.qs?.status === 'active');
@@ -272,11 +423,38 @@ check('add-rule picker opened', app.top().id === 'pick');
272
423
  term.press('return');
273
424
  check('rule added to combat', app.g.rotation().phases.combat.length === before + 1);
274
425
  check('cursor landed on the new rule', app.rowsOf(app.top())[app.top().cursor]?.data?.rule?.id?.startsWith('tui-'));
426
+ {
427
+ const added = app.rowsOf(app.top())[app.top().cursor].data.rule;
428
+ const want = defaultConditionsForSpell(ds.spell(added.action.spellId), app.ch);
429
+ check('new rule starts with the smart default conditions', JSON.stringify(added.conditions) === JSON.stringify(want));
430
+ }
275
431
  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"'));
432
+ check('condition editor opened', app.top().id === 'conds');
433
+ show('condition editor');
434
+ app.top().cursor = rowIndex((r, t) => /\+ Add a condition/.test(t)); term.press('return');
435
+ check('metric catalog opened', app.top().id === 'pick' && /What to check/.test(app.top().title));
436
+ app.top().cursor = rowIndex((r, t) => /living enemies/.test(t)); term.press('return');
437
+ check('condition added from the catalog', app.g.rotation().phases.combat.some((r) => r.id.startsWith('tui-') && r.conditions.some((c) => c.metric === 'enemyCount')));
438
+ check('one condition menu opened', app.top().id === 'cond');
439
+ app.top().cursor = rowIndex((r, t) => /^Value:/.test(t)); term.press('return'); term.type('3');
440
+ 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)));
441
+ show('one condition');
442
+ app.top().cursor = rowIndex((r, t) => /^Check:/.test(t)); term.press('return'); term.press('/'); term.type('ally');
443
+ check('/ searches the catalog', app.top().id === 'pick' && /matching "ally"/.test(app.top().title));
444
+ app.top().cursor = rowIndex((r, t) => /^\s*health %$/.test(t)); term.press('return');
445
+ app.top().cursor = rowIndex((r, t) => /^Who:/.test(t)); term.press('return');
446
+ app.top().cursor = rowIndex((r, t) => /an ally/.test(t)); term.press('return');
447
+ check('an ally subject shows the ally filter', rowIndex((r, t) => /Which ally/.test(t)) >= 0);
448
+ app.top().cursor = rowIndex((r, t) => /Which ally/.test(t)); term.press('return');
449
+ app.top().cursor = rowIndex((r, t) => /a healer/.test(t)); term.press('return');
450
+ 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'));
451
+ show('ally condition');
452
+ term.press('escape');
453
+ app.top().cursor = rowIndex((r, t) => /Quick presets/.test(t)); term.press('return');
454
+ app.top().cursor = rowIndex((r, t) => /My health is below/.test(t)); term.press('return'); term.type('50');
455
+ 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
456
  show('rotation edited');
457
+ term.press('escape'); // leave the condition editor
280
458
  term.press('return'); term.press('end'); term.press('return'); // menu → delete
281
459
  check('rule deleted through the menu', app.g.rotation().phases.combat.length === before);
282
460
 
@@ -385,6 +563,24 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
385
563
  check('auction dialog opened', app.top().id === 'auction');
386
564
  const listings = app.g.browseAuctions(Date.now()).length;
387
565
  check(`auction house has bot listings (${listings})`, listings > 0);
566
+ // Categories: Left/Right picks an item class, [ ] a subclass inside it; Left back round to All shows everything.
567
+ const shown = () => app.rowsOf(app.top()).filter((r) => r.data?.auction).map((r) => app.ds.item(r.data.auction.item));
568
+ term.press('right'); show('auction category');
569
+ const inCat = shown(), cls = inCat[0]?.itemClass;
570
+ check(`Right picks a category (${inCat.length} of ${listings})`, inCat.length > 0 && inCat.length < listings && inCat.every((it) => it.itemClass === cls));
571
+ const onStrip = () => app.rowsOf(app.top())[app.top().cursor]?.data?.strip;
572
+ check('the cursor starts on a listing, not a strip', !onStrip());
573
+ term.press('up');
574
+ check(`Up from the first listing lands on a strip (${onStrip()})`, !!onStrip());
575
+ if (onStrip() === 'sub') {
576
+ term.press('right'); show('auction subcategory');
577
+ const inSub = shown(), subOf = (it) => it.itemSubclass ?? it.weaponSubclass ?? it.armorSubclass;
578
+ 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])));
579
+ check('the cursor stays on the strip', onStrip() === 'sub');
580
+ term.press('down');
581
+ check('Down from the strip goes to the listings', !onStrip() && !!app.rowsOf(app.top())[app.top().cursor]?.data?.auction);
582
+ } else term.press('down');
583
+ term.press('left'); check('Left back to All lists every listing', shown().length === listings);
388
584
  term.press('s'); term.type('cloth'); show('auction search');
389
585
  check('search filters the boards', /"cloth"/.test(strip(frame())));
390
586
  term.press('tab'); show('auction sell');
@@ -463,7 +659,9 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
463
659
  term.press('return'); show('candidate page');
464
660
  check('Enter opens the candidate page', app.top().id.startsWith('cand-'));
465
661
  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');
662
+ app.top().cursor = rowIndex((r, t) => /^Hire as/.test(t)); app.render();
663
+ check('the hire row draws the candidate in their gear beside the cost', !hasArt(nativeDisplayId(human.id, 0)) || /[▀▄]/.test(frame()));
664
+ term.press('return'); show('after hire');
467
665
  check('hiring returns to the hire list, not the tab', app.top()?.id === 'hire');
468
666
  term.press('escape'); check('Esc closes the hire list', app.stack.length === 0);
469
667
  const comp = (g.state.squad ?? [])[0];
@@ -472,6 +670,7 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
472
670
  const row = rowIndex((r, t) => t.startsWith(comp.name));
473
671
  app.top().cursor = row; term.press('return'); show('companion page');
474
672
  check('Enter opens their page', app.top().id === `comp-${comp.id}`);
673
+ check('their page draws them beside the Role note', !hasArt(nativeDisplayId(comp.race, comp.gender)) || /[▀▄]/.test(frame()));
475
674
  // Make room for Tailoring: drop one primary when they hold two.
476
675
  const lines = g.companionProfessionsOf(comp.id)?.lines ?? [];
477
676
  const primaries = lines.filter((l) => l.kind !== 'secondary');
@@ -585,9 +784,31 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
585
784
  check('a solo pull brings nobody', !!app.fight && app.fight.session.alliesView().length === 0);
586
785
  finishFight();
587
786
  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))));
787
+ const quickEntry = app.rowsOf(app.top())[mobAt].data.creature.entry;
788
+ term.press(':'); term.type(`fight ${quickEntry}`);
789
+ // The log is capped at 2000 lines (say()), so read from the command's echo, not from a length taken before it.
790
+ const quickLog = app.log.map(strip);
791
+ const quickAt = quickLog.lastIndexOf(`> fight ${quickEntry}`);
792
+ check(':fight brings the party in Party mode', quickAt >= 0 && quickLog.slice(quickAt + 1).some((l) => squad.some((c) => l.includes(c.name))));
793
+ }
794
+
795
+ // ---- gear: a BoE a companion carries (a crafter's product) goes on from the Equipment tab, no trade first ----
796
+ {
797
+ const g = app.g, comp = g.state.squad[0];
798
+ const cloak = ds.items().find((i) => i.slot === 'back' && i.bonding === 2 && (i.requiredLevel ?? 1) <= app.ch.level && !i.allowClass?.length && i.entry > 100);
799
+ check('a BoE cloak exists to hand a companion', !!cloak && !!comp);
800
+ if (cloak && comp) {
801
+ comp.bags = addItem(comp.bags ?? { stacks: [] }, cloak.entry, 1, 1);
802
+ home(); app.switchTab(app.tabs.findIndex((t) => t.view.id === 'gear'));
803
+ app.top().cursor = rowIndex((r) => r.data?.slot === 'back' && !r.data?.upgrade); term.press('return'); show('back slot picker with companion bags');
804
+ const row = rowIndex((r, t) => t.includes(cloak.name) && t.includes(comp.name));
805
+ check("the slot picker lists the cloak from the companion's bags", row > 0);
806
+ if (row > 0) {
807
+ app.top().cursor = row; term.press('return');
808
+ const back = app.g.state.char.equipped.back;
809
+ check("Enter takes the companion's cloak and wears it", (back?.item ?? back) === cloak.entry && countItem(comp.bags, cloak.entry) === 0);
810
+ }
811
+ }
591
812
  }
592
813
 
593
814
  // ---- dungeons: the Deadmines as a text run ------------------------------------------------------------------
@@ -614,6 +835,28 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
614
835
  for (let n = 1; n <= 2; n++) {
615
836
  app.top().cursor = rowIndex((r, t) => /◀ next/.test(t)); term.press('return');
616
837
  check(`stop ${n} starts a live fight with the party`, !!app.fight?.withSquad && app.fight.session.alliesView().length >= 4);
838
+ if (n === 1 && app.fight) {
839
+ // The party stage: every body on one floor, the party's plates on one row (role letter, name) left of the
840
+ // pack's, an HP percent under each, the foe you face marked, and a cast bar under a caster while it casts.
841
+ for (let k = 0; k < 8 && app.fight; k++) app.fight.tick();
842
+ show('party stage');
843
+ const rows = strip(frame()).split('\n');
844
+ const plateRow = rows.findIndex((l) => /\bT \S/.test(l) && /\bH \S/.test(l) && l.includes(app.ch.name));
845
+ check('the party stage puts the tank, the healer and you on one plate row', plateRow > 0);
846
+ const pct = (rows[plateRow + 1] ?? '').match(/\d+%/g) ?? [];
847
+ const bodies = 1 + app.fight.session.alliesView().length + 1; // you, the party, at least the foe you face
848
+ check(`an HP percent sits under every sprite (${pct.length} of ${bodies}+)`, pct.length >= bodies);
849
+ check('the pack stands right of the party with the faced foe marked', plateRow > 0 && rows[plateRow].indexOf('◀') > rows[plateRow].lastIndexOf(app.ch.name));
850
+ check('the party stage stands on a floor', /[▁▂_]{40}/.test(strip(frame())));
851
+ let cast = null;
852
+ for (let k = 0; k < 400 && app.fight && !cast; k++) {
853
+ app.fight.tick();
854
+ const s = app.fight.session;
855
+ cast = [...s.alliesView(), ...s.enemiesView()].find((v) => v.hp > 0 && v.casting && v.casting.endsAt - s.t >= 300) ?? null;
856
+ }
857
+ check('a cast shows as a bar under the caster', !cast || strip(frame()).includes(cast.casting.name.slice(0, 8)));
858
+ if (cast) show('party stage cast');
859
+ }
617
860
  finishFight();
618
861
  check(`stop ${n} is cleared and the run window comes back`, g.state.instance.defeated.length === n && app.top()?.id === 'dungeon');
619
862
  }
@@ -1071,6 +1314,15 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1071
1314
  app.top().cursor = rowIndex((r, t) => /^\d+\s+empty$/.test(t.trim()) && !('b' in (r.data ?? {}))); term.press('return');
1072
1315
  app.top().cursor = rowIndex((r, t) => t.trim() === potion.name); term.press('return');
1073
1316
  check('a global bar slot takes a bag item', (g.state.globalActionBar?.items ?? []).includes(potion.entry));
1317
+ g.state.char.health = Math.max(1, Math.floor(g.state.char.health / 2));
1318
+ const potions = countItem(g.state.inventory, potion.entry);
1319
+ // The kobold fight above may have drunk one, and the potion cooldown runs on the wall clock now.
1320
+ g.state.spellCooldowns = {}; g.state.categoryCooldowns = {};
1321
+ app.top().cursor = rowIndex((r, t) => /Fire the whole global bar/.test(t)); term.press('return');
1322
+ check('firing the global bar drinks the potion', countItem(g.state.inventory, potion.entry) === potions - 1);
1323
+ app.top().cursor = rowIndex((r, t) => t.includes(potion.name) && !('b' in (r.data ?? {}))); term.press('return');
1324
+ app.top().cursor = rowIndex((r, t) => t.trim() === 'Clear'); term.press('return');
1325
+ check('Clear empties the global slot', !(g.state.globalActionBar?.items ?? []).includes(potion.entry));
1074
1326
  term.press('escape'); home(); term.press('1');
1075
1327
  g.state.char.health = Math.max(1, Math.floor(g.state.char.health / 2));
1076
1328
  const mob = app.rowsOf(app.top()).findIndex((r) => r.data?.creature);
@@ -1268,6 +1520,17 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1268
1520
  home(); term.press(':'); for (const ch of 'settings') term.press(ch); term.press('return');
1269
1521
  pickRow(/^Travel pace/);
1270
1522
  check('Settings sets a real-time travel pace', app.prefs.get('travelPace') === 1);
1523
+ pickRow(/^Screen refresh/);
1524
+ check('Settings steps the screen refresh up from 100 ms', app.prefs.get('refreshMs') === 250);
1525
+ // On a real terminal a game loop's repaint waits out the refresh; a key's render paints at once and drops it.
1526
+ term.isTTY = true;
1527
+ app.render(); const painted = term.frames;
1528
+ app.renderSoon(); app.renderSoon();
1529
+ check('renderSoon holds a repaint inside the refresh window', term.frames === painted && app.paintTimer != null);
1530
+ app.render();
1531
+ check('render paints and cancels the held repaint', term.frames === painted + 1 && app.paintTimer == null);
1532
+ term.isTTY = false;
1533
+ app.prefs.set('refreshMs', 100);
1271
1534
  home();
1272
1535
  const here = g.state.char.zoneId;
1273
1536
  const dest = (ds.zone(here)?.neighbors ?? []).map((id) => ds.zone(id)).find((z) => z && g.previewTravel(z.id).ok);
@@ -1275,7 +1538,8 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1275
1538
  show('on the road');
1276
1539
  check('a trip at 1x takes time', !!g.state.char.journey && g.state.char.zoneId !== dest.id);
1277
1540
  check('the header counts it down', strip(frame()).split('\n')[0].includes(`→ ${dest.name}`));
1278
- check('the World tab opens on the journey card', app.tab === 0 && /On the road to/.test(strip(frame())));
1541
+ check('the Travel tab stays open, on the journey card', app.tab === 7 && /On the road to/.test(strip(frame())));
1542
+ term.press('1'); check('the World tab carries the card too', /On the road to/.test(strip(frame()))); term.press('8');
1279
1543
  app.top().cursor = rowIndex((r, t) => /journey options/.test(t)); term.press('return');
1280
1544
  pickRow(/^Travel at 4x/);
1281
1545
  check('the trip can be sped up', g.journeyStatus()?.timeScale === 4);
@@ -1635,9 +1899,49 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1635
1899
  const app9 = new App({ term: term9, ds, repo });
1636
1900
  term9.open();
1637
1901
  const { characterCreation } = await import('./tui.mjs');
1638
- let created = null;
1639
- characterCreation(app9, (state) => { created = state; });
1640
- app9.top().cursor = app9.rowsOf(app9.top()).length - 1; term9.press('return');
1902
+ let created = null, cancelled = 0;
1903
+ characterCreation(app9, (state) => { created = state; }, { onCancel: () => { cancelled++; } });
1904
+ const row9 = (id) => app9.rowsOf(app9.top()).findIndex((r) => r.id === id);
1905
+ const text9 = () => term9.text();
1906
+ 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()));
1907
+ app9.top().cursor = row9('race'); term9.press('right');
1908
+ check('Right on Race steps to the next race of the faction', /Race\s+Dwarf/.test(text9()));
1909
+ app9.top().cursor = row9('faction'); term9.press('right');
1910
+ check('Right on Faction switches to its first race', /Faction\s+Horde/.test(text9()) && /Race\s+Orc/.test(text9()) && /Orc · Horde/.test(text9()));
1911
+ term9.press('left');
1912
+ check('flipping back returns to the race last chosen there', /Faction\s+Alliance/.test(text9()) && /Race\s+Dwarf/.test(text9()));
1913
+ term9.press('right');
1914
+ app9.top().cursor = row9('race'); term9.press('left');
1915
+ check('Left on Race wraps to the faction\'s last race', /Race\s+Troll/.test(text9()));
1916
+ // A troll cannot be a paladin or a druid: the class row only offers what the race may roll.
1917
+ app9.top().cursor = row9('class');
1918
+ const classes9 = new Set();
1919
+ for (let i = 0; i < 12; i++) { term9.press('right'); classes9.add(text9().match(/Class\s+(\w+)/)[1]); }
1920
+ check('Class cycles through the troll classes only', classes9.has('Hunter') && classes9.has('Shaman') && !classes9.has('Paladin') && !classes9.has('Druid'));
1921
+ term9.press('return');
1922
+ check('Enter on Class opens the class list over the screen', app9.top().title === 'Class' && app9.stack.length === 2 && /Hunter/.test(text9()));
1923
+ term9.press('escape');
1924
+ check('Esc closes the list before a game is open', app9.top().id === 'create' && app9.stack.length === 1);
1925
+ app9.top().cursor = row9('gender'); term9.press('right');
1926
+ check('Right on Gender flips it', /Gender\s+Female/.test(text9()) && /Female Troll/.test(text9()));
1927
+ app9.top().cursor = row9('skin');
1928
+ const skin9 = text9().match(/Skin Color\s+(\d+) of/)[1]; term9.press('right');
1929
+ check('Right on a look row changes it', text9().match(/Skin Color\s+(\d+) of/)[1] !== skin9);
1930
+ app9.top().cursor = row9('create'); term9.press('return');
1931
+ 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()));
1932
+ term9.press('n');
1933
+ check('n rolls a random name', /Name\s+[A-Z][a-z]+/.test(text9()));
1934
+ app9.top().cursor = row9('name'); term9.press('return'); term9.type('x1');
1935
+ check('a bad name is refused', !!app9.cmd && /Letters only/.test(text9()));
1936
+ term9.type('thrallina');
1937
+ check('typing a name lands on the Create row', !app9.cmd && app9.top().cursor === row9('create') && /Create Thrallina, Female Troll/.test(text9()));
1938
+ term9.press('return');
1939
+ 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);
1940
+ created = null; app9.stack = [];
1941
+ characterCreation(app9, (state) => { created = state; }, { onCancel: () => { cancelled++; } });
1942
+ term9.press('escape');
1943
+ check('Esc on the create screen goes back to the character list', cancelled === 1);
1944
+ app9.top().cursor = row9('random'); term9.press('return');
1641
1945
  check('the create screen rolls a random world bot', !!created?.char?.name && created.char.level >= 1);
1642
1946
  term9.close?.();
1643
1947
  home();
@@ -1685,6 +1989,39 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1685
1989
  await open();
1686
1990
  await choose(/^Keeper/);
1687
1991
  check('Enter on a save still plays it', played?.state?.char?.name === 'Keeper');
1992
+ // Locks: one character per process. A second repo stands in for a second terminal (same pid, so a
1993
+ // hand-written lock with a foreign live pid plays the other side; a dead pid is a stale lock).
1994
+ const { writeFileSync: writeLock, readFileSync: readLock } = await import('node:fs');
1995
+ const other = new FileSaveRepository(join(dir, 'saves'));
1996
+ check('lock() takes a free slot', repo10.lock('keeper').ok === true && existsSync(repo10.lockPath('keeper')));
1997
+ check('a lock we hold reads as free to us', repo10.lockedBy('keeper') === null);
1998
+ check('and as free to a second repo in the same process too', other.lockedBy('keeper') === null); // same pid: a reopen in-process is not a second terminal
1999
+ check('a different slot locks independently', other.lock('elsewhere').ok === true);
2000
+ writeLock(other.lockPath('ghost'), JSON.stringify({ pid: 2_000_000_000, at: 1 })); // no such process
2001
+ check('a stale lock (dead pid) is taken over', other.lock('ghost').ok === true && JSON.parse(readLock(other.lockPath('ghost'), 'utf8')).pid === process.pid);
2002
+ const livePid = process.ppid || process.pid + 1; // the parent shell is alive and is not us
2003
+ const foreign = JSON.stringify({ pid: livePid, at: Date.now() });
2004
+ writeLock(other.lockPath('taken'), foreign);
2005
+ const refused = other.lock('taken');
2006
+ check('a live lock from another process is refused', refused.ok === false && refused.pid === livePid && readLock(other.lockPath('taken'), 'utf8') === foreign);
2007
+ other.unlock('taken');
2008
+ check('unlock() never removes a lock we do not hold', existsSync(other.lockPath('taken')));
2009
+ repo10.unlock('keeper');
2010
+ check('unlock() releases ours', !existsSync(repo10.lockPath('keeper')));
2011
+ // The picker: a character held by another live process is marked and refuses to play or be managed.
2012
+ writeLock(repo10.lockPath('keeper'), foreign);
2013
+ played = null;
2014
+ await open(); show('save picker, keeper open elsewhere');
2015
+ const marked = () => app10.rowsOf(app10.top()).some((r) => /open in another terminal/.test(strip(r.text)));
2016
+ check('the picker marks a character open in another terminal', marked());
2017
+ await choose(/^Keeper.*open in another terminal/); // the imported copy is a Keeper too
2018
+ check('Enter refuses to play it', played === null && app10.log.map(strip).some((l) => /Keeper is open in another terminal/.test(l)));
2019
+ app10.top().cursor = at((r, t) => /^Keeper.*open in another terminal/.test(t)); term10.press('m'); await tick();
2020
+ check('m refuses to manage it', app10.top()?.title === 'Choose a character' && app10.log.map(strip).filter((l) => /Keeper is open in another terminal/.test(l)).length === 2);
2021
+ check('startGame on a held slot backs out', app10.startGame(newGameState(ds, human.id, 1, 'Keeper', 31, 0), 'keeper', Date.now(), true) === false && app10.g == null);
2022
+ writeLock(repo10.lockPath('keeper'), JSON.stringify({ pid: 2_000_000_000, at: 1 }));
2023
+ await open();
2024
+ check('a stale lock does not mark the character', !marked());
1688
2025
  term10.close?.();
1689
2026
  rmSync(dir, { recursive: true, force: true });
1690
2027
  if (exported) rmSync(exported, { force: true });
@@ -1742,15 +2079,42 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1742
2079
  app12.top().cursor = at((r, t) => /Warsong Gulch/.test(t)); term12.press('return');
1743
2080
  if (showFrames) console.log(`\n=== warsong gulch ===\n${term12.text()}`);
1744
2081
  check('the honor window starts Warsong Gulch', app12.top()?.id === 'bg' && !!g12.state.battleground && /flag/.test(strip(term12.text())));
1745
- app12.top().cursor = at((r, t) => /Your job/.test(t)); term12.press('return');
2082
+ app12.top().cursor = at((r, t) => /Autopilot:/.test(t)); term12.press('return'); // the orders picker
2083
+ app12.top().cursor = at((r, t) => /Autopilot:/.test(t)); term12.press('return'); // its job option
1746
2084
  app12.top().cursor = at((r, t) => /guard our flag/.test(t)); term12.press('return');
1747
2085
  check('you can change your job', app12.bg.world.units.get(app12.bg.selfId).role === 'defense');
1748
2086
  const bg = app12.bg;
2087
+ check('the gulch is drawn as a strip with you on it', /Horde.*mid.*Alliance/.test(strip(term12.text())) && /@/.test(strip(term12.text())));
2088
+ // Your own seat by place: an order takes you off autopilot, and once the gates open you walk toward mid.
2089
+ const { WSG_GEOMETRY } = await import('../domain/bg/wsg.ts');
2090
+ const me12 = bg.world.units.get(bg.selfId);
2091
+ const toMid = () => Math.hypot(me12.pos.x - WSG_GEOMETRY.mid.x, me12.pos.y - WSG_GEOMETRY.mid.y);
2092
+ const d0 = toMid();
2093
+ term12.press('o');
2094
+ app12.top().cursor = at((r, t) => /Go to mid/.test(t)); term12.press('return');
2095
+ check('an order by place takes your seat off autopilot', me12.human === true && me12.order.kind === 'attack-move');
2096
+ stepBg(app12, bg, 300);
2097
+ check('and you walk there yourself', toMid() < d0 - 50 && me12.order.kind === 'attack-move');
2098
+ // A team call holds your side against the strategy layer for a while, then hands them back.
2099
+ term12.press('t');
2100
+ app12.top().cursor = at((r, t) => /Everyone to mid/.test(t)); term12.press('return');
2101
+ const called = [...bg.commanded.keys()];
2102
+ stepBg(app12, bg, 25); // two strategy cycles
2103
+ 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; });
2104
+ 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);
2105
+ stepBg(app12, bg, 500);
2106
+ check('and lapses after its time', bg.commanded.size === 0);
2107
+ bg.speed = 8;
2108
+ term12.press('j');
2109
+ app12.top().cursor = at((r, t) => /guard our flag/.test(t)); term12.press('return');
2110
+ check('autopilot takes your seat back', me12.human === false && bg.my == null);
1749
2111
  const marks0 = g12.state.inventory.stacks.length;
1750
2112
  let n = 0;
1751
2113
  while (!bg.world.ended && n < 40000) { stepBg(app12, bg, 200); n += 200; }
1752
2114
  app12.render();
1753
2115
  check('the match plays to an end', bg.world.ended && bg.settled);
2116
+ check('inside its 25 minute clock, with a winner', bg.world.t <= 25 * 60_000 + 10_000 && bg.world.winner != null);
2117
+ 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())));
1754
2118
  check('honor and marks of honor settle', bg.marks > 0 && g12.state.inventory.stacks.length > marks0 && (g12.state.char.honor?.cp ?? 0) > 0);
1755
2119
  check('and you are out of the battleground', !g12.state.battleground);
1756
2120
  app12.top().cursor = at((r, t) => /Leave the battleground/.test(t)); term12.press('return');
@@ -1852,9 +2216,16 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1852
2216
  // World search: s on the World tab, a vendor item lists who sells it and where, a node its zones, a dungeon its door.
1853
2217
  {
1854
2218
  const txt = () => app.rowsOf(app.top()).map((r) => strip(r.text)).join('|');
1855
- const search = (q) => { home(); term.press('1'); term.press('s'); for (const ch of q) term.press(ch === ' ' ? 'space' : ch); term.press('return'); };
2219
+ const find = (key, q) => { home(); term.press('1'); term.press(key); for (const ch of q) term.press(ch === ' ' ? 'space' : ch); term.press('return'); };
2220
+ const search = (q) => find('S', q);
2221
+ find('s', 'hogger'); show('zone search');
2222
+ check('s searches the zone you stand in', app.top().id === 'world-search' && /Search Elwynn Forest/.test(strip(frame())) && /Hogger L11.*here/.test(txt()));
2223
+ find('s', 'deadmines');
2224
+ check('the zone search leaves out what is elsewhere', /Nothing by that name in Elwynn Forest/.test(txt()));
2225
+ term.press('a');
2226
+ check('a widens it to the whole world', /Deadmines.*Dungeon.*entrance in Westfall/.test(txt()));
1856
2227
  search('mining pick'); show('world search');
1857
- check('s opens the world search', app.top().id === 'world-search' && /Mining Pick/.test(txt()));
2228
+ check('S opens the world search', app.top().id === 'world-search' && /Mining Pick/.test(txt()));
1858
2229
  check('an item lists the vendors that sell it and where they stand', /sold by .*<.*>.*(here|Stormwind|Westfall|Loch Modan|Redridge)/.test(txt()));
1859
2230
  search('copper vein'); show('world search node');
1860
2231
  check('a gather node lists the zones it grows in', /Copper Vein.*Mining.*(here|Dun Morogh|Durotar|Mulgore|Westfall|Loch Modan)/.test(txt()));
@@ -1876,6 +2247,22 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1876
2247
  term.press('?'); check('help overlay', app.top().id === 'help'); term.press('escape');
1877
2248
  check('help closed', app.stack.length === 0);
1878
2249
 
2250
+ // Sixel art, last: once on it stays on for the process. The terminal says it draws sixel (DA1 parameter 4);
2251
+ // art then travels as placeholders whose markers the terminal layer takes out and places.
2252
+ {
2253
+ noteDeviceAttributes('?62;4;22');
2254
+ const art = creatureArt(2332, 28, 14);
2255
+ const mark = art?.[0]?.match(/\x1b\[9999;(\d+);0m/);
2256
+ 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 ?? ''));
2257
+ const placed = new Map();
2258
+ const rows = art.map((l, r) => takeMarks(`ab${l}${l}`, 5 + r, placed));
2259
+ 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
+ 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);
2261
+ 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
+ const icon = iconCells('spell_holy_holysmite', 6, '', [{ x: 4, y: 2, w: 2 }]);
2263
+ 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 ?? ''));
2264
+ }
2265
+
1879
2266
  check('frames were drawn', term.frames > 20);
1880
2267
  check('saves happened', repo.saved > 0);
1881
2268
  console.log(failures ? `\n${failures} check(s) failed` : '\nall TUI checks passed');