azeroth-shell 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +43 -10
  2. package/bin/azsh.mjs +15 -1
  3. package/package.json +5 -2
  4. package/src/cli/hdArt.mjs +1 -1
  5. package/src/cli/iconArt.mjs +11 -1
  6. package/src/cli/modelArt.mjs +7 -4
  7. package/src/cli/oocPrep.mjs +32 -0
  8. package/src/cli/repl.mjs +16 -0
  9. package/src/cli/sixel.mjs +26 -14
  10. package/src/cli/text.mjs +16 -0
  11. package/src/cli/tui.mjs +33 -9
  12. package/src/cli/tuiAutopilot.mjs +17 -8
  13. package/src/cli/tuiBags.mjs +37 -4
  14. package/src/cli/tuiCheck.mjs +276 -15
  15. package/src/cli/tuiCity.mjs +31 -31
  16. package/src/cli/tuiCompanionKit.mjs +6 -6
  17. package/src/cli/tuiCompanions.mjs +189 -29
  18. package/src/cli/tuiCreate.mjs +7 -0
  19. package/src/cli/tuiDungeon.mjs +56 -20
  20. package/src/cli/tuiFight.mjs +63 -21
  21. package/src/cli/tuiFightPanes.mjs +15 -6
  22. package/src/cli/tuiFloor.mjs +20 -8
  23. package/src/cli/tuiGear.mjs +9 -10
  24. package/src/cli/tuiLoot.mjs +5 -6
  25. package/src/cli/tuiMap.mjs +214 -0
  26. package/src/cli/tuiPaperdoll.mjs +6 -5
  27. package/src/cli/tuiPartyStage.mjs +36 -7
  28. package/src/cli/tuiPet.mjs +16 -8
  29. package/src/cli/tuiProfessions.mjs +23 -23
  30. package/src/cli/tuiSaves.mjs +14 -0
  31. package/src/cli/tuiSearch.mjs +4 -5
  32. package/src/cli/tuiSettings.mjs +3 -4
  33. package/src/cli/tuiStage.mjs +72 -16
  34. package/src/cli/tuiTalents.mjs +3 -4
  35. package/src/cli/tuiViews.mjs +371 -84
  36. package/src/cli/tuiWiki.mjs +2 -3
  37. package/src/cli/tuiWorld.mjs +88 -5
  38. package/src/data/gear-art.bin.br +0 -0
  39. package/src/data/hd-art.json +36 -36
  40. package/src/data/model-art-worn.bin.br +0 -0
  41. package/src/data/model-art.bin.gz +0 -0
  42. package/src/data/tables/creatures.json.gz +0 -0
  43. package/src/data/world-map-sixel.bin.br +0 -0
  44. package/src/data/world-map.bin.gz +0 -0
  45. package/src/domain/character/character.ts +6 -0
  46. package/src/domain/pet/pet.ts +31 -0
  47. package/src/engine/combat/pickAction.ts +3494 -3487
  48. package/src/engine/combat/sessionChase.ts +12 -1
  49. package/src/engine/game/loadReconcile.ts +21 -1
  50. package/src/engine/game/pets.ts +33 -1
  51. package/src/engine/game/types.ts +4 -0
@@ -3,17 +3,23 @@
3
3
  // held-item menu (panels/heldItemMenu.ts) is the reference; every verb is a Game facade call.
4
4
  import { bold, dim, yellow, green, red, qualityPaint, itemText } from './text.mjs';
5
5
  import { autoLootOn, openLoot, takeAll } from './tuiLoot.mjs';
6
- import { SLOT_LABEL, bagCapacity } from '../domain/items/equipment.ts';
6
+ import { SLOT_LABEL, bagCapacity, equipSlotPickTargets, DUAL_WIELD_SPELL_ID } from '../domain/items/equipment.ts';
7
+ import { equippedEntry } from '../domain/items/itemInstance.ts';
8
+ import { knows } from '../domain/character/spellbook.ts';
9
+ import { isDisenchantable, disenchantRuleFor } from '../domain/professions/enchanting.ts';
10
+ import { resolveRosterEnchanters, groupHasEnchanting, partyDisenchantCheck } from '../domain/professions/partyDisenchant.ts';
7
11
  import { freeSlots, isWrappingPaper, isFilledGift, canWrapTarget } from '../domain/items/inventory.ts';
8
12
  import { isOpenableContainer } from '../domain/items/itemContainer.ts';
9
13
  import { isAmmoItem } from '../domain/items/ammo.ts';
