azeroth-shell 0.2.0 → 0.4.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 (55) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +43 -10
  3. package/bin/azsh.mjs +15 -1
  4. package/package.json +7 -3
  5. package/src/cli/hdArt.mjs +1 -1
  6. package/src/cli/iconArt.mjs +11 -1
  7. package/src/cli/modelArt.mjs +7 -4
  8. package/src/cli/oocPrep.mjs +32 -0
  9. package/src/cli/repl.mjs +16 -0
  10. package/src/cli/sixel.mjs +26 -14
  11. package/src/cli/text.mjs +16 -0
  12. package/src/cli/tui.mjs +40 -13
  13. package/src/cli/tuiAutopilot.mjs +17 -8
  14. package/src/cli/tuiBags.mjs +37 -4
  15. package/src/cli/tuiBulk.mjs +83 -4
  16. package/src/cli/tuiCheck.mjs +328 -19
  17. package/src/cli/tuiCity.mjs +31 -31
  18. package/src/cli/tuiCompanionKit.mjs +26 -8
  19. package/src/cli/tuiCompanions.mjs +189 -29
  20. package/src/cli/tuiCreate.mjs +7 -0
  21. package/src/cli/tuiDungeon.mjs +113 -33
  22. package/src/cli/tuiFight.mjs +63 -21
  23. package/src/cli/tuiFightPanes.mjs +15 -6
  24. package/src/cli/tuiFloor.mjs +20 -8
  25. package/src/cli/tuiGear.mjs +9 -10
  26. package/src/cli/tuiLoot.mjs +5 -6
  27. package/src/cli/tuiMap.mjs +214 -0
  28. package/src/cli/tuiPaperdoll.mjs +6 -5
  29. package/src/cli/tuiPartyStage.mjs +36 -7
  30. package/src/cli/tuiPet.mjs +16 -8
  31. package/src/cli/tuiProfessions.mjs +23 -23
  32. package/src/cli/tuiSaves.mjs +14 -0
  33. package/src/cli/tuiSearch.mjs +4 -5
  34. package/src/cli/tuiSettings.mjs +3 -4
  35. package/src/cli/tuiStage.mjs +72 -16
  36. package/src/cli/tuiTalents.mjs +3 -4
  37. package/src/cli/tuiViews.mjs +374 -85
  38. package/src/cli/tuiWiki.mjs +2 -3
  39. package/src/cli/tuiWorld.mjs +88 -5
  40. package/src/data/gear-art.bin.br +0 -0
  41. package/src/data/hd-art.json +36 -36
  42. package/src/data/model-art-worn.bin.br +0 -0
  43. package/src/data/model-art.bin.gz +0 -0
  44. package/src/data/tables/creatures.json.gz +0 -0
  45. package/src/data/world-map-sixel.bin.br +0 -0
  46. package/src/data/world-map.bin.gz +0 -0
  47. package/src/domain/character/character.ts +6 -0
  48. package/src/domain/pet/pet.ts +31 -0
  49. package/src/engine/combat/pickAction.ts +3494 -3487
  50. package/src/engine/combat/sessionChase.ts +12 -1
  51. package/src/engine/game/loadReconcile.ts +21 -1
  52. package/src/engine/game/pets.ts +33 -1
  53. package/src/engine/game/professions.ts +4 -0
  54. package/src/engine/game/squadQuestShare.ts +13 -1
  55. package/src/engine/game/types.ts +4 -0
