azeroth-shell 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +362 -85
  2. package/bin/azsh.mjs +35 -19
  3. package/package.json +12 -1
  4. package/src/autopilot/plan.ts +8 -11
  5. package/src/autopilot/view.ts +12 -11
  6. package/src/cli/combatLogFormat.ts +22 -0
  7. package/src/cli/gzLoader.mjs +47 -6
  8. package/src/cli/hdArt.mjs +81 -0
  9. package/src/cli/iconArt.mjs +145 -0
  10. package/src/cli/iconGrid.mjs +100 -0
  11. package/src/cli/modelArt.mjs +298 -0
  12. package/src/cli/oocPrep.mjs +32 -0
  13. package/src/cli/paperdoll.mjs +76 -0
  14. package/src/cli/perfCheck.mjs +56 -0
  15. package/src/cli/repl.mjs +48 -7
  16. package/src/cli/rotationText.mjs +6 -6
  17. package/src/cli/sixel.mjs +319 -0
  18. package/src/cli/term.mjs +106 -12
  19. package/src/cli/text.mjs +21 -3
  20. package/src/cli/tui.mjs +262 -102
  21. package/src/cli/tuiAutopilot.mjs +356 -0
  22. package/src/cli/tuiBags.mjs +37 -4
  23. package/src/cli/tuiBg.mjs +370 -41
  24. package/src/cli/tuiCheck.mjs +633 -37
  25. package/src/cli/tuiCity.mjs +146 -41
  26. package/src/cli/tuiCompanionKit.mjs +6 -6
  27. package/src/cli/tuiCompanions.mjs +211 -41
  28. package/src/cli/tuiCreate.mjs +230 -0
  29. package/src/cli/tuiDungeon.mjs +57 -21
  30. package/src/cli/tuiFight.mjs +467 -88
  31. package/src/cli/tuiFightPanes.mjs +42 -14
  32. package/src/cli/tuiFloor.mjs +21 -9
  33. package/src/cli/tuiGear.mjs +17 -14
  34. package/src/cli/tuiLoot.mjs +6 -6
  35. package/src/cli/tuiMap.mjs +214 -0
  36. package/src/cli/tuiPaperdoll.mjs +108 -0
  37. package/src/cli/tuiPartyStage.mjs +206 -0
  38. package/src/cli/tuiPet.mjs +16 -8
  39. package/src/cli/tuiProfessions.mjs +27 -25
  40. package/src/cli/tuiRotationCond.mjs +390 -0
  41. package/src/cli/tuiSaves.mjs +24 -1
  42. package/src/cli/tuiSearch.mjs +79 -27
  43. package/src/cli/tuiSettings.mjs +3 -4
  44. package/src/cli/tuiStage.mjs +360 -0
  45. package/src/cli/tuiTalentTree.mjs +124 -0
  46. package/src/cli/tuiTalents.mjs +3 -4
  47. package/src/cli/tuiViews.mjs +633 -172
  48. package/src/cli/tuiWiki.mjs +2 -3
  49. package/src/cli/tuiWorld.mjs +90 -7
  50. package/src/data/gear-art.bin.br +0 -0
  51. package/src/data/hd-art.json +114 -0
  52. package/src/data/icon-art.bin.gz +0 -0
  53. package/src/data/model-art-worn.bin.br +0 -0
  54. package/src/data/model-art.bin.gz +0 -0
  55. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  56. package/src/data/tables/creatures.json.gz +0 -0
  57. package/src/data/world-map-sixel.bin.br +0 -0
  58. package/src/data/world-map.bin.gz +0 -0
  59. package/src/domain/bg/match.ts +85 -24
  60. package/src/domain/bg/strategies.ts +144 -29
  61. package/src/domain/bg/types.ts +2 -0
  62. package/src/domain/bg/wsg.ts +35 -20
  63. package/src/domain/character/character.ts +6 -0
  64. package/src/domain/pet/pet.ts +31 -0
  65. package/src/domain/quests/doQuest.ts +24 -6
  66. package/src/domain/rotation/build.ts +3031 -3031
  67. package/src/domain/rotation/classes/paladin.ts +16 -11
  68. package/src/domain/rotation/evaluate.ts +3 -0
  69. package/src/engine/combat/applySpell.ts +20 -9
  70. package/src/engine/combat/applySpellLate.ts +16 -12
  71. package/src/engine/combat/areaAura.ts +284 -283
  72. package/src/engine/combat/fighterFactory.ts +1 -1
  73. package/src/engine/combat/pickAction.ts +3494 -3449
  74. package/src/engine/combat/procsDamage.ts +31 -1
  75. package/src/engine/combat/sessionChase.ts +19 -2
  76. package/src/engine/combat/sessionMotionKit.ts +40 -2
  77. package/src/engine/game/loadReconcile.ts +21 -1
  78. package/src/engine/game/pets.ts +33 -1
  79. package/src/engine/game/types.ts +4 -0
  80. package/src/quests/quests.ts +60 -0