10
14
  import { itemTeachIntent } from '../domain/items/itemTeaches.ts';
11
15
  import { junkListOptions } from './tuiSettings.mjs';
12
- import { isMountItem, toggleMount } from './tuiWorld.mjs';
13
- import { questDialog } from './tuiViews.mjs';
16
+ import { isMountItem, toggleMount, afterDeparture } from './tuiWorld.mjs';
17
+ import { questDialog, landOnTab } from './tuiViews.mjs';
14
18
  import { useItemWithPicker, isPermanentEnchantItem } from './tuiEnchantTarget.mjs';
15
19
  import { bagQuestStartState } from '../quests/quests.ts';
16
20
 
21
+ const HEARTHSTONE_ITEM_ENTRY = 6948; // phonecraft src/ui/map/travelUi.ts
22
+
17
23
  const stacks = (app) => app.g.state.inventory.stacks;
18
24
  const hasLoot = (app) => (entry) => app.ds.lootTable(`item:${entry}`).length > 0;
19
25
  const isContainer = (app, it) => isOpenableContainer(it, hasLoot(app));
@@ -55,7 +61,8 @@ export function useStack(app, slot) {
55
61
  }
56
62
  if (isWrappingPaper(it)) { wrapWith(app, slot); return; }
57
63
  if (isContainer(app, it)) { openContainer(app, slot); return; }