@@ -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, NORMAL_INSTANCE_RESET_TIME_MS,
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';
@@ -45,6 +47,12 @@ const hasOwned = (app) => (entry) => countItem(ownedInventory(app.g.state.invent
45
47
  const bagCapacityOf = (app) => bagCapacity(app.ch, app.ds);
46
48
  const isRaid = (def) => (def.maxPlayers ?? 5) > 5;
47
49
 
50
+ /** Inside a run whose every required stop is down: nothing left but the way out. */
51
+ export function runCleared(app) {
52
+ const def = runDef(app);
53
+ return !!def && isComplete(def, app.g.state.instance);
54
+ }
55
+
48
56
  /** Bodies in a stop: the pack's counts, or the primary alone. */
49
57
  function packSize(enc) {
50
58
  const n = (enc.pack ?? []).reduce((s, m) => s + (m.count ?? 1), 0);
@@ -53,48 +61,96 @@ function packSize(enc) {
53
61
 
54
62
  // ---- portals and entry ------------------------------------------------------------------------------------
55
63
 
64
+ /** The engine's own door check (level, attunement, key, quest chain, lockout), so the row says no before Enter does. Ref worldView.ts:626. */
65
+ function entryCheck(app, def, now = Date.now()) {
66
+ const g = app.g;
67
+ return canEnter(def, app.ch.level, g.state.lockouts ?? {}, now, { teleports: app.ds.areatriggerTeleports(), instanceSaves: g.state.instanceSaves, ...buildEntryLockInputs(g.state, def.name) });
68
+ }
69
+
70
+ /** The other doors into this instance (Maraudon's orange side, Gnomeregan's train depot, Uldaman's back door...). */
71
+ const extraDoors = (def) => extraEntrancesForInstance(def.id).map((row) => ({ id: row.id, name: String(row.name ?? '').replace(/\s*-\s*Entering.*$/i, '').trim() || def.name }));
72
+
56
73
  function lockText(app, def, now = Date.now()) {
57
74
  const g = app.g;
58
75
  const held = restoreSavedRun(g.state.instanceSaves, def.id, now);
59
76
  const lock = lockRemainingMs(g.state.lockouts ?? {}, def.id, now);
60
77
  if (held) {
61
78
  const left = (g.state.instanceSaves?.[def.id]?.expiresAtMs ?? now) - now;
79
+ if (isComplete(def, held)) return green(`cleared, resets in ${secondsText(left)}`);
62
80
  return yellow(`your run is held, ${held.defeated.length} cleared, ${secondsText(left)} left`);
63
81
  }
64
82
  if (lock > 0) return red(`saved, ${secondsText(lock)} left`);
65
83
  return '';
66
84
  }
67
85
 
68
- /** World tab rows: the instance portals in this zone. */
86
+ /** World tab rows: the instance portals in this zone, the one you are in among them. */
69
87
  export function portalRows(app) {
70
88
  const g = app.g, ds = app.ds, ch = app.ch;
71
- const rows = [];
72
- if (g.state.instance) {
73
- const def = runDef(app);
74
- rows.push(H(''), H('Dungeon'), { text: `${green('▶')} Back into ${bold(def?.name ?? g.state.instance.dungeonId)}`, detail: () => [bold(def?.name ?? ''), 'You are inside this dungeon.', '', dim('Enter: the run window')], act: () => openRun(app) });
75
- return rows;
76
- }
89
+ const inside = runDef(app);
77
90
  const portals = instancePortals(ds, ch.zoneId).filter((d) => !d.id.startsWith('alterac'));
78
- if (!portals.length) return rows;
79
- rows.push(H(''), H(`Dungeons (${portals.length})`));
91
+ // The run you are in is listed even when its door is in another zone.
92
+ if (inside && !portals.some((d) => d.id === inside.id)) portals.unshift(inside);
93
+ if (!portals.length) return [];
94
+ const rows = [H(''), H(`Dungeons (${portals.length})`)];
80
95
  for (const def of portals) {
81
- const low = ch.level < def.minLevel;
96
+ const range = dim(`L${def.levelRange[0]}-${def.levelRange[1]}${isRaid(def) ? ` ${def.maxPlayers}-man raid` : ''}`);
97
+ if (def.id === inside?.id) {
98
+ rows.push({ text: `${cyan(def.name)} ${range} ${green('▶ you are in it')}`, data: { dungeon: def }, detail: () => portalLines(app, def), act: () => openRun(app) });
99
+ continue;
100
+ }
101
+ const chk = entryCheck(app, def);
102
+ const low = !chk.ok && !restoreSavedRun(g.state.instanceSaves, def.id, Date.now());
82
103
  const lock = lockText(app, def);
83
104
  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}` : ''}`,
105
+ text: `${low ? dim(def.name) : cyan(def.name)} ${range}${low && !lock ? red(` ${chk.reason ?? `requires ${def.minLevel}`}`) : ''}${lock ? ` ${lock}` : ''}`,
85
106
  data: { dungeon: def },
86
107
  detail: () => portalLines(app, def),
87
- act: () => app.push(entryDialog(app, def)),
108
+ act: () => goToDungeon(app, def),
88
109
  });
89
110
  }
90
111
  return rows;
91
112
  }
92
113
 
114
+ /** Enter on a portal: out of the run you are in (it is held), then straight back into a run you left
115
+ * half done, or the entry card for a fresh one. */
116
+ function goToDungeon(app, def) {
117
+ if (app.g.state.instance) {
118
+ const was = runDef(app);
119
+ if (!app.result(app.g.leaveInstance(Date.now()), `You leave ${was?.name ?? 'the dungeon'}.`)) return;
120
+ app.dungeonPulls = [];
121
+ app.floor = null;
122
+ app.stack = [];
123
+ }
124
+ const held = restoreSavedRun(app.g.state.instanceSaves, def.id, Date.now());
125
+ if (held && !isComplete(def, held)) enter(app, def, { routeDensity: held.routeDensity ?? DEFAULT_ROUTE_DENSITY, asRaid: isRaid(def), door: null });
126
+ else app.push(entryDialog(app, def));
127
+ }
128
+
129
+ // A 5-man you walk out of stays as you left it for two hours here, not the engine's 30 minutes
130
+ // (NORMAL_INSTANCE_RESET_TIME): time to hearth, sell, repair and come back. Raids keep their lockout.
131
+ const RUN_HOLD_MS = 2 * 60 * 60 * 1000;
132
+
133
+ /** After every action: when the run you were in is gone (leave, hearth, taxi, another door), stretch its hold. */
134
+ export function holdRunLonger(app) {
135
+ const g = app.g;
136
+ if (!g) { app.inRunId = null; return; }
137
+ const was = app.inRunId, now = g.state.instance?.dungeonId ?? null;
138
+ app.inRunId = now;
139
+ if (!was || was === now) return;
140
+ const def = app.ds.instance(was), save = g.state.instanceSaves?.[was];
141
+ if (!def || def.lockoutMs > 0 || !save) return;
142
+ g.state.instanceSaves = { ...g.state.instanceSaves, [was]: { ...save, expiresAtMs: save.expiresAtMs - NORMAL_INSTANCE_RESET_TIME_MS + RUN_HOLD_MS } };
143
+ const i = g.state.activity.findIndex((l, j) => j < 5 && l.endsWith('It resets in 30 minutes.'));
144
+ if (i >= 0) g.state.activity[i] = g.state.activity[i].replace('30 minutes', '2 hours');
145
+ }
146
+
93
147
  function portalLines(app, def) {
94
148
  const bosses = def.encounters.filter((e) => e.kind === 'boss');
95
149
  const out = [bold(def.name), dim(`levels ${def.levelRange[0]}-${def.levelRange[1]}, requires ${def.minLevel}${isRaid(def) ? `, ${def.maxPlayers} players` : ''}`)];
96
150
  const lock = lockText(app, def);
97
151
  if (lock) out.push(lock);
152
+ const chk = entryCheck(app, def);
153
+ if (!chk.ok && !lock) out.push(red(chk.reason ?? 'You cannot enter yet.'));
98
154
  out.push('', cyan(`Bosses (${bosses.length})`), ...bosses.slice(0, 14).map((b) => ` ${b.name}`), ...(bosses.length > 14 ? [dim(` and ${bosses.length - 14} more`)] : []));
99
155
  out.push('', dim('Enter: route, party and supplies, then go in'));
100
156
  return out;
@@ -102,7 +158,8 @@ function portalLines(app, def) {
102
158
 
103
159
  /** The card before the door: route density, party or raid, food and water, Enter. */
104
160
  export function entryDialog(app, def) {
105
- const opts = { routeDensity: DEFAULT_ROUTE_DENSITY, asRaid: isRaid(def) };
161
+ const opts = { routeDensity: DEFAULT_ROUTE_DENSITY, asRaid: isRaid(def), door: null };
162
+ const doors = extraDoors(def);
106
163
  return {
107
164
  id: `enter-${def.id}`, title: def.name, hint: 'Enter: choose',
108
165
  rows(a) {
@@ -110,10 +167,13 @@ export function entryDialog(app, def) {
110
167
  const squad = g.state.squad ?? [];
111
168
  const rows = [H(`${bold(def.name)} ${dim(`L${def.levelRange[0]}-${def.levelRange[1]}`)} ${lockText(a, def)}`), H('')];
112
169
  const held = restoreSavedRun(g.state.instanceSaves, def.id, Date.now());
113
- if (held) rows.push(H(yellow(`Your earlier run is held: ${held.defeated.length} stops cleared. Entering continues it.`)), H(''));
114
- 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) });
170
+ const heldDone = held && isComplete(def, held);
171
+ if (heldDone) rows.push(H(green('You cleared it. The halls stay empty until it resets.')), H(''));
172
+ else if (held) rows.push(H(yellow(`Your earlier run is held: ${held.defeated.length} stops cleared. Entering continues it.`)), H(''));
173
+ rows.push({ text: heldDone ? dim('Go back in (nothing left to fight)') : 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
174
  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
175
  rows.push(pace(opts.routeDensity));
176
+ 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
177
  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
178
  const walk = walkingIn(g, def, opts.asRaid);
119
179
  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 +191,7 @@ export function entryDialog(app, def) {
131
191
  }
132
192
 
133
193
  function enter(app, def, opts) {
134
- const r = app.g.enterInstance(def.id, Date.now(), { routeDensity: opts.routeDensity, asRaid: opts.asRaid });
194
+ const r = app.g.enterInstance(def.id, Date.now(), { routeDensity: opts.routeDensity, asRaid: opts.asRaid, ...(opts.door ? { entranceTriggerId: opts.door.id } : {}) });
135
195
  if (!app.result(r, `You enter ${def.name}.`)) return;
136
196
  app.dungeonPulls = [];
137
197
  app.stack = [];
@@ -141,17 +201,17 @@ function enter(app, def, opts) {
141
201
  // Food and water before the door: who is short, the vendor ladder, what it costs (runSupplyPanel.ts).
142
202
  function supplyRows(app) {
143
203
  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')}`)];
204
+ 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
205
  if (plan.mage || !plan.rows.length) return rows;
146
206
  for (const r of plan.rows) {
147
207
  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))}`));
208
+ 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
209
  }
150
210
  const buys = supplyBuys(plan.rows, app.ch.gold, true);
151
211
  rows.push({
152
- text: buys.length ? `Buy them ${yellow(`(${moneyText(buys.reduce((s, b) => s + b.cost, 0))})`)}` : dim('Buy them (not enough gold)'),
212
+ text: buys.length ? `Buy them ${yellow(`(${coinText(buys.reduce((s, b) => s + b.cost, 0))})`)}` : dim('Buy them (not enough gold)'),
153
213
  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)}.` : ''); },
214
+ 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
215
  });
156
216
  return rows;
157
217
  }
@@ -280,7 +340,7 @@ function openChest(app, pin) {
280
340
  app.say(`You open ${pin.name}.${r.loot.length ? '' : ' It is empty.'}`);
281
341
  if (took.ok) noteLoot(app, took.taken, took.copper);
282
342
  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)}`);
343
+ if (took.ok && took.copper) app.say(` ${yellow('You receive')} ${coinText(took.copper)}`);
284
344
  if (took.ok && took.remaining) app.say(red(` ${plural(took.remaining, 'item')} did not fit in your bags.`));
285
345
  return true;
286
346
  }
@@ -362,7 +422,7 @@ export function runView(app) {
362
422
  F: { hint: 'finish instantly', fn: (a) => a.confirm('Run every remaining stop now, without watching?', () => finishInstantly(a)) },
363
423
  L: { hint: 'leave', fn: (a) => leaveRun(a) },
364
424
  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]; } }) },
425
+ 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
426
  },
367
427
  rows(a) {
368
428
  const g = a.g, run = g.state.instance, def = runDef(a);
@@ -391,7 +451,7 @@ export function runView(app) {
391
451
  }
392
452
  for (const o of objects.get('_') ?? []) rows.push({ ...objectRow(a, def, o), data: { object: o } });
393
453
  rows.push(H(''), { text: `Walk the floor ${dim('go anywhere; patrols and packs you pass pull on their own (W)')}`, detail: () => [bold('The floor'), '', 'Instead of picking stops, walk the dungeon on its nav mesh. Patrols walk their routes, and whatever sees you on the way pulls.'], act: () => openFloor(a) });
394
- rows.push({ text: dim('Leave the dungeon'), detail: () => [bold('Leave'), '', isRaid(def) ? 'A raid holds your progress until the lockout resets.' : 'A 5-man holds your progress for 30 minutes.'], act: () => leaveRun(a) });
454
+ rows.push({ text: dim('Leave the dungeon'), detail: () => [bold('Leave'), '', isRaid(def) ? 'A raid holds your progress until the lockout resets.' : 'A 5-man holds your progress for 2 hours.'], act: () => leaveRun(a) });
395
455
  return rows;
396
456
  },
397
457
  };
@@ -399,13 +459,15 @@ export function runView(app) {
399
459
 
400
460
  // ---- pulls ---------------------------------------------------------------------------------------------
401
461
 
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 } = {}) {
462
+ /** Pull one stop. `floor`: the continuous floor's pull (no sequential lock: you walked into it); `ready`: buff
463
+ * and rest first, a pull you chose (a floor pack that saw you is already on you); `onDone(r)` runs after the
464
+ * fight with its outcome (null when you ran). */
465
+ export function pullStop(app, def, enc, { floor = false, ready = !floor, onDone, onTick } = {}) {
405
466
  const run = app.g.state.instance;
406
467
  if (!run) return;
407
468
  if (isCleared(run, enc.id)) { app.flashMsg(`${enc.name} is already cleared.`); return; }
408
469
  if (!floor && !isUnlocked(def, run, enc.id)) { app.flashMsg(`${enc.name} is locked.`); return; }
470
+ if (ready) readyUp(app);
409
471
  for (const l of prePull(app.g, app.ds, app.cmds.buffPace)) app.say(cyan(l));
410
472
  let playerMax = 0, session = null;
411
473
  const f = startFight(app, enc.creatureEntry, {
@@ -458,16 +520,34 @@ function recordPull(app, def, enc, r, playerMax, session, downtimeSec) {
458
520
  });
459
521
  }
460
522
 
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) {
523
+ /** Eat and drink from the bags, then sit, up to the squad strategy's rest line (betweenPullRest.ts). */
524
+ function eatAndDrink(app) {
463
525
  const g = app.g;
464
526
  const r = betweenPullConsumeLoop({ char: g.state.char, inventory: g.state.inventory, ds: app.ds, strategy: g.state.squadStrategy, deathFlow: g.state.deathFlow });
465
527
  g.state.char = r.char; g.state.inventory = r.inventory;
528
+ app.dungeonDowntime = (app.dungeonDowntime ?? 0) + r.seconds;
529
+ return r;
530
+ }
531
+
532
+ /** Between pulls: eat and drink to the rest line, then sit. */
533
+ export function restBetween(app, seconds) {
534
+ const r = eatAndDrink(app);
466
535
  app.idleTick(seconds, true);
467
- app.dungeonDowntime = (app.dungeonDowntime ?? 0) + r.seconds + seconds;
536
+ app.dungeonDowntime += seconds;
468
537
  app.say(dim(`The party rests${r.seconds ? ` and eats (${Math.round(r.seconds)} s)` : ''}. HP ${app.hpText()} ${app.pwText()}`));
469
538
  }
470
539
 
540
+ /** Before a pull you chose: the buffs phase cast standing still, then eat and drink back to the rest line, as
541
+ * runFullDungeon rests between pulls. Pulling on the mana the armour, Intellect and conjures just took sent
542
+ * a level 20 mage in at 761 of 1966, and nothing between stops gave it back. */
543
+ export function readyUp(app) {
544
+ const buffed = prepText(prepOoc(app.g, app.ds));
545
+ if (buffed) app.say(cyan(buffed));
546
+ const r = eatAndDrink(app);
547
+ if (r.seconds > 0) app.say(dim(`The party rests before the pull (${Math.round(r.seconds)} s). HP ${app.hpText()} ${app.pwText()}`));
548
+ for (const m of r.messages) if (/^Out of /.test(m)) app.say(yellow(` ${m}.`));
549
+ }
550
+
471
551
  /** Open gates that need no item, and route flags the density skipped (liveDungeonHeadless.ts autoFireGates). */
472
552
  function autoFireGates(app) {
473
553
  const g = app.g, ds = app.ds, inst = g.state.instance;
@@ -576,7 +656,7 @@ export function summaryView(app, s) {
576
656
  const deaths = s.pulls.filter((p) => p.outcome === 'defeat').length;
577
657
  const rows = [H(`${bold(s.dungeonName)} ${tag}`), H('')];
578
658
  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'}`));