@@ -0,0 +1,230 @@
1
+ // The character create screen: faction, race, class, gender, name and the five looks of the body on one form, the
2
+ // hero drawn in the starting gear beside it, and under the form a card of what they start with (the race
3
+ // and the class in a sentence, racials, pools and attributes at level 1, spells, gear). Left and Right
4
+ // change the row under the cursor; Enter on Race or Class opens a list with each choice drawn; Enter on
5
+ // Name types one; Enter on Create rolls the character. Esc goes back to the character list.
6
+ import { bold, dim, green, red, wrapLines, fit } from './text.mjs';
7
+ import { className, raceName } from '../domain/core/ids.ts';
8
+ import { newGameState, newGameStateFromWorldBot, pickRandomWorldBot } from '../engine/game.ts';
9
+ import { maxHealth, maxPower, knownUsableSpells } from '../domain/character/character.ts';
10
+ import { effectiveStats, powerType } from '../domain/character/characterStats.ts';
11
+ import { bodyOptions, clampAppearance, cycleAppearance, lowestSelectable, slotCount, APPEARANCE_SLOTS } from '../domain/character/appearance.ts';
12
+ import { equippedEntry } from '../domain/items/itemInstance.ts';
13
+ import { nativeDisplayId } from '../domain/character/displayForm.ts';
14
+ import { creatureArt } from './modelArt.mjs';
15
+ import { paperdollArt } from './paperdoll.mjs';
16
+ import { characterNamesEqual } from '../domain/character/characterName.ts';
17
+
18
+ // A sentence on each race and class, in the voice of the client's create screen; the racials, the start
19
+ // zone and everything else on the card come from the data.
20
+ const RACE_BLURB = {
21
+ 1: 'The people of Stormwind, adaptable and diplomatic: their spirit runs deeper, reputation comes sooner, and they see through stealth a little further.',
22
+ 2: 'The Horde of Thrall, hardy and fierce. Blood Fury hardens their attacks, stuns slide off them more often, their axes bite deeper, and their pets strike harder.',
23
+ 3: 'The stout folk of Ironforge. Stoneform shrugs off poison, disease and bleeding; they resist frost, shoot guns truer, and can find treasure in the earth.',
24
+ 4: 'The Kaldorei of Teldrassil, ancient and watchful. They melt into Shadowmeld, are harder to hit, resist nature, and walk as wisps when they fall.',
25
+ 5: 'The Forsaken of the Undercity, free of the Lich King. Will of the Forsaken breaks fear, charm and sleep; they eat the dead to heal, breathe underwater longer, and resist shadow.',
26
+ 6: 'The Shu\'halo of Mulgore, peaceful until roused. War Stomp stuns everything around them, they stand with more health, resist nature, and gather herbs faster.',
27
+ 7: 'The tinkers of Gnomeregan, guests of Ironforge. Escape Artist slips any snare, their minds hold more mana, they resist arcane, and they are born engineers.',
28
+ 8: 'The Darkspear, who followed Thrall from the Echo Isles. Berserking quickens blows and spells, they regenerate even in a fight, slay beasts harder, and throw and shoot bows truer.',
29
+ };
30
+ const CLASS_BLURB = {
31
+ 1: 'Steel and rage. Warriors wear the heaviest armour, hold the line behind a shield or swing a two-hander, and build rage by hitting and being hit.',
32
+ 2: 'The Light\'s champion in plate: a healer, a protector and a slow but sure fighter, with blessings for the party and a bubble for when it goes wrong.',
33
+ 3: 'A ranger with a bow or gun and a pet tamed from the wild. The pet holds the foe while the hunter shoots, and the map shows what they track.',
34
+ 4: 'A stealthy fighter who builds combo points with blades and spends them on finishing moves. Poisons, lockpicking and picking pockets come with the trade.',
35
+ 5: 'The great healer, in cloth. Holy mends and shields the party; Shadow drains and torments from Shadowform, trading the healing for the dark.',
36
+ 6: 'The Horde\'s keeper of the elements, in mail: totems, lightning and shocks, heals and weapon imbues, half melee and half spell at every turn.',
37
+ 7: 'The master of frost, fire and arcane, fragile in cloth and lethal at range. Conjures food and water, sheeps a foe, and opens portals between cities.',
38
+ 8: 'A wielder of fel magic in cloth: curses and damage over time, a demon summoned to tank or drain, and health, soul shards and healthstones traded for power.',
39
+ 9: 'The shapeshifter: a bear to tank, a cat to claw, healing and nature magic in caster form, and travel form for the road.',
40
+ };
41
+
42
+ const NAME_RE = /^[A-Za-z]{2,12}$/;
43
+ const properName = (s) => s[0].toUpperCase() + s.slice(1).toLowerCase();
44
+ const rand = (n) => Math.floor(Math.random() * n);
45
+ const newSeed = () => (Date.now() ^ (Math.random() * 0x7fffffff)) >>> 0;
46
+
47
+ /** A random look within the body's legal choices (the create screen's shuffle; bald and hornless allowed where offered). */
48
+ export function randomLook(race, gender) {
49
+ const look = {};
50
+ APPEARANCE_SLOTS.forEach((key, s) => {
51
+ const floor = lowestSelectable(race, gender, s), count = slotCount(race, gender, s);
52
+ look[key] = count > floor ? floor + rand(count - floor) : 0;
53
+ });
54
+ return clampAppearance(look, race, gender);
55
+ }
56
+
57
+ /** A name from the world bots' pool for the gender, fit for a character. */
58
+ export function randomName(ds, gender) {
59
+ const pool = ds.botNames()[gender === 1 ? 'female' : 'male'];
60
+ for (let i = 0; i < 50 && pool.length; i++) { const n = pool[rand(pool.length)]; if (NAME_RE.test(n)) return properName(n); }
61
+ return gender === 1 ? 'Aalina' : 'Aalart';
62
+ }
63
+
64
+ /** The screen. `done(state)` gets the new character's game state; `onCancel` is Esc (back to the character list). */
65
+ export function characterCreation(app, done, { onCancel } = {}) {
66
+ const ds = app.ds;
67
+ const races = ds.races();
68
+ const classesOf = (race) => race.classes.filter((c) => ds.createInfo(race.id, c));
69
+ const st = { race: races[0], cls: classesOf(races[0])[0], gender: 0, name: '', look: null };
70
+ st.look = randomLook(st.race.id, st.gender);
71
+ // The state a Create would make for a body and class, less the name and the look: its character carries the
72
+ // starting gear, pools and attributes the card and the drawing read. One per combination, kept.
73
+ const previews = new Map();
74
+ const previewOf = (race, cls, gender) => {
75
+ const key = `${race}-${cls}-${gender}`;
76
+ let p = previews.get(key);
77
+ if (!p) { p = newGameState(ds, race, cls, 'Preview', 1, gender); previews.set(key, p); }
78
+ return p;
79
+ };
80
+ const preview = () => previewOf(st.race.id, st.cls, st.gender);
81
+ const hero = (cls = st.cls) => ({ ...previewOf(st.race.id, cls, st.gender).char, name: st.name || 'Preview', appearance: st.look });
82
+ const setRace = (r) => { st.race = r; lastRace[r.faction] = r; const cs = classesOf(r); if (!cs.includes(st.cls)) st.cls = cs[0]; st.look = clampAppearance(st.look, r.id, st.gender); };
83
+ // Faction comes first: Race cycles and lists only its races, and flipping back returns to the race last chosen there.
84
+ const factions = [...new Set(races.map((r) => r.faction))];
85
+ const racesOf = (f) => races.filter((r) => r.faction === f);
86
+ const lastRace = { [st.race.faction]: st.race };
87
+ const setFaction = (f) => setRace(lastRace[f] ?? racesOf(f)[0]);
88
+ const setGender = (g) => { st.gender = g; st.look = clampAppearance(st.look, st.race.id, g); };
89
+ const cycle = (list, cur, dir) => list[(list.indexOf(cur) + dir + list.length) % list.length];
90
+ const genderName = () => (st.gender === 1 ? 'Female' : 'Male');
91
+ const factionName = (r) => (r.faction === 'horde' ? 'Horde' : 'Alliance');
92
+
93
+ const view = { id: 'create', title: 'New character', hint: 'Left Right: change Enter: pick or type' + (onCancel ? ' Esc: character list' : '') };
94
+ const rowAt = (id) => app.rowsOf(view).findIndex((r) => r.id === id);
95
+ const goTo = (id) => { const i = rowAt(id); if (i >= 0) { view.cursor = i; app.ensureVisible(view, app.rowsOf(view)); } app.render(); };
96
+
97
+ // The hero drawn for the pane: the body in its starting gear with the look, or the bare body when the gear pack lacks it.
98
+ const drawn = (cls, maxCols, maxRows) => paperdollArt(hero(cls), maxCols, maxRows) ?? creatureArt(nativeDisplayId(st.race.id, st.gender), maxCols, maxRows);
99
+ const withBody = (text, art) => (art ? [...text, '', ...art] : text);
100
+
101
+ // Mail to an alt finds them by name, so two of yours may not share one. Read once as the screen opens.
102
+ let taken = [];
103
+ void Promise.resolve(app.repo?.list?.()).then((l) => { taken = (l ?? []).map((s) => s.name ?? ''); }).catch(() => {});
104
+ const nameTaken = (name) => taken.some((n) => characterNamesEqual(n, name));
105
+ const askName = () => app.prompt('Name (2-12 letters): ', (raw) => {
106
+ const name = raw.trim();
107
+ if (!NAME_RE.test(name)) { app.say(red('Letters only, 2 to 12 of them.')); askName(); return; } // said in the log: the prompt covers the hint line
108
+ if (nameTaken(name)) { app.say(red(`${properName(name)} is already one of your characters.`)); askName(); return; }
109
+ st.name = properName(name);
110
+ goTo('create'); // named: Enter again creates
111
+ }); // the line starts empty: Esc keeps the name they had
112
+ const rollName = () => { st.name = randomName(ds, st.gender); app.render(); };
113
+ const shuffle = () => { st.look = randomLook(st.race.id, st.gender); app.render(); };
114
+ const create = () => {
115
+ if (!st.name) { app.flashMsg('Give them a name first.'); goTo('name'); return; }
116
+ if (nameTaken(st.name)) { app.flashMsg(`${st.name} is already one of your characters. Pick another name.`); goTo('name'); return; }
117
+ done(newGameState(ds, st.race.id, st.cls, st.name, newSeed(), st.gender, st.look));
118
+ };
119
+ const randomCharacter = () => {
120
+ const seed = newSeed();
121
+ done(newGameStateFromWorldBot(ds, pickRandomWorldBot(ds, seed), seed));
122
+ };
123
+
124
+ // Enter on Race: the faction's races listed with each body drawn; on Class: every class this race may be, drawn in its gear.
125
+ const racePicker = () => {
126
+ const list = racesOf(st.race.faction);
127
+ const p = app.pick('Race', list.map((r) => ({
128
+ label: `${fit(r.name, 10)} ${dim(factionName(r))}`,
129
+ detail: () => {
130
+ const L = app.layout();
131
+ const text = [bold(r.name), dim(factionName(r)), `Starts in ${ds.zone(r.startZone)?.name ?? r.startZone}`, dim('Classes: ') + classesOf(r).map(className).join(', ')];
132
+ return withBody(text, creatureArt(nativeDisplayId(r.id, st.gender), L.detailW - 2, L.mainH - text.length - 1));
133
+ },
134
+ value: r,
135
+ })), (r) => { setRace(r); app.render(); }, { hint: 'Enter: choose' });
136
+ p.cursor = list.indexOf(st.race);
137
+ };
138
+ const classPicker = () => {
139
+ const classes = classesOf(st.race);
140
+ const p = app.pick('Class', classes.map((c) => ({
141
+ label: className(c),
142
+ detail: () => {
143
+ const L = app.layout();
144
+ const text = [bold(className(c)), ...wrapLines(dim(CLASS_BLURB[c] ?? ''), L.detailW - 2)];
145
+ return withBody(text, drawn(c, L.detailW - 2, L.mainH - text.length - 1));
146
+ },
147
+ value: c,
148
+ })), (c) => { st.cls = c; app.render(); }, { hint: 'Enter: choose' });
149
+ p.cursor = classes.indexOf(st.cls);
150
+ };
151
+
152
+ // A form row: the label, the value, and the arrows when Left and Right change it.
153
+ const field = (id, label, value, { cycle: cyc, act, arrows = true }) => ({ id, text: `${fit(label, 12)} ${fit(bold(value), 10)}${arrows ? ` ${dim('◂ ▸')}` : ''}`, cycle: cyc, act });
154
+ const H = (text = '', plain = false) => ({ text, header: true, plain });
155
+
156
+ // Under the form: what the race and the class are, and what a level 1 of them starts with.
157
+ const card = () => {
158
+ const L = app.layout();
159
+ const w = Math.max(24, (L.detailW ? L.listW : L.W) - 3);
160
+ const wrap = (t) => wrapLines(t, w).map((l) => H(l, true));
161
+ const r = st.race, p = preview(), ch = p.char;
162
+ const out = [H(`${r.name} · ${factionName(r)}`)];
163
+ if (RACE_BLURB[r.id]) out.push(...wrap(RACE_BLURB[r.id]));
164
+ out.push(...wrap(dim(`Starts in ${ds.zone(r.startZone)?.name ?? r.startZone}; home city ${r.city}.`)));
165
+ const racials = [...new Set(r.racials.map((id) => ds.spell(id)?.name).filter(Boolean))];
166
+ if (racials.length) out.push(...wrap(`${dim('Racials:')} ${racials.join(', ')}`));
167
+ out.push(H(), H(className(st.cls)));
168
+ if (CLASS_BLURB[st.cls]) out.push(...wrap(CLASS_BLURB[st.cls]));
169
+ const s = effectiveStats(ch, ds), pt = powerType(ch);
170
+ out.push(...wrap(`${dim('Level 1:')} ${maxHealth(ch, ds)} health, ${maxPower(ch, ds)} ${pt}`));
171
+ out.push(...wrap(`${dim('Str')} ${s.str} ${dim('Agi')} ${s.agi} ${dim('Sta')} ${s.sta} ${dim('Int')} ${s.int} ${dim('Spi')} ${s.spi}`));
172
+ const spells = [...new Set(knownUsableSpells(ch, p.spellbook, ds).map((sp) => sp.name))];
173
+ if (spells.length) out.push(...wrap(`${dim('Knows:')} ${spells.join(', ')}`));
174
+ const worn = new Set(Object.values(ch.equipped ?? {}).map(equippedEntry));
175
+ const wears = [], carries = [];
176
+ for (const it of ds.createInfo(r.id, st.cls)?.items ?? []) {
177
+ const def = ds.item(it.item);
178
+ if (def) (worn.has(it.item) ? wears : carries).push(it.amount > 1 ? `${def.name} ×${it.amount}` : def.name);
179
+ }
180
+ if (wears.length) out.push(...wrap(`${dim('Wears:')} ${wears.join(', ')}`));
181
+ if (carries.length) out.push(...wrap(`${dim('Carries:')} ${carries.join(', ')}`));
182
+ return out;
183
+ };
184
+
185
+ view.rows = () => {
186
+ const rows = [
187
+ field('faction', 'Faction', factionName(st.race), { cycle: (d) => setFaction(cycle(factions, st.race.faction, d)), act: () => { setFaction(cycle(factions, st.race.faction, 1)); app.render(); } }),
188
+ field('race', 'Race', st.race.name, { cycle: (d) => setRace(cycle(racesOf(st.race.faction), st.race, d)), act: racePicker }),
189
+ field('class', 'Class', className(st.cls), { cycle: (d) => { st.cls = cycle(classesOf(st.race), st.cls, d); }, act: classPicker }),
190
+ field('gender', 'Gender', genderName(), { cycle: () => setGender(st.gender === 1 ? 0 : 1), act: () => { setGender(st.gender === 1 ? 0 : 1); app.render(); } }),
191
+ { id: 'name', text: `${fit('Name', 12)} ${st.name ? bold(st.name) : dim('Enter: type one n: a random one')}`, act: askName },
192
+ H(), H('Look'),
193
+ ];
194
+ // The body's own slots, by the names the client gives them (Horn Style, Tusks…); a slot with one choice is left out.
195
+ const body = bodyOptions(st.race.id, st.gender);
196
+ APPEARANCE_SLOTS.forEach((key, s) => {
197
+ const opt = body?.options[s];
198
+ if (!opt) return;
199
+ const floor = lowestSelectable(st.race.id, st.gender, s), count = opt.count;
200
+ if (count - floor <= 1) return;
201
+ const i = st.look[key];
202
+ const value = i === 0 && opt.none ? 'None' : `${i + 1 - floor} of ${count - floor}`;
203
+ rows.push(field(key, opt.name, value, { cycle: (d) => { st.look = cycleAppearance(st.look, st.race.id, st.gender, s, d); }, act: () => { st.look = cycleAppearance(st.look, st.race.id, st.gender, s, 1); app.render(); } }));
204
+ });
205
+ rows.push({ id: 'shuffle', text: `${fit('', 12)} ${dim('r: a random look')}`, act: shuffle });
206
+ rows.push(H());
207
+ rows.push({ id: 'create', text: green(bold(st.name ? `Create ${st.name}, ${genderName()} ${st.race.name} ${className(st.cls)}` : 'Create (needs a name)')), act: create });
208
+ rows.push({ id: 'random', text: `Random character ${dim('a world bot at its own level, geared and named')}`, act: randomCharacter });
209
+ rows.push(H(), ...card());
210
+ return rows;
211
+ };
212
+ // The pane: the hero as they will stand on the Character tab, in the starting gear, as large as the pane allows.
213
+ view.detail = () => {
214
+ const L = app.layout();
215
+ const head = [`${st.name ? bold(st.name) : dim('Unnamed')} ${dim(`${genderName()} ${raceName(st.race.id)} ${className(st.cls)}`)}`, ''];
216
+ const art = drawn(st.cls, L.detailW - 2, L.mainH - head.length);
217
+ return art ? [...head, ...art] : head;
218
+ };
219
+ view.keys = {
220
+ n: { hint: 'random name', fn: rollName },
221
+ r: { hint: 'random look', fn: shuffle },
222
+ };
223
+ view.onKey = (a, k, row) => {
224
+ if ((k === 'left' || k === 'right') && row?.cycle) { row.cycle(k === 'left' ? -1 : 1); a.render(); return true; }
225
+ if (k === 'escape') { onCancel?.(); return true; }
226
+ return false;
227
+ };
228
+ app.push(view);
229
+ return view;
230
+ }
@@ -3,14 +3,14 @@
3
3
  // stops, gates, chests and NPCs; Enter on an unlocked stop fights it live with the squad. Ports phonecraft