58
- if (it?.slot) { const r = app.g.equip(s.item); app.result(r, r.ok ? (it.slot === 'bag' ? `You equip ${itemText(it)} as ${SLOT_LABEL[r.slot] ?? r.slot}.` : `You equip ${itemText(it)}.`) : ''); return; }
64
+ if (s.item === HEARTHSTONE_ITEM_ENTRY) { const r = app.g.castHearth(); if (app.result(r)) { app.vendor = null; afterDeparture(app, () => `You hearth to ${app.zone()?.name}.`); landOnTab(app); } return; }
65
+ if (it?.slot) { equipFromBags(app, it); return; }
59
66
  if (isAmmoItem(it)) {
60
67
  if (app.ch.ammoItem === it.entry) app.result(app.g.clearAmmo(), 'You put your ammo away.');
61
68
  else app.result(app.g.setAmmo(s.item), `You load ${itemText(it)}.`);
@@ -67,6 +74,30 @@ export function useStack(app, slot) {
67
74
  useItemWithPicker(app, s.item, (r) => (r.learned ? green(`You learn ${r.learned.name}.`) : green(`You use ${itemText(it)}. HP ${app.hpText()} ${app.pwText()}`)));
68
75
  }
69
76
 
77
+ /** Equip from the bags. With both rings, both trinkets or both hands full it asks which to replace rather than
78
+ * overwrite the first (phonecraft equipSlotPicker.ts); an empty one of the pair still fills by itself. */
79
+ function equipFromBags(app, it) {
80
+ const done = (r) => app.result(r, r.ok ? (it.slot === 'bag' ? `You equip ${itemText(it)} as ${SLOT_LABEL[r.slot] ?? r.slot}.` : `You equip ${itemText(it)}.`) : '');
81
+ const ch = app.ch, slots = equipSlotPickTargets(ch, it, { canDualWield: knows(app.g.state.spellbook, DUAL_WIELD_SPELL_ID) });
82
+ if (slots.length < 2) { done(app.g.equip(it.entry)); return; }
83
+ const opts = slots.map((slot) => { const cur = equippedEntry(ch.equipped[slot]); return { label: `${SLOT_LABEL[slot] ?? slot}: ${cur != null ? itemText(app.ds.item(cur)) : dim('empty')}`, value: slot }; });
84
+ app.pick(`Equip ${itemText(it)}: replace which?`, opts, (slot) => done(app.g.equip(it.entry, slot)));
85
+ }
86
+
87
+ /** Disenchant on one stack, for anyone in the group with the skill: hidden when nobody has Enchanting or the item
88
+ * yields nothing, the reason shown when the group is short of the skill. Ref heldItemMenu.ts disenchantRow. */
89
+ function disenchantOption(app, slot, s, it) {
90
+ if (!it || !isDisenchantable(it) || it.quality < 2 || !disenchantRuleFor(it, app.ds)) return null;
91
+ const g = app.g, enchanters = resolveRosterEnchanters(app.ch, g.state.squad, g.state.botRoster, g.state.raidRoster?.members, app.ds);
92
+ if (!groupHasEnchanting(enchanters)) return null;
93
+ const chk = partyDisenchantCheck(s.item, g.state.inventory, app.ds, enchanters);
94
+ if (!chk.ok) return chk.kind === 'skill' ? { label: `Disenchant ${dim(chk.reason)}`, value: () => app.flashMsg(chk.reason) } : null;
95
+ return { label: `Disenchant${chk.who.kind === 'companion' ? dim(` ${chk.who.name}, Enchanting ${chk.who.skill}`) : ''}`, value: () => {
96
+ const r = g.disenchantStack(slot);
97
+ if (app.result(r)) app.say(green(`${r.worker ? `${r.worker} breaks down` : 'You break down'} ${itemText(it)}: ${r.yielded.map((y) => `${app.itemLabel(y.item)}${y.count > 1 ? ` x${y.count}` : ''}`).join(', ') || 'nothing'}.`) + (r.skillUp != null && !r.worker ? dim(` Enchanting ${r.skillUp}`) : ''));
98
+ } };
99
+ }
100
+
70
101
  /** Open a clam, crate or lockbox into the loot window; auto-loot takes what fits straight away. */
71
102
  export function openContainer(app, slot) {
72
103
  const name = app.itemLabel(stacks(app)[slot]?.item);
@@ -159,6 +190,8 @@ export function itemMenu(app, slot) {
159
190
  if (slot > 0) opts.push({ label: `Move up ${dim('[')}`, value: () => moveStack(app, slot, -1) });
160
191
  if (slot < stacks(app).length - 1) opts.push({ label: `Move down ${dim(']')}`, value: () => moveStack(app, slot, +1) });
161
192
  opts.push({ label: `Move to the end`, value: () => app.result(app.g.moveItem(slot, stacks(app).length)) });
193
+ const de = disenchantOption(app, slot, s, it);
194
+ if (de) opts.push(de);
162
195
  opts.push(...junkListOptions(app, s.item));
163
196
  opts.push({ label: red(`Destroy ${dim('d')}`), value: () => destroyStack(app, slot) });
164
197
  app.pick(`${itemText(it, 'Item')}${countText(s)}`, opts, (fn) => fn());
@@ -9,16 +9,21 @@ import { iconCells } from './iconArt.mjs';
9
9
  import { App } from './tui.mjs';
10
10
  import { bootDataStore } from './bootData.mjs';
11
11
  import { newGameState } from '../engine/game.ts';
12
- import { strip, wrapLines } from './text.mjs';
12
+ import { newGameStateFromWorldBot } from '../engine/game/newGameState.ts';
13
+ import { maxPower } from '../domain/character/character.ts';
14
+ import { startFight } from './tuiFight.mjs';
15
+ import { strip, vis, wrapLines, coinText } from './text.mjs';
13
16
  import { visibleBars } from './tuiBars.mjs';
14
17
  import { defaultConditionsForSpell } from './tuiRotationCond.mjs';
15
18
  import { creatureArt, hasArt, artWidth } from './modelArt.mjs';
16
19
  import { nativeDisplayId } from '../domain/character/displayForm.ts';
17
20
  import { paperdollLayers, paperdollArt } from './paperdoll.mjs';
21
+ import { playerArt, questDialog } from './tuiViews.mjs';
22
+ import { worldMapLines } from './tuiMap.mjs';
18
23
  import { iconsOn, withIcon, wrapIconLines } from './iconArt.mjs';
19
24
  import { addItem, countItem } from '../domain/items/inventory.ts';
20
- import { moneyText as moneyTextOf } from '../domain/economy/money.ts';
21
25
  import { offeredQuests } from '../quests/quests.ts';
26
+ import { isTalkToNpcQuest } from '../domain/quests/questlog.ts';
22
27
 
23
28
  const showFrames = process.argv.includes('--frames');
24
29
  let failures = 0;
@@ -75,13 +80,33 @@ check('rotation tab shows rules', (term.press('6'), /Combat \(checked/.test(fram
75
80
  check('talents tab renders', (term.press('7'), /Talents unlock|point/.test(frame())));
76
81
  check('0 opens the tenth tab', (term.press('0'), app.tab === 9));
77
82
  const lastTab = app.tabs.length - 1;
78
- check('character tab renders', (app.switchTab(lastTab), /Attributes/.test(frame())));
79
- check('the character tab draws your body', !hasArt(nativeDisplayId(app.ch.race, app.ch.gender)) || /[▀▄]/.test(frame()));
83
+ check('the last tab is Settings', (app.switchTab(lastTab), app.top().id === 'settings' && /Auto-loot/.test(frame())));
84
+ check('the character tab heads with your name and lists your gear', (app.switchTab(3), frame().includes(app.ch.name) && /Chest/.test(strip(frame()))));
85
+ check('s on the character tab opens your stats', (term.press('s'), app.top().id === 'char' && /Attributes/.test(frame())));
86
+ check('the stats page draws your body', !hasArt(nativeDisplayId(app.ch.race, app.ch.gender)) || /[▀▄]/.test(frame()));
80
87
  {
81
88
  // Starting gear is a shirt, pants, boots and a weapon or two: at least three of them have an appearance overlay.
82
89
  const p = paperdollLayers(app.ch);
83
90
  check('your starting gear stacks on your body', !p || (p.layers.filter((l) => /^\d/.test(l.key)).length >= 3 && !!paperdollArt(app.ch, 20, 12)));
84
91
  }
92
+ {
93
+ // A form or a costume is the model you are drawn as: each one's model is in the pack, and a fight's live
94
+ // view (the form, a Polymorph debuff) beats the saved character. Cite: displayForm.ts wornDisplay.
95
+ const drawn = (view) => playerArt(app, 20, 12, view)?.lines.join('\n');
96
+ const as = (displayId, height = 1) => creatureArt(displayId, 20, Math.round(12 * height))?.join('\n');
97
+ const view = (o) => ({ buffs: [], debuffs: [], ...o });
98
+ const flipOut = app.ds.creature(app.ds.spell(8219)?.effects.find((e) => e.kind === 'transform')?.creatureEntry)?.displayId;
99
+ const saved = app.ch.activeAuras;
100
+ app.ch.activeAuras = [...(saved ?? []), { spellId: 8219, expiresAt: Date.now() + 60_000 }];
101
+ const costumed = drawn(null) === as(flipOut);
102
+ app.ch.activeAuras = saved;
103
+ check('every form and costume model is in the art pack', [892, 8571, 2281, 2289, 632, 2428, 4613, 15374, 15375, 864, 857, flipOut].every((id) => hasArt(id)));
104
+ check('cat form in a fight draws you as the cat', drawn(view({ form: 1 })) === as(892, 0.6));
105
+ check('the cat stands lower than you do on two legs', playerArt(app, 20, 12, view({ form: 1 })).lines.length < playerArt(app, 20, 12, view({ form: 0 })).lines.length);
106
+ check('a Polymorph on you draws the sheep over your form, critter-sized', drawn(view({ form: 1, debuffs: [{ spellId: 118 }] })) === as(857, 0.35));
107
+ check('a costume aura draws its model', costumed);
108
+ check('out of form you are in your gear again', drawn(view({ form: 0 })) === paperdollArt(app.ch, 20, 12)?.join('\n'));
109
+ }
85
110
  if (iconsOn()) {
86
111
  // A big window: the Gear tab is the character sheet and the Talents tab the three trees, both icon grids
87
112
  // the arrows walk in two dimensions. At the usual 110x34 both list instead (the checks above).
@@ -90,7 +115,8 @@ if (iconsOn()) {
90
115
  const slot = () => app.rowsOf(app.top())[app.top().cursor]?.cell?.slot;
91
116
  const rowIndexOfSlot = (s) => app.rowsOf(app.top()).findIndex((r) => r.cell?.slot === s);
92
117
  app.switchTab(3);
93
- check('a big window draws the Gear tab as the sheet', !!app.top().gridNav && /Equipment/.test(frame()) && /Head/.test(frame()));
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()));
94
120
  app.top().cursor = 0; app.render();
95
121
  term.press('shift-right');
96
122
  const across = slot();
@@ -114,15 +140,16 @@ if (iconsOn()) {
114
140
  const next = app.rowsOf(app.top())[app.top().cursor].node;
115
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));
116
142
  term.cols = cols; term.rows = rows;
117
- app.switchTab(lastTab);
143
+ app.switchTab(3); term.press('s');
118
144
  // A tooltip's long sentence wraps beside its icon while they share rows, not back under the art.
119
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);
120
146
  check('tooltip text wraps beside its icon', tip.slice(0, 4).every((l) => l.startsWith('\x1b[38;2;')) && tip.length > 4);
121
147
  }
122
148
  {
123
149
  const charRows = app.rowsOf(app.top()).map((r) => strip(r.text)).join('|');
124
- check('the character tab lists weapon skills with Defense and the hand in use', /Weapon skills\|\s+Defense\s+\d+\/\d+\|/.test(charRows) && /\d+\/\d+( \+\d+)? +main hand/.test(charRows));
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));
125
151
  }
152
+ home(); app.switchTab(lastTab);
126
153
  term.press('right'); check('Right wraps to the first tab', app.tab === 0);
127
154
  term.press('left'); check('Left wraps to the last tab', app.tab === lastTab);
128
155
  term.press('escape'); check('Esc on a tab returns to World', app.tab === 0);
@@ -320,6 +347,7 @@ if (app.auto) {
320
347
  show('autopilot between fights');
321
348
  app.fightPane = 'loot'; app.render();
322
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())));
323
351
  term.press('tab'); check('Tab between autopilot fights cycles the pane, not the tabs', app.fightPane === 'run' && !app.auto.browsing);