659
+ rows.push(H(` ${secondsText(s.combatMs)} fighting, ${secondsText(s.downtimeSec * 1000)} resting +${s.totalXp} xp ${coinText(s.totalGold)}`));
580
660
  if (s.stopReason) rows.push(H(red(` Stopped: ${s.stopReason}`)));
581
661
  if (s.memberTotals?.length) {
582
662
  const secs = Math.max(1, s.combatMs / 1000);
@@ -4,17 +4,17 @@
4
4
  // your cast bar is centred under the floor and the keyed ability bar runs along the bottom. A party fight or
5
5
  // a narrow terminal falls back to the list of unit frames (renderFrames). Auto-play runs the character's
6
6
  // rotation; a number key presses an ability over it for one GCD.
7
- import { bold, dim, cyan, yellow, green, red, gray, inverse, fit, vis, cut, bar, strip, brightBlue, itemText, orange } from './text.mjs';
7
+ import { bold, dim, cyan, yellow, green, red, gray, inverse, fit, vis, cut, bar, strip, brightBlue, itemText, orange, coinText } from './text.mjs';
8
8
  import { className } from '../domain/core/ids.ts';
9
- import { SIZES, youSize, foeSize, canvas, put, putLines, floorLine, POWER_PAINT, POP_MS, POP_RISE_MS, LUNGE_MS, SHORT_BUFF_MS, ROLE_GLYPH, plateLines, auraRows, castSlot, auraText, auraTime, putMissiles, lootToasts, putLootToasts } from './tuiStage.mjs';
9
+ import { SIZES, youSize, foeSize, canvas, put, putLines, floorLine, POWER_PAINT, POP_MS, POP_RISE_MS, LUNGE_MS, SHORT_BUFF_MS, ROLE_GLYPH, plateLines, auraRows, castSlot, castRows, auraText, auraTime, putMissiles, lootToasts, putLootToasts } from './tuiStage.mjs';
10
10
  import { partyStage } from './tuiPartyStage.mjs';
11
11
  import { combatDistanceYd, DEFAULT_OPEN_YD, MELEE_CONTACT_YD } from '../engine/combat/combatGeometry.ts';
12
12
  import { formatCombatLog, combatLogPlainText, dropLogRepeats, POWER_MERGE_MS } from './combatLogFormat.ts';
13
13
  import { impliedDest } from './combatLogFilter.ts';
14
- import { moneyText } from '../domain/economy/money.ts';
15
14
  import { maxHealth, maxPower } from '../domain/character/character.ts';
16
15
  import { powerType } from '../domain/character/characterStats.ts';
17
16
  import { pullOpts, sessionLogNames } from './squad.mjs';
17
+ import { prepOoc, prepText } from './oocPrep.mjs';
18
18
  import { slotContent, visibleBars } from './tuiBars.mjs';
19
19
  import { autoLootOn, openLoot } from './tuiLoot.mjs';
20
20
  import { createCombatMacroHost } from './macroHostCombat.ts';
@@ -25,6 +25,8 @@ import { creatureArt, artWidth } from './modelArt.mjs';
25
25
  import { iconArt, iconsOn } from './iconArt.mjs';
26
26
 
27
27
  export const TICK_MS = 100; // one engine tick per 100 ms of real time, like the web client
28
+ // Fight time with every living foe out of combat (an evade, a leash) before the fight ends as a stalemate.
29
+ export const QUIET_END_MS = 3000;
28
30
  // A win holds the fight on screen this long (real time), plus a beat for each drop as it slides in.
29
31
  const WIN_HOLD_MS = 1000, DROP_HOLD_MS = 450, DROP_STAGGER_MS = 220, WIN_HOLD_MAX_MS = 3500;
30
32
 
@@ -38,6 +40,8 @@ const WIN_HOLD_MS = 1000, DROP_HOLD_MS = 450, DROP_STAGGER_MS = 220, WIN_HOLD_MA
38
40
  export function startFight(app, creatureEntry, { onEnd, label, begin, after, resolve, onTick, why } = {}) {
39
41
  if (app.fight) return { ok: false, reason: 'Already in a fight.' };
40
42
  if (app.isDead()) { app.flashMsg('You are dead.'); return { ok: false, reason: 'You are dead.' }; }
43
+ // A pull you chose buffs first, standing still; a dungeon stop does it in pullStop, an ambush (begin) cannot.
44
+ if (!begin && !resolve) { const buffed = prepText(prepOoc(app.g, app.ds)); if (buffed) app.say(cyan(buffed)); }
41
45
  const opts = begin ? { withSquad: true } : pullOpts(app.g, app.ds, app.cmds.buffPace, creatureEntry, (l) => app.say(cyan(l)));
42
46
  const b = begin ? begin() : app.g.beginFight(creatureEntry, opts);
43
47
  if (!app.result(b)) { app.render(); return { ok: false, reason: b.reason || 'Cannot fight that.' }; }
@@ -81,9 +85,24 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
81
85
  else events = session.tick();
82
86
  this.logEvents(events);
83
87
  if (!session.ended) this.onTick?.();
88
+ if (!session.ended && !begin && !resolve) this.watchQuiet();
84
89
  if (session.ended) this.finish();
85
90
  app.renderSoon();
86
91
  },
92
+ /** A pull that went quiet: something was in combat, and every living foe has been out of it for QUIET_END_MS.
93
+ * The engine keeps that session open (a reset mob stands passive and the rotation leaves it be, so a Scout
94
+ * that evaded once held the fight for 970 s), so it ends here as a timeout, which settles as a stalemate:
95
+ * the web client's "Enemies evade" settle. Not on a dungeon floor, whose session holds unpulled packs. */
96
+ wasEngaged: false, quietSince: null,
97
+ watchQuiet() {
98
+ const live = session.enemiesView().filter((e) => e.hp > 0);
99
+ if (live.some((e) => e.engaged)) { this.wasEngaged = true; this.quietSince = null; return; }
100
+ if (!this.wasEngaged || !live.length || !session.mirrorOutcome) return;
101
+ this.quietSince ??= session.t;
102
+ if (session.t - this.quietSince < QUIET_END_MS) return;
103
+ app.say(yellow(`${live.length > 1 ? 'Your foes reset' : `${live[0].name} resets`} and the fight is over.`));
104
+ session.mirrorOutcome(true, 'timeout');
105
+ },
87
106
  /** A win holds the fight on screen a moment before it gives the screen back: the foe at 0 on its plate and
88
107
  * what it dropped sliding in beside it (putLootToasts). The timer keeps ticking through it, so the hold is
89
108
  * counted in ticks: the speed keys and the check script drive it like the fight. */
@@ -102,6 +121,8 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
102
121
  logEvents(events) {
103
122
  app.meter?.ingest(events);
104
123
  notePops(this.frames, events);
124
+ let known = null;
125
+ noteCasts(this.frames, events, (id) => (known ??= new Set(session.abilities().map((a) => a.spell.id))).has(id));
105
126
  if (this.folded()) return;
106
127
  const flush = () => { if (this.pend && this.pend.amount > 0) app.say(line(this.pend.t, this.pend.remake(this.pend.amount))); this.pend = null; };
107
128
  const line = (t, row) => `${dim((t / 1000).toFixed(1).padStart(5) + 's')} ${paintLog(row)}`;
@@ -130,7 +151,7 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
130
151
  const tag = r.outcome === 'win' ? green(bold('Victory')) : r.outcome === 'defeat' ? red(bold('Defeat')) : yellow(bold('Stalemate'));
131
152
  const bits = [`${(r.result.durationMs / 1000).toFixed(1)} s`, `HP ${app.hpText()}`];
132
153
  if (r.xpGained) bits.push(`+${r.xpGained} xp`);
133
- if (r.goldGained && !hold) bits.push(`+${moneyText(r.goldGained)}`);
154
+ if (r.goldGained && !hold) bits.push(`+${coinText(r.goldGained)}`);
134
155
  app.say(`${tag} against ${this.enemyName} ${dim(bits.join(' '))}`);
135
156
  if (!hold) { noteLoot(app, r.loot, r.goldGained); for (const d of r.loot) app.say(` ${yellow('You receive')} ${app.itemLabel(d.item)}${d.count > 1 ? ` x${d.count}` : ''}`); }
136
157
  const gained = (app.g.state.squad ?? []).filter((c) => (c.xp ?? 0) !== squadXp.get(c.id)).map((c) => `${c.name} ${(c.xp ?? 0) > (squadXp.get(c.id) ?? 0) ? `+${(c.xp ?? 0) - (squadXp.get(c.id) ?? 0)} xp` : 'levels up'}`);
@@ -558,7 +579,21 @@ const CAST_W = 30; // the cast/control slot on a frame's second line, so auras n
558
579
  function newFrameMemory(app) {
559
580
  // The player's own aura order carries across fights, so their frame never reshuffles.
560
581
  app.playerAuraOrder ??= new Map();
561
- return { auraOrder: new Map([['player', app.playerAuraOrder]]), pops: new Map(), used: new Map(), lunge: new Map() };
582
+ return { auraOrder: new Map([['player', app.playerAuraOrder]]), pops: new Map(), used: new Map(), lunge: new Map(), casts: new Map(), barred: new Map() };
583
+ }
584
+
585
+ /** Every unit's casts as they go off, newest first, for its cast slot (castRows): a cast_finish with no
586
+ * cast_start before it is an instant. `own(id)` keeps yours to the spells you know, so a proc's triggered
587
+ * spell never shows as something you pressed. */
588
+ function noteCasts(mem, events, own) {
589
+ for (const ev of events) {
590
+ if (!ev.actor || ev.spellId == null) continue;
591
+ if (ev.kind === 'cast_start') { mem.barred.set(ev.actor, ev.spellId); continue; }
592
+ if (ev.kind !== 'cast_finish' || (ev.actor === 'player' && !own(ev.spellId))) continue;
593
+ const instant = mem.barred.get(ev.actor) !== ev.spellId;
594
+ mem.barred.delete(ev.actor);
595
+ mem.casts.set(ev.actor, [{ t: ev.t, spellId: ev.spellId, name: ev.name, instant }, ...(mem.casts.get(ev.actor) ?? [])].slice(0, 8));
596
+ }
562
597
  }
563
598
 
564
599
  const POP_W = 18; // a hit or heal stays beside the bars for POP_MS (fight time), in a slot this wide
@@ -566,12 +601,14 @@ const USED_FLASH_MS = 500; // the ability bar slot of a spell just cast stays in
566
601
  const MISS_WORD = { miss: 'Miss', dodge: 'Dodge', parry: 'Parry', immune: 'Immune', deflect: 'Deflect', block: 'Block' };
567
602
 
568
603
  /** Damage and healing landing on each unit, newest first, for the numbers that pop up beside its bars; the
569
- * player's casts, for the flash on the ability bar; and every melee swing, for the lunge. */
604
+ * player's casts, for the flash on the ability bar; and every melee auto-attack, for the lunge. */
570
605
  function notePops(mem, events) {
571
606
  for (const ev of events) {
572
607
  let side, text;
573
608
  if (ev.actor === 'player' && ev.spellId != null && (ev.kind === 'cast_start' || ev.kind === 'cast_finish' || ev.kind === 'spell_hit' || ev.kind === 'cast_self')) mem.used?.set(ev.spellId, ev.t);
574
- if (ev.kind === 'melee' && ev.actor) mem.lunge?.set(ev.actor, ev.t); // only a weapon swing lunges; spells, strikes and thorns do not
609
+ // Only a melee auto-attack lunges: spells, strikes and thorns do not, nor a ranged white (Auto Shot, Shoot
610
+ // or Throw), which the engine sends as a melee event tagged with the shoot spell's id and name.
611
+ if (ev.kind === 'melee' && ev.actor && ev.spellId == null && !ev.name) mem.lunge?.set(ev.actor, ev.t);
575
612
  if (ev.kind === 'melee' || ev.kind === 'spell_hit' || ev.kind === 'dot_tick') {
576
613
  side = ev.target ?? impliedDest(ev.actor);
577
614
  if (ev.amount > 0) text = ev.result === 'crit' ? bold(red(`-${ev.amount}!`)) : red(`-${ev.amount}`);
@@ -603,7 +640,7 @@ function unitFrame(app, mem, v, W, now, isPlayer, focused = false, { compact = f
603
640
  if (!bars) {
604
641
  const key = isPlayer ? 'player' : (v.side ?? v.name);
605
642
  const name = `${(dead ? gray : isPlayer ? green : yellow)(bold(v.name))} ${dim(`L${v.level}`)}`;
606
- const slot = castSlot(v, now);
643
+ const slot = castSlot(v, now, mem.casts?.get(key));
607
644
  const auras = auraText(app, mem, v, key);
608
645
  return [cut(` ${fit(name, 20)} ${fit(slot, CAST_W)} ${fit(popText(mem, key, now), POP_W)} ${auras}`, W)];
609
646
  }
@@ -619,12 +656,15 @@ function unitFrame(app, mem, v, W, now, isPlayer, focused = false, { compact = f
619
656
  if (compact) return [cut(`${head}${v.casting && !dead ? ` ${yellow(v.casting.name)}` : after}`, W)];
620
657
  // The second line is always there, cast slot first (a cast bar, else crowd control, else stealth, blank when
621
658
  // idle) and the auras after it, so gaining a buff or starting a cast never moves this frame or the ones below.
622
- const slot = castSlot(v, now);
659
+ const recent = mem.casts?.get(key);
623
660
  if (iconsOn()) {
624
- // Icons: a strip of 4x4 icons beside the cast slot, each one's time (or stacks) under it on a third line.
661
+ // Icons: the cast slot two lines tall (the spell's icon, its name, the casts before it), then a strip of
662
+ // 4x4 icons, each aura's time (or stacks) under it on the third line.
625
663
  const [top, bottom] = auraIcons(app, mem, v, key);
626
- return [cut(`${head}${after}`, W), cut(` ${fit(slot, CAST_W)} ${top}`, W), cut(` ${' '.repeat(CAST_W)} ${bottom}`, W)];
664
+ const [slotTop, slotUnder] = dead ? ['', ''] : castRows(app, v, recent, now, CAST_W);
665
+ return [cut(`${head}${after}`, W), cut(` ${fit(slotTop, CAST_W)} ${top}`, W), cut(` ${fit(slotUnder, CAST_W)} ${bottom}`, W)];
627
666
  }
667
+ const slot = castSlot(v, now, recent);
628
668
  const auras = auraText(app, mem, v, key);
629
669
  return [cut(`${head}${after}`, W), cut(` ${fit(slot, CAST_W)} ${auras}`, W)];
630
670
  }
@@ -662,7 +702,7 @@ export function fightArt(app, ctl, W, H) {
662
702
  const cols = artCols(W);
663
703
  const entry = ctl.session?.enemyView()?.creatureEntry ?? ctl.enemyEntry;
664
704
  const foe = cols && entry != null ? creatureArt(app.ds.creature(entry)?.displayId, cols, H) : null;
665
- const me = cols && W >= 130 ? playerArt(app, cols, H)?.lines ?? null : null;
705
+ const me = cols && W >= 130 ? playerArt(app, cols, H, ctl.session?.playerView())?.lines ?? null : null;
666
706
  const innerW = W - (foe ? artWidth(foe) + 2 : 0) - (me ? artWidth(me) + 2 : 0);
667
707
  return { me, foe, innerW };
668
708
  }
@@ -698,13 +738,14 @@ function duelStage(app, ctl, W, H, { inline }) {
698
738
  const icons = iconsOn();
699
739
  const barW = W >= 140 ? 30 : W >= 110 ? 24 : 18;
700
740
  const plateW = barW + PLATE_PAD;
701
- const auraH = icons ? 3 : 1;
702
- // Rows the scene keeps whatever the sprites get: the plate (3), the state row, the auras and the floor.
703
- // The state row is there idle or casting, so a cast never moves the auras under it. The ability bar goes
741
+ const auraH = icons ? 3 : 1, stateH = icons ? 2 : 1;
742
+ // Rows the scene keeps whatever the sprites get: the plate (3), the state rows, the auras and the floor.
743
+ // The state rows are there idle or casting, so a cast never moves the auras under them; with icons they are
744
+ // two, the spell's icon beside its name and the trail of casts before it (castRows). The ability bar goes
704
745
  // when rows are short, and only under auto-play.
705
746
  const barLines = ctl.abilityRows(W, W >= 140 ? 6 : 4, { live: !inline });
706
747
  let barRows = barLines.length;
707
- const fixed = () => 3 + 1 + auraH + 1 + barRows;
748
+ const fixed = () => 3 + stateH + auraH + 1 + barRows;
708
749
  if (H - fixed() < 4 && ctl.auto) barRows = 0;
709
750
  const artRows = H - fixed();
710
751
  if (artRows < 4) return null;
@@ -718,7 +759,7 @@ function duelStage(app, ctl, W, H, { inline }) {
718
759
  return { v, key, art, aw: artWidth(art), cw: Math.max(pw, art ? artWidth(art) : 0), plateW: pw, ...o, barW: o.barW ?? barW };
719
760
  };
720
761
  const cols = [];
721
- const you = column(pv, 'player', youSize(W), (c, r) => playerArt(app, c, r)?.lines ?? null, { isPlayer: true });
762
+ const you = column(pv, 'player', youSize(W), (c, r) => playerArt(app, c, r, pv)?.lines ?? null, { isPlayer: true });
722
763
  const margin = Math.max(GAP, Math.floor(W / 16)); // the actors stand a little in from the edges
723
764
  you.x = margin; cols.push(you);
724
765
  let left = you.x + you.cw + GAP, right = W - margin, petCol = null;
@@ -763,7 +804,7 @@ function duelStage(app, ctl, W, H, { inline }) {
763
804
  }
764
805
  cols.push(foe);
765
806
  }
766
- const zone = artRows, stateRow = zone + 3, auraTop = zone + 4, floorRow = auraTop + auraH, barTop = floorRow + 1;
807
+ const zone = artRows, stateRow = zone + 3, auraTop = stateRow + stateH, floorRow = auraTop + auraH, barTop = floorRow + 1;
767
808
  const c = canvas(W, H);
768
809
  for (const col of cols) {
769
810
  const v = col.v, dead = v.hp <= 0;
@@ -774,9 +815,10 @@ function duelStage(app, ctl, W, H, { inline }) {
774
815
  col.ax = col.x + Math.max(0, Math.floor((col.cw - col.aw) / 2)) + lunge; col.top = zone - col.art.length; putLines(c, col.ax, col.top, col.art);
775
816
  }
776
817
  putLines(c, col.x, zone, plateLines(app, v, { ...col, dead }));
777
- // Your cast sits where the foe's does: under the power bar, in the state row every plate keeps.
778
- const slot = castSlot(v, now);
779
- if (slot && !dead) put(c, col.x, stateRow, cut(slot, col.plateW));
818
+ // Your cast sits where the foe's does: under the power bar, in the state rows every plate keeps.
819
+ const recent = mem.casts.get(col.key);
820
+ const slot = dead ? [''] : icons ? castRows(app, v, recent, now, col.plateW) : [castSlot(v, now, recent)];
821
+ if (slot[0] || slot[1]) putLines(c, col.x, stateRow, slot.map((l) => cut(l, col.plateW)));
780
822
  else if (col === foe && hidden) put(c, col.x, stateRow, dim(`+${hidden} more in the fight`));
781
823
  if (!col.small && !dead) putLines(c, col.x, auraTop, auraRows(app, mem, v, col.key, col.plateW, icons));
782
824
  // Hits and heals rise from the chest, the newest lowest; without art they sit beside the name.
@@ -2,10 +2,10 @@
2
2
  // and the loot taken so far. The log is the game's own log (tui.mjs); the meter is phonecraft's DPSMate
3
3
  // aggregator fed every tick's events; threat reads the engine's ThreatManager through the session; loot is a
4
4
  // session-long tally of what the "You receive" lines handed over. The choice sticks between fights.
5
- import { bold, dim, cyan, yellow, green, red, inverse, fit, vis, cut, bar, pct } from './text.mjs';
5
+ import { bold, dim, cyan, yellow, green, red, inverse, fit, vis, cut, bar, pct, coinText } from './text.mjs';
6
6
  import { createDpsMateMeter, partyMeterSnapshot } from '../domain/bots/dpsMate.ts';
7
- import { moneyText } from '../domain/economy/money.ts';
8
7
  import { runPaneLines, questPaneLines, bagsPaneLines } from './tuiAutopilot.mjs';
8
+ import { iconsOn, iconArt, iconBlank } from './iconArt.mjs';
9
9
 
10
10
  export const FIGHT_PANES = [
11
11
  { id: 'log', label: 'Log' },
@@ -230,16 +230,25 @@ function threatLines(app, ctl, L) {
230
230
  function lootLines(app, L) {
231
231
  const t = app.lootTally ?? { rows: [], copper: 0 };
232
232
  const n = t.rows.reduce((a, r) => a + r.count, 0);
233
- const lines = [` ${bold('Loot this sitting')} ${dim(`${n} item${n === 1 ? '' : 's'}${t.copper ? ` · ${moneyText(t.copper)}` : ''}`)}`];
233
+ const lines = [` ${bold('Loot this sitting')} ${dim(`${n} item${n === 1 ? '' : 's'}${t.copper ? ` · ${coinText(t.copper)}` : ''}`)}`];
234
234
  if (!t.rows.length && !t.copper) { lines.push(dim(' Nothing looted yet. Whatever you take shows up here.')); return lines; }
235
235
  const colW = Math.max(24, Math.floor((L.W - 2) / Math.max(1, Math.min(3, Math.floor(L.W / 30)))));
236
236
  const cols = Math.max(1, Math.floor((L.W - 2) / colW));
237
- const cells = t.rows.map((r) => `${app.itemLabel(r.item)}${r.count > 1 ? dim(` x${r.count}`) : ''}`);
238
- const rowsAvail = L.logH - 1;
237
+ // With icons each cell is two lines: a 4px icon down its left, the name beside the top and the count and
238
+ // kind beside the bottom.
239
+ const icons = iconsOn() && L.W >= 40;
240
+ const h = icons ? 2 : 1;
241
+ const cells = t.rows.map((r) => {
242
+ if (!icons) return [`${app.itemLabel(r.item)}${r.count > 1 ? dim(` x${r.count}`) : ''}`];
243
+ const it = app.ds.item(r.item);
244
+ const art = iconArt(it?.icon, 4) ?? iconBlank(4);
245
+ return [`${art[0]} ${app.itemLabel(r.item)}`, `${art[1]} ${dim([r.count > 1 ? `x${r.count}` : '', it?.typeName].filter(Boolean).join(' · '))}`];
246
+ });
247
+ const rowsAvail = Math.floor((L.logH - 1) / h);
239
248
  for (let i = 0; i < rowsAvail; i++) {
240
249
  const slice = cells.slice(i * cols, i * cols + cols);
241
250
  if (!slice.length) break;
242
- lines.push(` ${slice.map((c) => fit(c, colW - 1)).join(' ')}`);
251
+ for (let k = 0; k < h; k++) lines.push(` ${slice.map((c) => fit(c[k], colW - 1)).join(' ')}`);
243
252
  }
244
253
  const shown = Math.min(cells.length, rowsAvail * cols);
245
254
  if (shown < cells.length) lines[lines.length - 1] = fit(lines[lines.length - 1], L.W - 10) + dim(`+${cells.length - shown} more`);