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.
- package/README.md +43 -10
- package/bin/azsh.mjs +15 -1
- package/package.json +5 -2
- package/src/cli/hdArt.mjs +1 -1
- package/src/cli/iconArt.mjs +11 -1
- package/src/cli/modelArt.mjs +7 -4
- package/src/cli/oocPrep.mjs +32 -0
- package/src/cli/repl.mjs +16 -0
- package/src/cli/sixel.mjs +26 -14
- package/src/cli/text.mjs +16 -0
- package/src/cli/tui.mjs +33 -9
- package/src/cli/tuiAutopilot.mjs +17 -8
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiCheck.mjs +276 -15
- package/src/cli/tuiCity.mjs +31 -31
- package/src/cli/tuiCompanionKit.mjs +6 -6
- package/src/cli/tuiCompanions.mjs +189 -29
- package/src/cli/tuiCreate.mjs +7 -0
- package/src/cli/tuiDungeon.mjs +56 -20
- package/src/cli/tuiFight.mjs +63 -21
- package/src/cli/tuiFightPanes.mjs +15 -6
- package/src/cli/tuiFloor.mjs +20 -8
- package/src/cli/tuiGear.mjs +9 -10
- package/src/cli/tuiLoot.mjs +5 -6
- package/src/cli/tuiMap.mjs +214 -0
- package/src/cli/tuiPaperdoll.mjs +6 -5
- package/src/cli/tuiPartyStage.mjs +36 -7
- package/src/cli/tuiPet.mjs +16 -8
- package/src/cli/tuiProfessions.mjs +23 -23
- package/src/cli/tuiSaves.mjs +14 -0
- package/src/cli/tuiSearch.mjs +4 -5
- package/src/cli/tuiSettings.mjs +3 -4
- package/src/cli/tuiStage.mjs +72 -16
- package/src/cli/tuiTalents.mjs +3 -4
- package/src/cli/tuiViews.mjs +371 -84
- package/src/cli/tuiWiki.mjs +2 -3
- package/src/cli/tuiWorld.mjs +88 -5
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +36 -36
- package/src/data/model-art-worn.bin.br +0 -0
- package/src/data/model-art.bin.gz +0 -0
- package/src/data/tables/creatures.json.gz +0 -0
- package/src/data/world-map-sixel.bin.br +0 -0
- package/src/data/world-map.bin.gz +0 -0
- package/src/domain/character/character.ts +6 -0
- package/src/domain/pet/pet.ts +31 -0
- package/src/engine/combat/pickAction.ts +3494 -3487
- package/src/engine/combat/sessionChase.ts +12 -1
- package/src/engine/game/loadReconcile.ts +21 -1
- package/src/engine/game/pets.ts +33 -1
- package/src/engine/game/types.ts +4 -0
package/src/cli/tuiBags.mjs
CHANGED
|
@@ -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 (
|
|
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());
|
package/src/cli/tuiCheck.mjs
CHANGED
|
@@ -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 {
|
|
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('
|
|
79
|
-
check('the character tab
|
|
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
|
|
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(
|
|
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
|
|
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())) &&
|
|
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()).
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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 ?? ''));
|