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
@@ -1,14 +1,15 @@
1
1
  // The Companions tab: your party and bench, hiring at an inn, one page per companion (role, gear, bags,
2
2
  // professions) and their gathering trips, and the squad mode that decides who fights beside you. A trimmed
3
3
  // port of phonecraft's companionsPanel.ts, companionMissionsDesk.ts and squadModePicker.ts.
4
- import { bold, dim, cyan, yellow, green, red, qualityPaint, fit, plural, itemText, vis, wrapLines } from './text.mjs';
4
+ import { bold, dim, cyan, yellow, green, red, inverse, qualityPaint, fit, cut, plural, itemText, vis, wrapLines, coinText, coin } from './text.mjs';
5
+ import { miniIcon } from './iconArt.mjs';
6
+ import { paperdollGrid, paperdollPlan } from './tuiPaperdoll.mjs';
5
7
  import { itemLines } from './tuiViews.mjs';
6
8
  import { recipeBook, openSendGoods, companionsOf } from './tuiProfessions.mjs';
7
9
  import { SQUAD_MODES, SQUAD_MODE_LABEL, squadModeOf, setSquadMode } from './squad.mjs';
8
10
  import { settingsRows, petRows, questRows, teachRow, bagRows, bankRows, shareDialog, vaultDialog, hireSearch, tavernDuel } from './tuiCompanionKit.mjs';
9
11
  import { barsDialog, companionBarsOwner } from './tuiBars.mjs';
10
12
  import { enchantLines } from './tuiGear.mjs';
11
- import { moneyText } from '../domain/economy/money.ts';
12
13
  import { className, raceName } from '../domain/core/ids.ts';
13
14
  import { paperdollArt } from './paperdoll.mjs';
14
15
  import { bagCapacity, EQUIP_SLOTS, SLOT_LABEL, itemSlotTargets } from '../domain/items/equipment.ts';
@@ -16,10 +17,12 @@ import { equippedEntry } from '../domain/items/itemInstance.ts';
16
17
  import { companionCharacter, companionLevel, hireSkillMap, MAX_COMPANIONS, SQUAD_ROLES } from '../domain/bots/squad.ts';
17
18
  import { companionEquipOpts } from '../domain/professions/autoDisenchant.ts';
18
19
  import { companionSkillCap, companionTrainedRank, SECONDARY_SKILLS } from '../domain/bots/botProfessions.ts';
19
- import { displayGearScore, gearProfileLabel } from '../domain/bots/companionHireCost.ts';
20
+ import { displayGearScore, gearProfileLabel, hireCostCopper } from '../domain/bots/companionHireCost.ts';
21
+ import { GEAR_PROFILES } from '../domain/bots/botGear.ts';
22
+ import { searchWorldBots, innZoneSalt, pickProfile, WORLD_BOT_COUNT } from '../domain/bots/botPool.ts';
20
23
  import { maxHealth, maxPower } from '../domain/character/character.ts';
21
24
  import { effectiveStats, effectiveArmor, attackPower, powerType } from '../domain/character/characterStats.ts';