4
4
  // instanceView.ts (the entry card), liveDungeonHeadless.ts (pulls, gates, rest, Finish instantly) and the
5
5
  // floor's object clicks (liveObjectLoot.ts) onto the sequential route.
6
- import { bold, dim, cyan, yellow, green, red, gray, qualityPaint, fit, plural, secondsText, wrapLines, itemText } from './text.mjs';
6
+ import { bold, dim, cyan, yellow, green, red, gray, qualityPaint, fit, plural, secondsText, wrapLines, itemText, coinText } from './text.mjs';
7
7
  import { openFloor } from './tuiFloor.mjs';
8
8
  import { startFight } from './tuiFight.mjs';
9
9
  import { noteLoot } from './tuiFightPanes.mjs';
10
10
  import { prePull } from './squad.mjs';
11
+ import { prepOoc, prepText } from './oocPrep.mjs';
11
12
  import { questDialog } from './tuiViews.mjs';
12
13
  import { nextDoQuestStep } from '../domain/quests/doQuest.ts';
13
- import { moneyText } from '../domain/economy/money.ts';
14
14
  import { maxHealth } from '../domain/character/character.ts';
15
15
  import { bagCapacity } from '../domain/items/equipment.ts';
16
16
  import { companionLevel, companionCharacter } from '../domain/bots/squad.ts';