324
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))));
325
353
  show('autopilot run pane');
@@ -331,7 +359,8 @@ if (app.auto) {
331
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)));
332
360
  show('autopilot quests pane');
333
361
  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())));
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())));
335
364
  const bagCursor = app.bagsPane.cursor; term.press('down');
336
365
  check('Down moves the Bags pane cursor', app.bagsPane.cursor > bagCursor && !!app.auto);
337
366
  const logBefore = app.log.length; term.press('return');
@@ -406,6 +435,13 @@ if (doRow >= 0) {
406
435
  // Rotation editing through the menu: toggle a rule, add one, give it a condition, delete it.
407
436
  home(); term.press('6');
408
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');
409
445
  const ruleRow = app.rowsOf(app.top()).findIndex((r) => r.data?.rule);
410
446
  const ruleData = app.rowsOf(app.top())[ruleRow].data;
411
447
  app.top().cursor = ruleRow; term.press('return');
@@ -655,7 +691,18 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
655
691
  if (hire >= 0) {
656
692
  app.top().cursor = hire; term.press('return'); show('hire list');
657
693
  check('the hire list opened', app.top().id === 'hire' && rowIndex(() => true) >= 0);
658
- check('hire rows name their trades', /(Mining|Herb|Skin|Tailor|Smith|Engi|Alchemy|Enchant|Leather) \d+/.test(strip(app.rowsOf(app.top()).find((r) => !r.header)?.text ?? '')));
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');
659
706
  term.press('return'); show('candidate page');
660
707
  check('Enter opens the candidate page', app.top().id.startsWith('cand-'));
661
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);
@@ -743,6 +790,16 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
743
790
  }