22
- import { critChanceDisplay, dodgeChanceDisplay } from '../domain/character/characterRatings.ts';
25
+ import { critChanceDisplay, dodgeChanceDisplay, hitChance } from '../domain/character/characterRatings.ts';
23
26
  import {
24
27
  canCompanionLearnProfession, countDoneMissions, hoursAffordable, isMissionSkill, missionEtaLabel, missionHoursLabel,
25
28
  missionProgress, missionVerb, energyResetsAtMs, MAX_COMPANION_ENERGY,
@@ -63,9 +66,15 @@ function profTags(lines) {
63
66
 
64
67
  function profLines(app, lines) {
65
68
  if (!lines.length) return [` ${dim('none')}`];
66
- return lines.map((l) => ` ${fit(app.ds.profession(l.line)?.name ?? l.line, 15)} ${fit(`${l.current}/${l.max}`, 8)} ${dim(l.rank ?? '')}`);
69
+ return lines.map((l) => { const prof = app.ds.profession(l.line); return paneLine(app, ` ${miniIcon(prof?.icon)}${fit(prof?.name ?? l.line, 15)} ${fit(`${l.current}/${l.max}`, 8)} ${dim(l.rank ?? '')}`); });
67
70
  }
68
71
 
72
+ /** A detail-pane line kept to one row: a mini icon (sixel) is blank cells the pane's wrap would squeeze. */
73
+ function paneLine(app, line) { const w = (app.layout?.().detailW ?? 0) - 2; return w > 0 && vis(line) > w ? cut(line, w) : line; }
74
+
75
+ /** What a piece is, for the line under its name: Plate 412 armor, Sword 41-77. */
76
+ function gearKind(it) { return it ? [it.typeName, it.armor ? `${it.armor} armor` : '', it.weapon ? `${it.weapon.dmgMin}-${it.weapon.dmgMax}` : ''].filter(Boolean).join(' ') : ''; }
77
+
69
78
  function statLines(app, char) {
70
79
  const ds = app.ds, st = effectiveStats(char, ds), pt = powerType(char);
71
80
  return [
@@ -92,7 +101,7 @@ function wornLines(app, gear) {
92
101
  const out = [];
93
102
  for (const slot of EQUIP_SLOTS) {
94
103
  const e = equippedEntry(gear?.[slot]);
95
- if (e != null) { const it = app.ds.item(e); out.push(` ${fit(SLOT_LABEL[slot] ?? slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? e)}`); }
104
+ if (e != null) { const it = app.ds.item(e); out.push(paneLine(app, ` ${miniIcon(it?.icon)}${fit(SLOT_LABEL[slot] ?? slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? e)}`)); }
96
105
  }
97
106
  return out.length ? out : [` ${dim('nothing')}`];
98
107
  }
@@ -140,7 +149,7 @@ export function companionsView(app) {
140
149
  rows(a) {
141
150
  const g = a.g, squad = g.state.squad ?? [], bench = Object.values(g.state.botRoster ?? {});
142
151
  const energy = g.companionPartyEnergy(Date.now());
143
- const rows = [H(`Party ${squad.length}/${MAX_COMPANIONS} ${dim(`trip energy ${energy}/${MAX_COMPANION_ENERGY}`)} ${yellow(moneyText(a.ch.gold) || '0c')}`), squadModeRow(a)];
152
+ const rows = [H(`Party ${squad.length}/${MAX_COMPANIONS} ${dim(`trip energy ${energy}/${MAX_COMPANION_ENERGY}`)} ${coinText(a.ch.gold)}`), squadModeRow(a)];
144
153
  const row = (c) => ({
145
154
  text: `${fit(c.name, 12)} ${fit(dim(`L${companionLevel(c, a.ch.level)} ${className(c.cls)}`), 12)} ${fit(roleText(c.role), 6)} ${c.mission ? yellow('away ') : ''}${profTags(compProfLines(a, c))}`,
146
155
  detail: () => withArt(a, c, [...compLines(a, c), '', dim('Enter: open their page')]), // them in their gear
@@ -152,8 +161,8 @@ export function companionsView(app) {
152
161
  const inn = g.innkeeperHere();
153
162
  rows.push(H(''), H('Hire'));
154
163
  if (inn) {
155
- const n = g.innCandidates().length;
156
- rows.push({ text: `Hire a companion at ${inn.name} ${dim(`(${plural(n, 'face')})`)}`, detail: () => [bold(inn.name), 'Innkeeper', '', `${plural(n, 'adventurer')} near your level looking for work. A hire joins at their own level in their own gear; a bench companion comes back free.`], act: () => a.push(hireDialog(a)) });
164
+ const n = hirePool(a).list.length;
165
+ rows.push({ text: `Hire a companion at ${inn.name} ${dim(`(${plural(n, 'face')})`)}`, detail: () => [bold(inn.name), 'Innkeeper', '', `${plural(n, 'adventurer')} of your faction looking for work, every level, sorted by role and kit. A hire joins at their own level in their own gear; a bench companion comes back free.`], act: () => a.push(hireDialog(a)) });
157
166
  } else rows.push(H(dim(' No inn here. Companions are hired from innkeepers (Goldshire, Razor Hill, every capital).')));
158
167
  if (squad.length || bench.length) {
159
168
  const ups = g.squadUpgradePlan().length;
@@ -196,7 +205,41 @@ function yieldText(app, rows) {
196
205
 
197
206
  // ---- hiring -------------------------------------------------------------------------------------------
198
207
 
199
- function findCand(app, botId) { return [...app.g.innCandidates(), ...(app.hireResults ?? [])].find((c) => c.bot.id === botId) ?? null; }
208
+ // Every same-faction face not already yours, from the bot pool alone: who they are, their spec, kit and
209
+ // price. Their gear is the dear part (a gear walk each, 6 s for 3000), so a face gets it when you look at
210
+ // them. The inn's own slate rolls its kits as a set, and hiring prices a slate face by that roll, so those
211
+ // faces come from the slate.
212
+ function hirePool(app) {
213
+ const g = app.g, ch = g.state.char;
214
+ const taken = [...(g.state.squad ?? []).map((c) => c.id), ...Object.keys(g.state.botRoster ?? {})];
215
+ const key = `${ch.zoneId}|${ch.level}|${ch.race}|${g.innkeeperHere()?.entry}|${taken.sort().join(',')}`;
216
+ if (app.hirePoolMemo?.g === g && app.hirePoolMemo.key === key) return app.hirePoolMemo;
217
+ const slate = new Map(g.innCandidates().map((c) => [c.bot.id, c]));
218
+ const salt = innZoneSalt(ch.zoneId, ch.level);
219
+ const bots = g.innkeeperHere() ? searchWorldBots({ ds: app.ds, playerRace: ch.race, playerLevel: ch.level, zoneId: ch.zoneId, excludeIds: taken, filters: { roles: SQUAD_ROLES, limit: WORLD_BOT_COUNT } }) : [];
220
+ const list = bots.map((bot) => slate.get(bot.id) ?? (() => {
221
+ const spec = app.ds.botSpecs(bot.cls)?.[bot.specIdx], gearProfile = pickProfile(bot.id, salt, ch.level);
222
+ return { bot, ...(spec ? { specName: spec.name, specRole: spec.role } : {}), gearProfile, hireCostCopper: hireCostCopper(bot.level, gearProfile) };
223
+ })());
224
+ app.hirePoolMemo = { g, key, list, full: new Map(), tags: new Map() };
225
+ return app.hirePoolMemo;
226
+ }
227
+
228
+ /** A pool face with their gear on: built once, through the engine's own search so it is the body a hire gets. */
229
+ function fullCand(app, c) {
230
+ if (c.gear) return c;
231
+ const memo = hirePool(app);
232
+ if (!memo.full.has(c.bot.id)) {
233
+ const b = c.bot;
234
+ memo.full.set(b.id, app.g.searchInnCandidates({ name: b.name, classes: [b.cls], minLevel: b.level, maxLevel: b.level, limit: WORLD_BOT_COUNT }).find((x) => x.bot.id === b.id) ?? null);
235
+ }
236
+ return memo.full.get(c.bot.id) ?? null;
237
+ }
238
+
239
+ function findCand(app, botId) {
240
+ const c = [...app.g.innCandidates(), ...(app.hireResults ?? [])].find((x) => x.bot.id === botId) ?? hirePool(app).list.find((x) => x.bot.id === botId);
241
+ return c ? fullCand(app, c) : null;
242
+ }
200
243
 
201
244
  // The body they would bring: same shell hireCandidate builds, on their own spec, for the stat preview.
202
245
  function candCharacter(app, c) {
@@ -206,15 +249,16 @@ function candCharacter(app, c) {
206
249
 
207
250
  function hireText(app, c, short = false) {
208
251
  const cost = c.hireCostCopper ?? 0;
209
- return (app.ch.gold >= cost ? yellow : red)((short ? shortMoney(cost) : moneyText(cost)) || 'free');
252
+ if (!cost) return yellow('free');
253
+ return (app.ch.gold >= cost ? yellow : red)(short ? shortMoney(cost) : coinText(cost));
210
254
  }
211
255
 
212
256
  // The two biggest coins only, for the list column: 1g 20s, 51s, 76c.
213
257
  function shortMoney(copper) {
214
258
  const g = Math.floor(copper / 10000), sv = Math.floor((copper % 10000) / 100), c = copper % 100;
215
- if (g) return `${g}g${sv ? ` ${sv}s` : ''}`;
216
- if (sv) return `${sv}s`;
217
- return c ? `${c}c` : '';
259
+ if (g) return `${g}${coin('g')}${sv ? ` ${sv}${coin('s')}` : ''}`;
260
+ if (sv) return `${sv}${coin('s')}`;
261
+ return `${c}${coin('c')}`;
218
262
  }
219
263
 
220
264
  function candLines(app, c) {
@@ -232,7 +276,7 @@ function candLines(app, c) {
232
276
  function hireAs(app, c, role) {
233
277
  const r = app.g.hireCandidate(c.bot.id, role);
234
278
  if (r.ok && app.hireResults) app.hireResults = app.hireResults.filter((x) => x.bot.id !== c.bot.id);
235
- return app.result(r, `${r.name ?? c.bot.name} joins you as ${role}${r.costCopper ? ` for ${moneyText(r.costCopper)}` : ''}${r.benched ? ', on the bench' : ''}.`);
279
+ return app.result(r, `${r.name ?? c.bot.name} joins you as ${role}${r.costCopper ? ` for ${coinText(r.costCopper)}` : ''}${r.benched ? ', on the bench' : ''}.`);
236
280
  }
237
281
 
238
282
  function pickRoleAndHire(app, c, after) {
@@ -240,31 +284,77 @@ function pickRoleAndHire(app, c, after) {
240
284
  app.pick(`${c.bot.name}: what role?`, roles.map((r) => ({ label: `${roleText(r)}${r === c.specRole ? dim(' (their spec)') : ''}`, value: r })), (role) => { if (hireAs(app, c, role)) after?.(); });
241
285
  }
242
286
 
287
+ // The hire list's two filters: a role tab (Left and Right) and a kit (t), each with its count under the other.
288
+ const HIRE_ROLES = [{ role: null, label: 'All' }, { role: 'dps', label: 'DPS' }, { role: 'healer', label: 'Heal' }, { role: 'tank', label: 'Tank' }];
289
+ const KIT_SHORT = { naked: 'Naked', shabby: 'Shabby', field: 'Field', twink: 'Twink', 'raid-mixed': 'Raid', 'raid-t2': 'Raid T2', 'raid-t3': 'Raid T3' };
290
+ const roleOfCand = (c) => c.specRole ?? 'dps';
291
+
243
292
  function hireDialog(app, results) {
244
- return {
245
- id: results ? 'hire-search' : 'hire', title: results ? 'Search' : 'Hire', hint: 'Enter: look closer',
293
+ // The kits this list has, cheapest first; t steps through them and back to any.
294
+ const kitsOf = (list) => [null, ...GEAR_PROFILES.filter((p) => list.some((c) => c.gearProfile === p))];
295
+ const view = {
296
+ id: results ? 'hire-search' : 'hire', hint: 'Enter: look closer Left Right: role',
297
+ roleTab: 0, kit: null, placed: false,
298
+ // The filter rows scroll off a long list; the breadcrumb keeps them in sight.
299
+ get title() { return [results ? 'Search' : 'Hire', HIRE_ROLES[this.roleTab].role && HIRE_ROLES[this.roleTab].label, this.kit && KIT_SHORT[this.kit]].filter(Boolean).join(' · '); },
246
300
  keys: {
247
301
  h: { hint: 'hire', fn: (a, row) => { const c = row?.data && findCand(a, row.data); if (c) pickRoleAndHire(a, c); } },
302
+ t: { hint: 'kit', fn: (a) => stepKit(a, +1) },
303
+ T: { hint: 'kit back', fn: (a) => stepKit(a, -1) },
248
304
  '/': { hint: 'search', fn: (a) => hireSearch(a, (found) => { a.hireResults = found; a.push(hireDialog(a, found)); }) },
249
305
  D: { hint: 'duel', fn: (a, row) => { if (row?.data) tavernDuel(a, row.data); } },
250
306
  },
307
+ onKey(a, k) {
308
+ const d = { right: +1, tab: +1, left: -1, 'shift-tab': -1 }[k];
309
+ if (!d) return false;
310
+ view.roleTab = (view.roleTab + d + HIRE_ROLES.length) % HIRE_ROLES.length;
311
+ view.placed = false;
312
+ a.render();
313
+ return true;
314
+ },
251
315
  rows(a) {
252
- const g = a.g, list = results ?? g.innCandidates();
316
+ const g = a.g, all = results ?? hirePool(a).list, memo = hirePool(a);
253
317
  const squad = g.state.squad ?? [];
254
- const rows = [H(`${plural(list.length, 'adventurer')} for hire ${squad.length >= MAX_COMPANIONS ? yellow('party full: a new hire goes to the bench') : dim(`party ${squad.length}/${MAX_COMPANIONS}`)} ${yellow(moneyText(a.ch.gold) || '0c')}`), H('')];
255
- if (!list.length) rows.push(H(dim(' Nobody is looking for work here right now.')));
318
+ const { role } = HIRE_ROLES[view.roleTab];
319
+ const ofRole = (c) => !role || roleOfCand(c) === role, ofKit = (c) => !view.kit || c.gearProfile === view.kit;
320
+ const list = all.filter((c) => ofRole(c) && ofKit(c));
321
+ const roleTabs = HIRE_ROLES.map((t, i) => { const label = ` ${t.label} ${all.filter((c) => (!t.role || roleOfCand(c) === t.role) && ofKit(c)).length} `; return i === view.roleTab ? inverse(bold(label)) : dim(label); }).join(' ');
322
+ const kitTabs = kitsOf(all).map((p) => { const label = p ? `${KIT_SHORT[p] ?? p} ${all.filter((c) => ofRole(c) && c.gearProfile === p).length}` : 'Any'; return p === view.kit ? inverse(bold(` ${label} `)) : dim(` ${label} `); });
323
+ // Seven kits and their counts outrun the list pane at 60: they wrap onto a second row.
324
+ const kitW = a.listWOf(view, a.layout()) - 2, kitLines = [`Kit`];
325
+ for (const t of [...kitTabs, dim(' t')]) { if (vis(kitLines.at(-1)) + vis(t) > kitW) kitLines.push(' '); kitLines[kitLines.length - 1] += t; }
326
+ const rows = [
327
+ H(`${plural(list.length, 'adventurer')} for hire ${squad.length >= MAX_COMPANIONS ? yellow('party full: a new hire goes to the bench') : dim(`party ${squad.length}/${MAX_COMPANIONS}`)} ${coinText(a.ch.gold)}`),
328
+ H(`${roleTabs} ${dim('Left Right')}`), ...kitLines.map(H), H(''),
329
+ ];
330
+ if (!list.length) rows.push(H(dim(all.length ? ' Nobody here fits that role and kit.' : ' Nobody is looking for work here right now.')));
256
331
  for (const c of list) {
257
332
  const b = c.bot;
333
+ if (!memo.tags.has(b.id)) memo.tags.set(b.id, profTags(candProfLines(c)));
258
334
  rows.push({
259
- text: `${fit(b.name, 12)} ${fit(dim(`L${b.level} ${className(b.cls)}`), 12)} ${fit(c.specRole ? roleText(c.specRole) : '', 6)} ${fit(hireText(a, c, true), 7)} ${profTags(candProfLines(c))}`,
335
+ text: `${fit(b.name, 12)} ${fit(dim(`L${b.level} ${className(b.cls)}`), 12)} ${fit(c.specRole ? roleText(c.specRole) : '', 6)} ${fit(dim(KIT_SHORT[c.gearProfile] ?? ''), 7)} ${fit(hireText(a, c, true), 7)} ${memo.tags.get(b.id)}`,
260
336
  data: b.id,
261
- detail: () => withArt(a, candCharacter(a, c), [...candLines(a, c), '', dim('Enter: their page h: hire')]),
337
+ detail: () => { const full = fullCand(a, c); return full ? withArt(a, candCharacter(a, full), [...candLines(a, full), '', dim('Enter: their page h: hire')]) : []; },
262
338
  act: () => a.push(candidateDialog(a, b.id)),
263
339
  });
264
340
  }
341
+ // A new list opens on your own level (the slate's window), not on the level 1s at its top.
342
+ if (!view.placed && list.length) {
343
+ view.placed = true;
344
+ const at = list.findIndex((c) => c.bot.level >= a.ch.level - 2);
345
+ view.cursor = rows.length - list.length + (at < 0 ? list.length - 1 : at);
346
+ view.scroll = at > 0 ? view.cursor - 3 : 0; // a few lower levels above it, or the filter rows when it heads the list
347
+ }
265
348
  return rows;
266
349
  },
267
350
  };
351
+ const stepKit = (a, d) => {
352
+ const kits = kitsOf(results ?? hirePool(a).list);
353
+ view.kit = kits[(kits.indexOf(view.kit) + d + kits.length) % kits.length];
354
+ view.placed = false;
355
+ a.render();
356
+ };
357
+ return view;
268
358
  }
269
359
 
270
360
  /** One face at the inn: everything about them, and the hire buttons. */
@@ -284,7 +374,7 @@ function candidateDialog(app, botId) {
284
374
  for (const role of roles) {
285
375
  rows.push({
286
376
  text: `Hire as ${roleText(role)} for ${hireText(a, c)}${role === c.specRole ? dim(' their spec') : ''}`,
287
- detail: () => withArt(a, body, [bold(`Hire ${b.name} as ${role}`), `Cost: ${hireText(a, c)} you have ${yellow(moneyText(a.ch.gold) || '0c')}`,
377
+ detail: () => withArt(a, body, [bold(`Hire ${b.name} as ${role}`), `Cost: ${hireText(a, c)} you have ${coinText(a.ch.gold)}`,
288
378
  role === c.specRole ? dim(`They already play ${c.specName}.`) : dim('They respec for the role: talents and rotation rebuild for free.'),
289
379
  ...((a.g.state.squad ?? []).length >= MAX_COMPANIONS ? [yellow('Your party is full: they start on the bench.')] : []), dim('A released hire refunds the price.')]),
290
380
  act: () => { if (hireAs(a, c, role)) a.pop(); },
@@ -295,7 +385,8 @@ function candidateDialog(app, botId) {
295
385
  for (const l of candProfLines(c)) {
296
386
  const prof = ds.profession(l.line);
297
387
  rows.push({
298
- text: `${fit(prof?.name ?? l.line, 16)} ${fit(`${l.current}/${l.max}`, 8)} ${dim(l.rank ?? '')}${isSecondary(l.line) ? dim(' secondary') : ''}`,
388
+ text: `${fit(prof?.name ?? l.line, 16)} ${fit(`${l.current}/${l.max}`, 8)} ${dim(l.rank ?? '')}`,
389
+ icon: prof?.icon, sub: dim(isSecondary(l.line) ? 'secondary' : prof?.kind ?? ''),
299
390
  detail: () => [bold(prof?.name ?? l.line), `${l.current}/${l.max}${l.rank ? ` ${l.rank}` : ''}`, '',
300
391
  isMissionSkill(l.line) ? `Once hired they can go on ${missionVerb(l.line).toLowerCase()} trips while you play.` : `Once hired they craft from their own ${prof?.name ?? l.line} recipe book with your materials.`,
301
392
  dim('Companions train for free as they level, and can swap trades later.')],
@@ -309,7 +400,7 @@ function candidateDialog(app, botId) {
309
400
  const e = equippedEntry(c.gear?.[slot]);
310
401
  if (e == null) continue;
311
402
  const it = ds.item(e);
312
- rows.push({ text: `${fit(SLOT_LABEL[slot] ?? slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? e)}`, detail: () => (it ? itemLines(a, it) : []) });
403
+ rows.push({ text: `${fit(SLOT_LABEL[slot] ?? slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? e)}`, icon: it?.icon, sub: dim(`${fit('', 10)} ${gearKind(it)}`), detail: () => (it ? itemLines(a, it) : []) });
313
404
  }
314
405
  return rows;
315
406
  },
@@ -322,6 +413,7 @@ export function companionDialog(app, id) {
322
413
  return {
323
414
  id: `comp-${id}`, title: findComp(app, id)?.name ?? 'Companion', hint: 'Enter: act',
324
415
  keys: {
416
+ c: { hint: 'character sheet', fn: (a) => a.push(companionSheet(a, id)) },
325
417
  x: { hint: 'take off / drop', fn: (a, row) => {
326
418
  const d = row?.data;
327
419
  if (d?.slot && d.it) a.result(a.g.unequipCompanionSlot(id, d.slot), `${findComp(a, id)?.name} takes off ${itemText(d.it)}.`);
@@ -340,6 +432,9 @@ export function companionDialog(app, id) {
340
432
  }
341
433
  rows.push(H(''));
342
434
  const note = (title, ...lines) => withArt(a, comp, [bold(title), '', ...lines]);
435
+ rows.push({ text: `Character sheet ${dim('c')}`, icon: 'inv_misc_head_human_01', sub: dim('their gear on the paperdoll, and their stats'),
436
+ detail: () => note('Character sheet', 'What they wear on the paperdoll, the way your Character tab shows you: each piece in its slot, its tooltip beside, their numbers underneath.'),
437
+ act: () => a.push(companionSheet(a, id)) });
343
438
  rows.push({ text: `Role: ${roleText(comp.role)}`, detail: () => note('Role', 'Tank, healer or damage. Changing it is free and rebuilds their talents and rotation for the role.'),
344
439
  act: () => a.pick(`${comp.name}: what role?`, SQUAD_ROLES.map((r) => ({ label: roleText(r), value: r })), (r) => a.result(g.setCompanionRole(id, r), `${comp.name} is now your ${r}.`)) });
345
440
  rows.push(seated
@@ -359,7 +454,8 @@ export function companionDialog(app, id) {
359
454
  for (const l of sortLines(profs?.lines ?? [])) {
360
455
  const prof = ds.profession(l.line);
361
456
  rows.push({
362
- text: `${fit(prof?.name ?? l.line, 16)} ${fit(`${l.current}/${l.max}`, 8)} ${dim(l.rank ?? '')}${isSecondary(l.line) ? dim(' secondary') : ''}${comp.mission?.skill === l.line ? yellow(' away') : ''}`, data: { line: l.line },
457
+ text: `${fit(prof?.name ?? l.line, 16)} ${fit(`${l.current}/${l.max}`, 8)} ${dim(l.rank ?? '')}${comp.mission?.skill === l.line ? yellow(' away') : ''}`, data: { line: l.line },
458
+ icon: prof?.icon, sub: dim(`${isSecondary(l.line) ? 'secondary' : prof?.kind ?? ''} ${isMissionSkill(l.line) ? 'Enter: trips' : 'Enter: recipe book'}`),
363
459
  detail: () => [bold(prof?.name ?? l.line), `${l.current}/${l.max}${l.rank ? ` ${l.rank}` : ''}`, '', dim(isMissionSkill(l.line) ? 'Enter: smelting and gathering trips' : 'Enter: their recipe book'), dim('x: drop it')],
364
460
  act: () => a.push(recipeBook(a, id, l.line)),
365
461
  });
@@ -384,6 +480,7 @@ export function companionDialog(app, id) {
384
480
  const it = e != null ? ds.item(e) : null;
385
481
  rows.push({
386
482
  text: `${fit(SLOT_LABEL[slot] ?? slot, 10)} ${it ? qualityPaint(it.quality)(it.name) : dim('—')}`, data: { slot, it },
483
+ icon: it?.icon, sub: it ? dim(`${fit('', 10)} ${gearKind(it)}`) : undefined,
387
484
  detail: () => (it ? itemLines(a, it, [...enchantLines(a, comp, slot), '', dim('Enter: change x: take off')]) : [dim(`Nothing in the ${SLOT_LABEL[slot] ?? slot} slot.`), '', dim('Enter: pick something for them to wear')]),
388
485
  act: () => slotPicker(a, id, slot),
389
486
  });
@@ -393,7 +490,70 @@ export function companionDialog(app, id) {
393
490
  rows.push(...bankRows(a, comp));
394
491
 
395
492
  rows.push(H(''), { text: red(`Release ${comp.name} for good`), detail: () => note('Release', 'They leave your roster for good. You get the hire price back. Their bags and gear go with them.'),
396
- act: () => a.confirm(`Release ${comp.name} for good? Their bags and gear go with them.`, () => { const r = g.releaseCompanions({ ids: [id] }); if (a.result(r, `${comp.name} leaves.${r.refundCopper ? ` You get ${moneyText(r.refundCopper)} back.` : ''}`)) a.popTo('__none__'); }) });
493
+ act: () => a.confirm(`Release ${comp.name} for good? Their bags and gear go with them.`, () => { const r = g.releaseCompanions({ ids: [id] }); if (a.result(r, `${comp.name} leaves.${r.refundCopper ? ` You get ${coinText(r.refundCopper)} back.` : ''}`)) a.popTo('__none__'); }) });
494
+ return rows;
495
+ },
496
+ };
497
+ }
498
+
499
+ // ---- their character sheet ------------------------------------------------------------------------------
500
+
501
+ /** Their numbers at the foot of the sheet's tooltip pane, laid out like yours under the Character tab. */
502
+ function sheetStats(app, char, w) {
503
+ const ds = app.ds, st = effectiveStats(char, ds), pt = powerType(char);
504
+ const pct = (x) => `${((x ?? 0) * 100).toFixed(2)}%`;
505
+ const attrs = [['Strength', st.str], ['Agility', st.agi], ['Stamina', st.sta], ['Intellect', st.int], ['Spirit', st.spi]];
506
+ const combat = [['Armor', effectiveArmor(char, ds)], ['Attack pwr', attackPower(char, ds)], ['Crit', pct(critChanceDisplay(char, ds))],
507
+ ['Hit', pct(hitChance(char, ds))], ['Dodge', pct(dodgeChanceDisplay(char, ds))]];
508
+ return [
509
+ dim('─'.repeat(Math.max(0, w))),
510
+ `Health ${maxHealth(char, ds)} ${pt[0].toUpperCase() + pt.slice(1)} ${maxPower(char, ds)}`,
511
+ '',
512
+ ...attrs.map(([name, v], i) => `${fit(name, 10)}${String(v).padStart(5)} ${fit(combat[i][0], 10)} ${String(combat[i][1]).padStart(7)}`),
513
+ ];
514
+ }
515
+
516
+ function sheetTitle(app, comp) {
517
+ const ilvl = displayGearScore(comp.equipped ?? {}, app.ds);
518
+ return `${bold(comp.name)} ${dim(`level ${companionLevel(comp, app.ch.level)} ${raceName(comp.race)} ${className(comp.cls)}`)} ${roleText(comp.role)}${ilvl ? dim(` ilvl ~${ilvl}`) : ''}`;
519
+ }
520
+
521
+ /**
522
+ * A companion's character sheet: the Character tab's paperdoll (tuiPaperdoll.mjs) for them. Their pieces
523
+ * framed in quality colour down both sides, them in their gear in the middle, the tooltip beside and their
524
+ * stats at its foot. Enter picks from their bags and yours, x takes a piece off, u puts on every upgrade.
525
+ * Too small a window or no colour, and it lists the slots with their icons instead.
526
+ */
527
+ export function companionSheet(app, id) {
528
+ const viewId = `sheet-${id}`;
529
+ const hint = 'Enter: change x: take off u: upgrades';
530
+ return {
531
+ id: viewId, title: 'Character sheet', hint: 'Enter: change',
532
+ detailFoot: (a, w) => { const c = findComp(a, id); return c ? sheetStats(a, companionCharacter(c, companionLevel(c, a.ch.level), a.ds), w) : []; },
533
+ keys: {
534
+ x: { hint: 'take off', fn: (a, row) => { const d = row?.data; if (d?.slot && d.it) a.result(a.g.unequipCompanionSlot(id, d.slot), `${findComp(a, id)?.name} takes off ${itemText(d.it)}.`); } },
535
+ u: { hint: 'put on upgrades', fn: (a) => { const c = findComp(a, id), r = a.g.equipCompanionBest(id); a.result(r, r.equipped ? `${c?.name} puts on ${plural(r.equipped, 'piece')}.` : `${c?.name} already wears the best they can.`); } },
536
+ },
537
+ grid: (a, rows, w, h) => { const c = findComp(a, id); return c ? paperdollGrid(a, rows, w, h, (cols, lines) => paperdollArt(c, cols, lines), sheetTitle(a, c), hint) : null; },
538
+ rows(a) {
539
+ const ds = a.ds, comp = findComp(a, id);
540
+ if (!comp) { setTimeout(() => { if (a.top()?.id === viewId) a.pop(); }, 0); return [H('They are gone.')]; }
541
+ const ups = a.g.companionUpgradesFor(id);
542
+ const rows = paperdollPlan(a, 0) ? [] : [{ text: sheetTitle(a, comp), header: true, plain: true }, H('')];
543
+ for (const slot of EQUIP_SLOTS) {
544
+ const e = equippedEntry(comp.equipped?.[slot]);
545
+ const it = e != null ? ds.item(e) : null;
546
+ const best = ups.filter((u) => u.slot === slot).sort((x, y) => y.delta - x.delta)[0];
547
+ const bestLines = best ? ['', green(`▲ Better: ${a.itemName(best.entry)} (+${Math.round(best.delta)})${best.from === 'player' ? ', in your bags' : ', in their bags'}`), dim('Enter: pick it u: put on every upgrade')] : [];
548
+ rows.push({
549
+ text: `${fit(SLOT_LABEL[slot] ?? slot, 10)} ${it ? qualityPaint(it.quality)(it.name) : dim('—')}${best ? green(' ▲') : ''}`, data: { slot, it },
550
+ icon: it?.icon, sub: it ? dim(`${fit('', 10)} ${gearKind(it)}`) : undefined,
551
+ cell: { slot, it, up: !!best, sub: dim(gearKind(it)) },
552
+ detail: () => (it ? itemLines(a, it, [...enchantLines(a, comp, slot), '', dim('Enter: change x: take off'), ...bestLines])
553
+ : [bold(SLOT_LABEL[slot] ?? slot), dim('Nothing worn here.'), '', dim('Enter: pick something for them to wear'), ...bestLines]),
554
+ act: () => slotPicker(a, id, slot),
555
+ });
556
+ }
397
557
  return rows;
398
558
  },
399
559
  };
@@ -406,7 +566,7 @@ function giveDialog(app, id) {
406
566
  if (!st.length) { app.flashMsg('Your bags are empty.'); return; }
407
567
  app.pick(`Give to ${comp?.name}`, st.map((s, slot) => {
408
568
  const it = app.ds.item(s.item);
409
- return { label: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''}`, detail: () => itemLines(app, it), value: { s, slot, it } };
569
+ return { label: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''}`, icon: it?.icon, sub: dim(it?.typeName ?? ''), detail: () => itemLines(app, it), value: { s, slot, it } };
410
570
  }), ({ slot, it }) => {
411
571
  const r = app.g.giveItemToBotStack(id, slot);
412
572
  app.result(r, `You give ${itemText(it, 'it')}${r.moved > 1 ? ` x${r.moved}` : ''} to ${comp?.name}.`);
@@ -424,7 +584,7 @@ function slotPicker(app, id, slot) {
424
584
  const options = [];
425
585
  const add = (s, from) => {
426
586
  const it = ds.item(s.item), up = ups.get(s.item);
427
- options.push({ label: `${qualityPaint(it.quality)(it.name)}${up ? green(` ▲ +${Math.round(up.delta)}`) : ''}${it.requiredLevel > lvl ? red(` L${it.requiredLevel}`) : ''}${dim(from === 'you' ? ' your bags' : ' their bags')}`, detail: () => itemLines(app, it), value: it });
587
+ options.push({ label: `${qualityPaint(it.quality)(it.name)}${up ? green(` ▲ +${Math.round(up.delta)}`) : ''}${it.requiredLevel > lvl ? red(` L${it.requiredLevel}`) : ''}${dim(from === 'you' ? ' your bags' : ' their bags')}`, icon: it.icon, sub: dim(gearKind(it)), detail: () => itemLines(app, it), value: it });
428
588
  };
429
589
  for (const s of comp.bags?.stacks ?? []) if (fits(s)) add(s, 'them');
430
590
  for (const s of app.g.state.inventory.stacks) if (fits(s)) add(s, 'you');
@@ -13,6 +13,7 @@ import { equippedEntry } from '../domain/items/itemInstance.ts';
13
13
  import { nativeDisplayId } from '../domain/character/displayForm.ts';
14
14
  import { creatureArt } from './modelArt.mjs';
15
15
  import { paperdollArt } from './paperdoll.mjs';
16
+ import { characterNamesEqual } from '../domain/character/characterName.ts';
16
17
 
17
18
  // A sentence on each race and class, in the voice of the client's create screen; the racials, the start
18
19
  // zone and everything else on the card come from the data.
@@ -97,9 +98,14 @@ export function characterCreation(app, done, { onCancel } = {}) {
97
98
  const drawn = (cls, maxCols, maxRows) => paperdollArt(hero(cls), maxCols, maxRows) ?? creatureArt(nativeDisplayId(st.race.id, st.gender), maxCols, maxRows);
98
99
  const withBody = (text, art) => (art ? [...text, '', ...art] : text);
99
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));
100
105
  const askName = () => app.prompt('Name (2-12 letters): ', (raw) => {
101
106
  const name = raw.trim();
102
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; }
103
109
  st.name = properName(name);
104
110
  goTo('create'); // named: Enter again creates
105
111
  }); // the line starts empty: Esc keeps the name they had
@@ -107,6 +113,7 @@ export function characterCreation(app, done, { onCancel } = {}) {
107
113
  const shuffle = () => { st.look = randomLook(st.race.id, st.gender); app.render(); };
108
114
  const create = () => {
109
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; }
110
117
  done(newGameState(ds, st.race.id, st.cls, st.name, newSeed(), st.gender, st.look));
111
118
  };
112
119
  const randomCharacter = () => {
@@ -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
  }
@@ -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);