@@ -27,10 +27,12 @@ import { isSkippableBeginFailure } from '../engine/game/types.ts';
27
27
  import {
28
28
  instancePortals, routeEncounterList, routeEncounterIdList, encounterOf, isCleared, isUnlocked, nextEncounter,
29
29
  isComplete, requiredFlags, hasFlag, routeBossProgress, applyEncounterCleared, autoAdvanceRouteGates,
30
- itemDropsInsideInstance, lockRemainingMs, restoreSavedRun,
30
+ itemDropsInsideInstance, lockRemainingMs, restoreSavedRun, canEnter,
31
31
  } from '../world/instances.ts';
32
32
  import { instanceObjectPins, lootObjectPins, objectGatesFor } from '../world/instanceObjects.ts';
33
33
  import { dungeonNpcsForMap } from '../data/dungeonSpawns.ts';
34
+ import { extraEntrancesForInstance } from '../data/dungeons/pack-from-clusters.ts';
35
+ import { buildEntryLockInputs } from '../domain/run/canEnterContext.ts';
34
36
  import { getDungeonGameObject } from '../data/dungeonGameObjects.ts';
35
37
  import { countRouteTrash, selectRouteEncounters, DEFAULT_ROUTE_DENSITY, DEFAULT_PULL_FUSION } from '../world/dungeonRoute.ts';