744
791
  const squad = g.state.squad ?? [];
745
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');
746
803
  home(); app.switchTab(tabOf('companions'));
747
804
  term.press('m'); show('squad mode picker');
748
805
  check('m on Companions opens the squad mode picker', app.top().id === 'pick' && /How do your companions fight/.test(app.top().title));
@@ -856,6 +913,15 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
856
913
  }
857
914
  check('a cast shows as a bar under the caster', !cast || strip(frame()).includes(cast.casting.name.slice(0, 8)));
858
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); }
859
925
  }
860
926
  finishFight();
861
927
  check(`stop ${n} is cleared and the run window comes back`, g.state.instance.defeated.length === n && app.top()?.id === 'dungeon');
@@ -927,6 +993,16 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
927
993
  app.top().cursor = rowIndex((r, t) => /^Raid group/.test(t)); term.press('return'); term.press('f');
928
994
  check('f fills the 20-man group', g.state.raidLineup?.size === 20 && g.state.raidLineup.ids.length > 0);
929
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();
930
1006
  g.state.char.zoneId = 'westfall';
931
1007
  }
932
1008
 
@@ -974,6 +1050,27 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
974
1050
  check('p adds a named companion rotation', g.companionRotationProfiles(comp().id).some((p) => p.name === 'Second' && p.active));
975
1051
  term.press('w'); term.press('return');
976
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
+ }
977
1074
  // Player action bars: bar 1 from the Spells tab, and the fight's number row reads it.
978
1075
  home(); term.press('5'); term.press('b'); show('player bars');
979
1076
  check('b on Spells opens your action bars', app.top().id === 'bars' && /Bar 1/.test(strip(frame())));
@@ -1032,7 +1129,21 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1032
1129
  g2.state.char.level = 12; g2.state.char.gold = 1_000_000;
1033
1130
  app2.render();