36
38
  import { WIPE_RETRY_PER_PULL, WIPE_CAP_PER_RUN } from '../world/dungeonPreset.ts';
@@ -53,6 +55,15 @@ function packSize(enc) {
53
55
 
54
56
  // ---- portals and entry ------------------------------------------------------------------------------------
55
57
 
58
+ /** The engine's own door check (level, attunement, key, quest chain, lockout), so the row says no before Enter does. Ref worldView.ts:626. */
59
+ function entryCheck(app, def, now = Date.now()) {
60
+ const g = app.g;
61
+ return canEnter(def, app.ch.level, g.state.lockouts ?? {}, now, { teleports: app.ds.areatriggerTeleports(), instanceSaves: g.state.instanceSaves, ...buildEntryLockInputs(g.state, def.name) });
62
+ }
63
+
64
+ /** The other doors into this instance (Maraudon's orange side, Gnomeregan's train depot, Uldaman's back door...). */
65
+ const extraDoors = (def) => extraEntrancesForInstance(def.id).map((row) => ({ id: row.id, name: String(row.name ?? '').replace(/\s*-\s*Entering.*$/i, '').trim() || def.name }));
66
+
56
67
  function lockText(app, def, now = Date.now()) {
57
68
  const g = app.g;
58
69
  const held = restoreSavedRun(g.state.instanceSaves, def.id, now);
@@ -78,10 +89,11 @@ export function portalRows(app) {
78
89
  if (!portals.length) return rows;
79
90
  rows.push(H(''), H(`Dungeons (${portals.length})`));
80
91
  for (const def of portals) {
81
- const low = ch.level < def.minLevel;
92
+ const chk = entryCheck(app, def);
93
+ const low = !chk.ok && !restoreSavedRun(g.state.instanceSaves, def.id, Date.now());
82
94
  const lock = lockText(app, def);
83
95
  rows.push({
84
- text: `${low ? dim(def.name) : cyan(def.name)} ${dim(`L${def.levelRange[0]}-${def.levelRange[1]}${isRaid(def) ? ` ${def.maxPlayers}-man raid` : ''}`)}${low ? red(` requires ${def.minLevel}`) : ''}${lock ? ` ${lock}` : ''}`,
96
+ text: `${low ? dim(def.name) : cyan(def.name)} ${dim(`L${def.levelRange[0]}-${def.levelRange[1]}${isRaid(def) ? ` ${def.maxPlayers}-man raid` : ''}`)}${low && !lock ? red(` ${chk.reason ?? `requires ${def.minLevel}`}`) : ''}${lock ? ` ${lock}` : ''}`,
85
97
  data: { dungeon: def },
86
98
  detail: () => portalLines(app, def),
87
99
  act: () => app.push(entryDialog(app, def)),
@@ -95,6 +107,8 @@ function portalLines(app, def) {
95
107
  const out = [bold(def.name), dim(`levels ${def.levelRange[0]}-${def.levelRange[1]}, requires ${def.minLevel}${isRaid(def) ? `, ${def.maxPlayers} players` : ''}`)];
96
108
  const lock = lockText(app, def);
97
109
  if (lock) out.push(lock);
110
+ const chk = entryCheck(app, def);
111
+ if (!chk.ok && !lock) out.push(red(chk.reason ?? 'You cannot enter yet.'));
98
112
  out.push('', cyan(`Bosses (${bosses.length})`), ...bosses.slice(0, 14).map((b) => ` ${b.name}`), ...(bosses.length > 14 ? [dim(` and ${bosses.length - 14} more`)] : []));
99
113
  out.push('', dim('Enter: route, party and supplies, then go in'));
100
114
  return out;
@@ -102,7 +116,8 @@ function portalLines(app, def) {
102
116
 
103
117
  /** The card before the door: route density, party or raid, food and water, Enter. */
104
118
  export function entryDialog(app, def) {
105
- const opts = { routeDensity: DEFAULT_ROUTE_DENSITY, asRaid: isRaid(def) };
119
+ const opts = { routeDensity: DEFAULT_ROUTE_DENSITY, asRaid: isRaid(def), door: null };
120
+ const doors = extraDoors(def);
106
121
  return {
107
122
  id: `enter-${def.id}`, title: def.name, hint: 'Enter: choose',
108
123
  rows(a) {
@@ -114,6 +129,7 @@ export function entryDialog(app, def) {
114
129
  rows.push({ text: green(held ? 'Go back in' : 'Enter the dungeon'), detail: () => [bold('Enter'), '', `${opts.routeDensity === 'full' ? 'Every pack on the way' : 'Bosses and what guards them'}; ${opts.asRaid ? 'as a raid, the bench comes too' : `you and ${plural(squad.length, 'companion')}`}.`], act: () => enter(a, def, opts) });
115
130
  const pace = (d) => ({ text: `Route: ${cyan(d === 'full' ? 'Full' : 'Bosses')}`, detail: () => [bold('Route density'), '', 'Bosses: the bosses and the packs that stand in their way.', 'Full: every pack in the dungeon.', '', dim('Enter: switch')], act: () => { opts.routeDensity = opts.routeDensity === 'full' ? 'bosses' : 'full'; } });
116
131
  rows.push(pace(opts.routeDensity));
132
+ if (doors.length) { const all = [null, ...doors]; rows.push({ text: `Door: ${cyan(opts.door?.name ?? 'Main entrance')}`, detail: () => [bold('Which door'), '', 'Some instances have more than one way in; each starts you at its own end of the route.', ...doors.map((d) => ` ${d.name}`), '', dim('Enter: next door')], act: () => { opts.door = all[(all.indexOf(opts.door) + 1) % all.length]; } }); }
117
133
  rows.push({ text: `Group: ${cyan(opts.asRaid ? 'Raid' : 'Party')}`, detail: () => [bold('Party or raid'), '', 'Party: you and the up to four companions you have seated.', `Raid: the bench comes too, up to ${(def.maxPlayers ?? 5) - 1} companions.`, '', dim('Enter: switch')], act: () => { opts.asRaid = !opts.asRaid; } });
118
134
  const walk = walkingIn(g, def, opts.asRaid);
119
135
  rows.push({ text: `Who walks in: ${cyan(`${walk.ids.length} of ${walk.cap}`)}${walk.picked ? '' : dim(' (auto)')}`, detail: () => [bold('Who walks in'), '', 'Pick from every companion you own, or Fill to match what this instance wants.', '', dim('Enter: pick')], act: () => a.push(rosterDialog(a, def, () => opts.asRaid)) });
@@ -131,7 +147,7 @@ export function entryDialog(app, def) {
131
147
  }
132
148
 
133
149
  function enter(app, def, opts) {
134
- const r = app.g.enterInstance(def.id, Date.now(), { routeDensity: opts.routeDensity, asRaid: opts.asRaid });
150
+ const r = app.g.enterInstance(def.id, Date.now(), { routeDensity: opts.routeDensity, asRaid: opts.asRaid, ...(opts.door ? { entranceTriggerId: opts.door.id } : {}) });
135
151
  if (!app.result(r, `You enter ${def.name}.`)) return;
136
152
  app.dungeonPulls = [];
137
153
  app.stack = [];
@@ -141,17 +157,17 @@ function enter(app, def, opts) {
141
157
  // Food and water before the door: who is short, the vendor ladder, what it costs (runSupplyPanel.ts).
142
158
  function supplyRows(app) {
143
159
  const plan = app.g.runSupplyPlan();
144
- const rows = [H(''), H(`Food and water ${dim(plan.mage ? '(a mage conjures both on the way)' : plan.rows.length ? `${moneyText(plan.cost)} for everyone` : 'everyone has enough')}`)];
160
+ const rows = [H(''), H(`Food and water ${dim(plan.mage ? '(a mage conjures both on the way)' : plan.rows.length ? `${coinText(plan.cost)} for everyone` : 'everyone has enough')}`)];
145
161
  if (plan.mage || !plan.rows.length) return rows;
146
162
  for (const r of plan.rows) {
147
163
  const rung = r.ladder[r.rung];
148
- rows.push(H(` ${fit(r.memberName, 12)} ${fit(r.kind, 6)} ${dim(`has ${r.have}, short ${r.short}`)} ${itemText(rung?.item, '?')} x${r.units} ${yellow(moneyText(r.cost))}`));
164
+ rows.push(H(` ${fit(r.memberName, 12)} ${fit(r.kind, 6)} ${dim(`has ${r.have}, short ${r.short}`)} ${itemText(rung?.item, '?')} x${r.units} ${coinText(r.cost)}`));
149
165
  }
150
166
  const buys = supplyBuys(plan.rows, app.ch.gold, true);
151
167
  rows.push({
152
- text: buys.length ? `Buy them ${yellow(`(${moneyText(buys.reduce((s, b) => s + b.cost, 0))})`)}` : dim('Buy them (not enough gold)'),
168
+ text: buys.length ? `Buy them ${yellow(`(${coinText(buys.reduce((s, b) => s + b.cost, 0))})`)}` : dim('Buy them (not enough gold)'),
153
169
  detail: () => [bold('Buy supplies'), '', 'The vendor price, straight into each member\'s own bags. What does not fit is left out.', ...(plan.full.length ? ['', yellow(`Bags full: ${plan.full.map((f) => f.name).join(', ')}`)] : [])],
154
- act: () => { if (!buys.length) { app.flashMsg('Not enough gold for any of it.'); return; } const r = app.g.buyRunSupplies(buys); app.result(r, r.ok ? `Bought ${r.units} food and water for ${moneyText(r.spent)}.` : ''); },
170
+ act: () => { if (!buys.length) { app.flashMsg('Not enough gold for any of it.'); return; } const r = app.g.buyRunSupplies(buys); app.result(r, r.ok ? `Bought ${r.units} food and water for ${coinText(r.spent)}.` : ''); },
155
171
  });
156
172
  return rows;
157
173
  }
@@ -280,7 +296,7 @@ function openChest(app, pin) {
280
296
  app.say(`You open ${pin.name}.${r.loot.length ? '' : ' It is empty.'}`);
281
297
  if (took.ok) noteLoot(app, took.taken, took.copper);
282
298
  for (const d of took.ok ? took.taken : []) app.say(` ${yellow('You receive')} ${app.itemLabel(d.item)}${d.count > 1 ? ` x${d.count}` : ''}`);
283
- if (took.ok && took.copper) app.say(` ${yellow('You receive')} ${moneyText(took.copper)}`);
299
+ if (took.ok && took.copper) app.say(` ${yellow('You receive')} ${coinText(took.copper)}`);
284
300
  if (took.ok && took.remaining) app.say(red(` ${plural(took.remaining, 'item')} did not fit in your bags.`));
285
301
  return true;
286
302
  }
@@ -314,7 +330,7 @@ function objectRow(app, def, o) {
314
330
  const name = spawn.name ?? ds.creature(spawn.entry)?.name ?? `npc ${spawn.entry}`;
315
331
  const marks = `${quests.turnIn.length ? green(' ?') : ''}${quests.offer.length ? yellow(' !') : ''}${quests.escort.length ? cyan(' escort') : ''}`;
316
332
  return {
317
- text: `${dim('☺')} ${name}${marks}`,
333
+ text: `${dim('•')} ${name}${marks}`,
318
334
  detail: () => [bold(name), ...quests.offer.map((q) => `${yellow('!')} ${q.title}`), ...quests.turnIn.map((q) => `${green('?')} ${q.title}`), ...quests.escort.map((q) => `${cyan('escort')} ${q.title}`), '', dim('Enter: speak with them')],
319
335
  act: () => npcDialog(app, spawn.entry, name),
320
336
  };
@@ -362,7 +378,7 @@ export function runView(app) {
362
378
  F: { hint: 'finish instantly', fn: (a) => a.confirm('Run every remaining stop now, without watching?', () => finishInstantly(a)) },
363
379
  L: { hint: 'leave', fn: (a) => leaveRun(a) },
364
380
  P: { hint: 'blessings', fn: (a) => a.push(paladinDialog(a)) },
365
- R: { hint: 'reset', fn: (a) => a.confirm('Reset the run? Every stop comes back.', () => { const id = a.g.state.instance?.dungeonId; if (a.result(a.g.resetInstance(), 'The dungeon resets.')) { a.dungeonPulls = []; a.floor = null; if (a.floorKills && id) delete a.floorKills[id]; } }) },
381
+ R: { hint: 'reset', fn: (a) => a.confirm('Reset the run? Every stop comes back.', () => { if (a.result(a.g.resetInstance(), 'The dungeon resets.')) { a.dungeonPulls = []; a.floor = null; } }) },
366
382
  },
367
383
  rows(a) {
368
384
  const g = a.g, run = g.state.instance, def = runDef(a);
@@ -399,13 +415,15 @@ export function runView(app) {
399
415
 
400
416
  // ---- pulls ---------------------------------------------------------------------------------------------
401
417
 
402
- /** Pull one stop. `floor`: the continuous floor's pull (no sequential lock: you walked into it); `onDone(r)`
403
- * runs after the fight with its outcome (null when you ran). */
404
- export function pullStop(app, def, enc, { floor = false, onDone, onTick } = {}) {
418
+ /** Pull one stop. `floor`: the continuous floor's pull (no sequential lock: you walked into it); `ready`: buff
419
+ * and rest first, a pull you chose (a floor pack that saw you is already on you); `onDone(r)` runs after the
420
+ * fight with its outcome (null when you ran). */
421
+ export function pullStop(app, def, enc, { floor = false, ready = !floor, onDone, onTick } = {}) {
405
422
  const run = app.g.state.instance;
406
423
  if (!run) return;
407
424
  if (isCleared(run, enc.id)) { app.flashMsg(`${enc.name} is already cleared.`); return; }
408
425
  if (!floor && !isUnlocked(def, run, enc.id)) { app.flashMsg(`${enc.name} is locked.`); return; }
426
+ if (ready) readyUp(app);
409
427
  for (const l of prePull(app.g, app.ds, app.cmds.buffPace)) app.say(cyan(l));
410
428
  let playerMax = 0, session = null;
411
429
  const f = startFight(app, enc.creatureEntry, {
@@ -458,16 +476,34 @@ function recordPull(app, def, enc, r, playerMax, session, downtimeSec) {
458
476
  });
459
477
  }
460
478
 
461
- /** Between pulls: eat and drink from the bags to the squad strategy's rest line, then sit (betweenPullRest.ts). */
462
- export function restBetween(app, seconds) {
479
+ /** Eat and drink from the bags, then sit, up to the squad strategy's rest line (betweenPullRest.ts). */
480
+ function eatAndDrink(app) {
463
481
  const g = app.g;
464
482
  const r = betweenPullConsumeLoop({ char: g.state.char, inventory: g.state.inventory, ds: app.ds, strategy: g.state.squadStrategy, deathFlow: g.state.deathFlow });
465
483
  g.state.char = r.char; g.state.inventory = r.inventory;
484
+ app.dungeonDowntime = (app.dungeonDowntime ?? 0) + r.seconds;
485
+ return r;
486
+ }
487
+
488
+ /** Between pulls: eat and drink to the rest line, then sit. */
489
+ export function restBetween(app, seconds) {
490
+ const r = eatAndDrink(app);
466
491
  app.idleTick(seconds, true);
467
- app.dungeonDowntime = (app.dungeonDowntime ?? 0) + r.seconds + seconds;
492
+ app.dungeonDowntime += seconds;
468
493
  app.say(dim(`The party rests${r.seconds ? ` and eats (${Math.round(r.seconds)} s)` : ''}. HP ${app.hpText()} ${app.pwText()}`));
469
494
  }
470
495
 
496
+ /** Before a pull you chose: the buffs phase cast standing still, then eat and drink back to the rest line, as
497
+ * runFullDungeon rests between pulls. Pulling on the mana the armour, Intellect and conjures just took sent
498
+ * a level 20 mage in at 761 of 1966, and nothing between stops gave it back. */
499
+ export function readyUp(app) {
500
+ const buffed = prepText(prepOoc(app.g, app.ds));
501
+ if (buffed) app.say(cyan(buffed));
502
+ const r = eatAndDrink(app);
503
+ if (r.seconds > 0) app.say(dim(`The party rests before the pull (${Math.round(r.seconds)} s). HP ${app.hpText()} ${app.pwText()}`));
504
+ for (const m of r.messages) if (/^Out of /.test(m)) app.say(yellow(` ${m}.`));
505
+ }
506
+
471
507
  /** Open gates that need no item, and route flags the density skipped (liveDungeonHeadless.ts autoFireGates). */
472
508
  function autoFireGates(app) {
473
509
  const g = app.g, ds = app.ds, inst = g.state.instance;
@@ -576,7 +612,7 @@ export function summaryView(app, s) {
576
612
  const deaths = s.pulls.filter((p) => p.outcome === 'defeat').length;
577
613
  const rows = [H(`${bold(s.dungeonName)} ${tag}`), H('')];
578
614
  rows.push(H(` ${s.encountersCleared}/${s.encounterTotal} stops, bosses ${s.bossesDown}/${s.bossTotal}, ${plural(s.pulls.length, 'pull')}, ${plural(deaths, 'wipe')}`));
579
- rows.push(H(` ${secondsText(s.combatMs)} fighting, ${secondsText(s.downtimeSec * 1000)} resting +${s.totalXp} xp ${moneyText(s.totalGold) || '0c'}`));
615
+ rows.push(H(` ${secondsText(s.combatMs)} fighting, ${secondsText(s.downtimeSec * 1000)} resting +${s.totalXp} xp ${coinText(s.totalGold)}`));
580
616
  if (s.stopReason) rows.push(H(red(` Stopped: ${s.stopReason}`)));
581
617
  if (s.memberTotals?.length) {
582
618
  const secs = Math.max(1, s.combatMs / 1000);