1034
1131
  const at = (pred) => app2.rowsOf(app2.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
1035
- app2.top().cursor = at((r) => r.data?.creature?.tameable && r.data.creature.level <= 12); term2.press('t');
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');
1036
1147
  check('t on a tameable beast tames it', !!g2.state.char.pet);
1037
1148
  app2.top().cursor = at((r, t) => /^Pet:/.test(t)); term2.press('return');
1038
1149
  if (showFrames) console.log(`\n=== pet page ===\n${term2.text()}`);
@@ -1057,13 +1168,56 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1057
1168
  check('Dun Morogh lists a pet trainer', trainer > 0);
1058
1169
  if (trainer > 0) {
1059
1170
  app2.top().cursor = trainer; term2.press('return');
1060
- 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));
1061
1173
  app2.top().cursor = rank; term2.press('return');
1062
1174
  check('a pet trainer rank can be bought', (g2.state.char.petSpells ?? []).length > 0);
1063
1175
  }
1064
1176
  term2.close?.();
1065
1177
  }
1066
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
+
1067
1221
  // Loot window: auto-loot off holds the corpse's drops and coin; Enter takes a row, a takes the rest, Esc leaves it.
1068
1222
  {
1069
1223
  home(); term.press('1');
@@ -1208,7 +1362,7 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1208
1362
  const slotRow = rowIndex((r) => r.data?.slot === worn);
1209
1363
  check('a worn-down slot shows its durability', / 1\/\d+/.test(strip(app.rowsOf(app.top())[slotRow].text)));
1210
1364
  const quote = g.repairQuote();
1211
- check('the Repair all row prices the bill', rowIndex((r, t) => t.startsWith('Repair all') && t.includes(moneyTextOf(quote.cost))) > 0);
1365
+ check('the Repair all row prices the bill', rowIndex((r, t) => t.startsWith('Repair all') && t.includes(strip(coinText(quote.cost)))) > 0);
1212
1366
  if (quote.vendorName) {
1213
1367
  app.top().cursor = slotRow; term.press('r');
1214
1368
  check('r on a slot repairs that piece', g.state.char.durability?.[worn] == null);
@@ -1372,15 +1526,15 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1372
1526
  home();
1373
1527
  }
1374
1528
 
1375
- // 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.
1376
1530
  {
1377
1531
  const g = app.g;
1378
1532
  const fort = ds.spell(1243);
1379
1533
  const give = () => { g.state.char.activeAuras = [...(g.state.char.activeAuras ?? []).filter((x) => x.spellId !== 1243), { spellId: 1243, expiresAt: Date.now() + 600000 }]; };
1380
1534
  give();
1381
- home(); app.switchTab(app.tabs.length - 1); show('character auras');
1535
+ home(); app.switchTab(3); term.press('s'); show('character auras');
1382
1536
  const at = rowIndex((r, t) => t.includes(fort.name));
1383
- check('the Character tab lists your auras', at > 0);
1537
+ check('the stats page lists your auras', at > 0);
1384
1538
  app.top().cursor = at; term.press('return');
1385
1539
  check('Enter on a buff cancels it', !(g.state.char.activeAuras ?? []).some((x) => x.spellId === 1243));
1386
1540
  give(); home(); term.press('1');
@@ -1512,6 +1666,21 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1512
1666
  home();
1513
1667
  }
1514
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
+
1515
1684
  // Real-time travel: with a 1x pace a trip takes time; the header counts down, the World tab carries the journey
1516
1685
  // card (speed, pause, arrive now, turn back), and the game loop lands it.
1517
1686
  {
@@ -2247,6 +2416,94 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
2247
2416
  term.press('?'); check('help overlay', app.top().id === 'help'); term.press('escape');
2248
2417
  check('help closed', app.stack.length === 0);
2249
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
+
2250
2507
  // Sixel art, last: once on it stays on for the process. The terminal says it draws sixel (DA1 parameter 4);
2251
2508
  // art then travels as placeholders whose markers the terminal layer takes out and places.
2252
2509
  {
@@ -2254,10 +2511,14 @@ check('help closed', app.stack.length === 0);
2254
2511
  const art = creatureArt(2332, 28, 14);
2255
2512
  const mark = art?.[0]?.match(/\x1b\[9999;(\d+);0m/);
2256
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] ?? '')));
2257
2516
  const placed = new Map();
2258
2517
  const rows = art.map((l, r) => takeMarks(`ab${l}${l}`, 5 + r, placed));
2259
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')));
2260
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])));
2261
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')));
2262
2523
  const icon = iconCells('spell_holy_holysmite', 6, '', [{ x: 4, y: 2, w: 2 }]);
2263
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 ?? ''));