azeroth-shell 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +362 -85
  2. package/bin/azsh.mjs +35 -19
  3. package/package.json +12 -1
  4. package/src/autopilot/plan.ts +8 -11
  5. package/src/autopilot/view.ts +12 -11
  6. package/src/cli/combatLogFormat.ts +22 -0
  7. package/src/cli/gzLoader.mjs +47 -6
  8. package/src/cli/hdArt.mjs +81 -0
  9. package/src/cli/iconArt.mjs +145 -0
  10. package/src/cli/iconGrid.mjs +100 -0
  11. package/src/cli/modelArt.mjs +298 -0
  12. package/src/cli/oocPrep.mjs +32 -0
  13. package/src/cli/paperdoll.mjs +76 -0
  14. package/src/cli/perfCheck.mjs +56 -0
  15. package/src/cli/repl.mjs +48 -7
  16. package/src/cli/rotationText.mjs +6 -6
  17. package/src/cli/sixel.mjs +319 -0
  18. package/src/cli/term.mjs +106 -12
  19. package/src/cli/text.mjs +21 -3
  20. package/src/cli/tui.mjs +262 -102
  21. package/src/cli/tuiAutopilot.mjs +356 -0
  22. package/src/cli/tuiBags.mjs +37 -4
  23. package/src/cli/tuiBg.mjs +370 -41
  24. package/src/cli/tuiCheck.mjs +633 -37
  25. package/src/cli/tuiCity.mjs +146 -41
  26. package/src/cli/tuiCompanionKit.mjs +6 -6
  27. package/src/cli/tuiCompanions.mjs +211 -41
  28. package/src/cli/tuiCreate.mjs +230 -0
  29. package/src/cli/tuiDungeon.mjs +57 -21
  30. package/src/cli/tuiFight.mjs +467 -88
  31. package/src/cli/tuiFightPanes.mjs +42 -14
  32. package/src/cli/tuiFloor.mjs +21 -9
  33. package/src/cli/tuiGear.mjs +17 -14
  34. package/src/cli/tuiLoot.mjs +6 -6
  35. package/src/cli/tuiMap.mjs +214 -0
  36. package/src/cli/tuiPaperdoll.mjs +108 -0
  37. package/src/cli/tuiPartyStage.mjs +206 -0
  38. package/src/cli/tuiPet.mjs +16 -8
  39. package/src/cli/tuiProfessions.mjs +27 -25
  40. package/src/cli/tuiRotationCond.mjs +390 -0
  41. package/src/cli/tuiSaves.mjs +24 -1
  42. package/src/cli/tuiSearch.mjs +79 -27
  43. package/src/cli/tuiSettings.mjs +3 -4
  44. package/src/cli/tuiStage.mjs +360 -0
  45. package/src/cli/tuiTalentTree.mjs +124 -0
  46. package/src/cli/tuiTalents.mjs +3 -4
  47. package/src/cli/tuiViews.mjs +633 -172
  48. package/src/cli/tuiWiki.mjs +2 -3
  49. package/src/cli/tuiWorld.mjs +90 -7
  50. package/src/data/gear-art.bin.br +0 -0
  51. package/src/data/hd-art.json +114 -0
  52. package/src/data/icon-art.bin.gz +0 -0
  53. package/src/data/model-art-worn.bin.br +0 -0
  54. package/src/data/model-art.bin.gz +0 -0
  55. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  56. package/src/data/tables/creatures.json.gz +0 -0
  57. package/src/data/world-map-sixel.bin.br +0 -0
  58. package/src/data/world-map.bin.gz +0 -0
  59. package/src/domain/bg/match.ts +85 -24
  60. package/src/domain/bg/strategies.ts +144 -29
  61. package/src/domain/bg/types.ts +2 -0
  62. package/src/domain/bg/wsg.ts +35 -20
  63. package/src/domain/character/character.ts +6 -0
  64. package/src/domain/pet/pet.ts +31 -0
  65. package/src/domain/quests/doQuest.ts +24 -6
  66. package/src/domain/rotation/build.ts +3031 -3031
  67. package/src/domain/rotation/classes/paladin.ts +16 -11
  68. package/src/domain/rotation/evaluate.ts +3 -0
  69. package/src/engine/combat/applySpell.ts +20 -9
  70. package/src/engine/combat/applySpellLate.ts +16 -12
  71. package/src/engine/combat/areaAura.ts +284 -283
  72. package/src/engine/combat/fighterFactory.ts +1 -1
  73. package/src/engine/combat/pickAction.ts +3494 -3449
  74. package/src/engine/combat/procsDamage.ts +31 -1
  75. package/src/engine/combat/sessionChase.ts +19 -2
  76. package/src/engine/combat/sessionMotionKit.ts +40 -2
  77. package/src/engine/game/loadReconcile.ts +21 -1
  78. package/src/engine/game/pets.ts +33 -1
  79. package/src/engine/game/types.ts +4 -0
  80. package/src/quests/quests.ts +60 -0
@@ -0,0 +1,390 @@
1
+ // The Rotation tab's "when it fires" editor: every condition phonecraft's web editor offers, driven by
2
+ // menus. The catalog (metrics, defaults, buckets) is the engine's condMetricPicker; the field shapes per
3
+ // metric (subject, reference, yes/no vs compare) mirror phonecraft src/ui/rotation/rotationEditor.ts.
4
+ // Also the smart defaults a freshly added spell or item rule starts with (build.ts defaultSpellRuleConditions).
5
+ import { bold, dim, cyan, green, red, yellow } from './text.mjs';
6
+ import {
7
+ condSentence, conditionsSentence, ruleSentence, SUBJECT_LABEL, FORM_NAMES,
8
+ CREATURE_TYPE_NAME, DISPEL_TYPE_NAME, SCHOOL_NAMES, TOTEM_ELEMENTS, AOE_MODE_NAMES, creatureMaskName,
9
+ } from './rotationText.mjs';
10
+ import { COND_METRIC_DEFAULTS, COND_METRIC_LABEL, COND_METRIC_BUCKET, metricsForFilter } from '../domain/rotation/condMetricPicker.ts';
11
+ import { defaultSpellRuleConditions, defaultItemRuleConditions, knownSpellsForSuggestionResolve } from '../domain/rotation/build.ts';
12
+ import { applyFormEnterGate, cond } from '../domain/rotation/classify.ts';
13
+ import { keepUpPresenceCondition } from '../domain/rotation/auraSide.ts';
14
+ import { spellAddPresetsFor } from '../domain/rotation/spellPicker.ts';
15
+ import { combatItems } from '../domain/character/character.ts';
16
+ import { imbueItemsIn } from '../domain/bots/companionPrep.ts';
17
+ import { consumableFamilyOfEntry } from '../domain/items/consumableFamily.ts';
18
+ import { equippedEntry } from '../domain/items/itemInstance.ts';
19
+ import { allySideAt } from '../engine/core/events.ts';
20
+ import { CLASS_NAMES } from '../domain/core/ids.ts';
21
+ import { DISPEL_TYPE } from '../domain/spells/dispel.ts';
22
+
23
+ // ---- field shapes per metric (phonecraft rotationEditor.ts) ---------------------------------------
24
+
25
+ const SUBJECTS = ['self', 'target', 'pet'];
26
+ /** Metrics whose subject may be a friendly, so the list offers "an ally" on them. */
27
+ const ALLY_SUBJECT_METRICS = new Set([
28
+ 'healthPct', 'powerPct', 'manaPct', 'level', 'alive', 'timeToDieMs',
29
+ 'buffPresent', 'debuffPresent', 'buffStacks', 'debuffStacks', 'buffRemainingMs', 'debuffRemainingMs',
30
+ 'castingPresent', 'castingIsHeal', 'castingSchool', 'castingSpellId',
31
+ 'classId', 'isHealer', 'isCaster', 'isHunter', 'formId', 'inMelee',
32
+ 'controlled', 'rooted', 'feared', 'stunned', 'snared', 'charmed', 'hasFlag', 'isFlagCarrier', 'drLevel',
33
+ ]);
34
+ /** Metrics that ignore the subject entirely (evaluate.ts resolves them without subjectOf). */
35
+ const SUBJECTLESS_METRICS = new Set([
36
+ 'itemCount', 'itemEquipped', 'combatTimeMs', 'inCombat', 'enemyCount', 'totemPresent', 'weaponImbuePresent',
37
+ 'allyLowestHpPct', 'hasAggro', 'targetAttackingMe', 'charmedHostileCount', 'deadAllyCount', 'aoeMode',
38
+ 'offHealAllowed', 'partySeats', 'allyLowestBuffPresent', 'allyAuraPresent', 'allyAuraStacks',
39
+ 'alliesBelowHpCount', 'nearbyAlliesBelowHpCount', 'meleeAllyCount', 'allyHasFlag', 'enemyHasFlag',
40
+ 'enemiesWithinYd', 'alliesWithinYd', 'healersWithinYd', 'enemyHealersWithinYd', 'enemyCastingOnAlly',
41
+ 'enemyCastingOnSelf', 'objectiveContested', 'objectiveOwnedByUs', 'objectiveDistanceYd', 'allyWithoutAuraNearYd',
42
+ 'hazardProximityYd', 'targetDistanceYd', 'creatureDistanceYd', 'allyHasAggro', 'isMainTank', 'isOffTank',
43
+ 'myJob', 'myCamp', 'assignedHealSubgroup', 'healerPowerPct',
44
+ ]);
45
+ /** Metrics whose reference is a spell id. */
46
+ const SPELL_REF_METRICS = new Set([
47
+ 'buffPresent', 'debuffPresent', 'buffStacks', 'debuffStacks', 'allyLowestBuffPresent', 'buffRemainingMs',
48
+ 'debuffRemainingMs', 'canAfford', 'allyAuraPresent', 'allyAuraStacks', 'allyWithoutAuraNearYd',
49
+ 'aggroHolderDebuffPresent', 'aggroHolderDebuffStacks', 'castingSpellId',
50
+ ]);
51
+ /** Metrics whose comparison is a plain yes / no, with the wording for each side ([yes, no]). */
52
+ const BOOL_LABEL = {
53
+ alive: ['is alive', 'is dead'],
54
+ buffPresent: ['has it', 'does not have it'], debuffPresent: ['has it', 'does not have it'],
55
+ allyLowestBuffPresent: ['the most-hurt ally has it', 'the most-hurt ally does not have it'],
56
+ allyAuraPresent: ['anyone has it', 'nobody has it'],
57
+ aggroHolderDebuffPresent: ['the tank has it', 'the tank does not have it'],
58
+ castingPresent: ['is casting', 'is not casting'], castingIsHeal: ['the cast is a heal', 'the cast is not a heal'],
59
+ castingSpellId: ['is casting that spell', 'is not casting that spell'],
60
+ dodgedRecently: ['dodged recently', 'has not dodged recently'],
61
+ defenseReactRecently: ['had a defense react', 'had no defense react'],
62
+ hunterParryRecently: ['parried recently', 'has not parried recently'],
63
+ inMelee: ['in melee', 'at range'], charmed: ['is charmed / possessed', 'is not charmed'],
64
+ totemPresent: ['slot occupied', 'slot empty'], weaponImbuePresent: ['has an imbue', 'has no imbue'],
65
+ hasAggro: ['an enemy is on you', 'no enemy is on you'], allyHasAggro: ['an enemy is on an ally', 'no enemy is on an ally'],
66
+ targetAttackingMe: ['the target is attacking you', 'the target is attacking someone else'],
67
+ offHealAllowed: ['off-heal is on', 'off-heal is off'],
68
+ creatureTypeMask: ['matches', 'does not match'], dispellablePresent: ['is present', 'is not present'],
69
+ controlled: ['under crowd control', 'free of crowd control'], rooted: ['rooted', 'not rooted'],
70
+ feared: ['feared', 'not feared'], stunned: ['stunned', 'not stunned'], snared: ['snared', 'not snared'],
71
+ silenced: ['silenced', 'not silenced'],
72
+ hasFlag: ['carrying the flag', 'not carrying the flag'], allyHasFlag: ['we have a flag', 'we have no flag'],
73
+ enemyHasFlag: ['they have our flag', 'they do not have it'], isFlagCarrier: ['is the flag carrier', 'is not the carrier'],
74
+ isHealer: ['is a healer', 'is not a healer'], isCaster: ['is a caster', 'is not a caster'],
75
+ isHunter: ['is a hunter', 'is not a hunter'], inCombat: ['in combat', 'out of combat'],
76
+ itemEquipped: ['equipped', 'not equipped'],
77
+ enemyCastingOnAlly: ['an enemy is casting at an ally', 'no enemy is casting at an ally'],
78
+ enemyCastingOnSelf: ['an enemy is casting at you', 'no enemy is casting at you'],
79
+ objectiveContested: ['contested', 'not contested'], objectiveOwnedByUs: ['ours', 'not ours'],
80
+ inTargetCone: ['in the frontal cone', 'out of the frontal cone'],
81
+ isMainTank: ['is the main tank', 'is not the main tank'], isOffTank: ['is an off-tank', 'is not an off-tank'],
82
+ };
83
+ const OPS = ['<', '<=', '>', '>=', '==', '!='];
84
+ const OP_LABEL = { '<': 'less than', '<=': 'at most', '>': 'more than', '>=': 'at least', '==': 'equal to', '!=': 'not equal to' };
85
+ /** Metrics whose value is a named thing rather than a number: [value, label] pairs. */
86
+ const NAMED_VALUE = {
87
+ castingSchool: { opWords: ['is', 'is not'], options: () => SCHOOL_NAMES.map((n, i) => [i, n]) },
88
+ aoeMode: { opWords: ['is', 'is not'], options: () => Object.entries(AOE_MODE_NAMES).map(([v, n]) => [Number(v), n]) },
89
+ classId: { opWords: ['is a', 'is not a'], options: () => PLAYER_CLASS_IDS.map((id) => [id, CLASS_NAMES[id] ?? String(id)]) },
90
+ formId: { opWords: ['in', 'not in'], options: () => Object.entries(FORM_NAMES).map(([v, n]) => [Number(v), n]) },
91
+ myJob: { opWords: ['is', 'is not'], options: () => [[0, 'DPS'], [1, 'Healer'], [2, 'Off-tank'], [3, 'Main tank']] },
92
+ myCamp: { opWords: ['is', 'is not'], options: () => [[0, 'unassigned'], [1, 'camp A'], [2, 'camp B'], [3, 'camp C']] },
93
+ };
94
+ const PLAYER_CLASS_IDS = [1, 2, 3, 4, 5, 7, 8, 9, 11];
95
+ /** The dispel categories a gate can ask about (classify.ts dispelGateConditions). */
96
+ const DISPEL_CATEGORIES = [DISPEL_TYPE.MAGIC, DISPEL_TYPE.CURSE, DISPEL_TYPE.DISEASE, DISPEL_TYPE.POISON, DISPEL_TYPE.STEALTH, DISPEL_TYPE.INVISIBILITY, DISPEL_TYPE.ENRAGE];
97
+ const ALLY_WHO = [
98
+ ['any', 'any ally'], ['role:tank', 'the tank'], ['role:healer', 'a healer'], ['role:damage', 'a DPS'],
99
+ ['not:tank', 'anyone but the tank'], ['not:healer', 'anyone but a healer'], ['not:damage', 'anyone but a DPS'],
100
+ ];
101
+ /** What a metric's number means, for the prompt. */
102
+ const VALUE_UNIT = {
103
+ healthPct: '%', powerPct: '%', manaPct: '%', petHappiness: '%', allyLowestHpPct: '%', healerPowerPct: '%',
104
+ buffRemainingMs: 'ms', debuffRemainingMs: 'ms', combatTimeMs: 'ms', timeToDieMs: 'ms',
105
+ enemiesWithinYd: 'enemies', alliesWithinYd: 'allies', healersWithinYd: 'healers', enemyHealersWithinYd: 'healers',
106
+ objectiveDistanceYd: 'yd', hazardProximityYd: 'yd', targetDistanceYd: 'yd', creatureDistanceYd: 'yd', allyWithoutAuraNearYd: 'yd',
107
+ };
108
+ /** Metrics whose `ref` is a yard radius / health band the count is taken over. */
109
+ const REF_NUMBER = {
110
+ enemiesWithinYd: 'Within how many yards', alliesWithinYd: 'Within how many yards', healersWithinYd: 'Within how many yards',
111
+ enemyHealersWithinYd: 'Within how many yards', alliesBelowHpCount: 'Below what health %', nearbyAlliesBelowHpCount: 'Within how many yards',
112
+ creatureDistanceYd: 'Creature entry', drLevel: 'Diminishing group',
113
+ };
114
+ const isBool = (m) => m in BOOL_LABEL;
115
+ const condYes = (c) => (c.op === '!=' ? c.value === 0 : c.value !== 0);
116
+
117
+ // ---- smart defaults for a new rule ------------------------------------------------------------------
118
+
119
+ /** The conditions a spell rule starts with: the engine's smart default, plus the form-enter gate. */
120
+ export function defaultConditionsForSpell(sp, char) {
121
+ const base = defaultSpellRuleConditions(sp, char).conditions;
122
+ return applyFormEnterGate(sp, base);
123
+ }
124
+
125
+ /** The conditions an item rule starts with, read off what the item actually does, and its rank ladder. */
126
+ export function defaultItemRule(app, owner, itemEntry) {
127
+ const ds = app.ds;
128
+ const bags = { stacks: owner.bag() };
129
+ const isImbue = imbueItemsIn(bags, ds).some((x) => x.entry === itemEntry);
130
+ const ci = combatItems(owner.char(), bags, ds).find((x) => x.entry === itemEntry);
131
+ const effect = ci?.effect;
132
+ const kind = isImbue ? 'weapon_imbue' : (effect?.kind ?? '');
133
+ const buffSpellId = effect?.kind === 'apply_buff' ? effect.spellId : undefined;
134
+ const payload = effect?.kind === 'friendly_cast' ? { spell: effect.spell, spellId: effect.spellId, ds } : {};
135
+ const { conditions } = defaultItemRuleConditions(kind, { itemEntry, ...(buffSpellId != null ? { buffSpellId } : {}), ...payload });
136
+ const family = consumableFamilyOfEntry(itemEntry, ds);
137
+ return { action: { kind: 'item', itemEntry, ...(family ? { family } : {}) }, conditions };
138
+ }
139
+
140
+ /** The quick presets for a spell rule (phonecraft's template chips), each a whole condition list. */
141
+ function presetLists(sp, char) {
142
+ if (!sp) return [];
143
+ const smart = defaultConditionsForSpell(sp, char);
144
+ const out = [{ label: `Recommended for ${sp.name}`, conds: smart }];
145
+ for (const p of spellAddPresetsFor(sp)) {
146
+ switch (p.id) {
147
+ case 'on_cast': out.push({ label: p.title, conds: [cond('target', 'castingPresent', '==', 1)] }); break;
148
+ case 'on_aggro': out.push({ label: p.title, conds: [cond('self', 'hasAggro', '==', 1)] }); break;
149
+ case 'off_target': out.push({ label: p.title, conds: [cond('self', 'targetAttackingMe', '==', 0)] }); break;
150
+ case 'low_self_hp': out.push({ label: p.title, conds: [cond('self', 'healthPct', '<', 35)] }); break;
151
+ case 'pack': out.push({ label: p.title, conds: [cond('self', 'enemyCount', '>=', 2)] }); break;
152
+ case 'keep_up': {
153
+ const hasGate = smart.some((c) => ['debuffPresent', 'buffPresent', 'buffStacks', 'debuffStacks', 'buffRemainingMs', 'debuffRemainingMs', 'feared'].includes(c.metric));
154
+ out.push({ label: p.title, conds: hasGate ? smart : [keepUpPresenceCondition(sp)] });
155
+ break;
156
+ }
157
+ default: break;
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+
163
+ // ---- the editor ----------------------------------------------------------------------------------------
164
+
165
+ /** A list view whose rows are rebuilt on every render, so it stays open while sub-pickers change the rule. */
166
+ function liveMenu(app, id, title, rowsFn) {
167
+ const view = { id, title, hint: 'Enter: choose Esc: back', rows: () => rowsFn() };
168
+ app.push(view);
169
+ return view;
170
+ }
171
+ const numberPrompt = (app, label, cur, onOk) => app.prompt(`${label} (now ${cur}): `, (txt) => {
172
+ const n = Number(txt);
173
+ if (!Number.isFinite(n)) { app.flashMsg('Enter a number.'); return; }
174
+ onOk(n);
175
+ app.afterAction();
176
+ }, '');
177
+
178
+ /** Switch a condition to another metric: the catalog's starting comparison, keeping what still applies. */
179
+ function applyMetric(c, metric, ruleSpellId) {
180
+ c.metric = metric;
181
+ const def = COND_METRIC_DEFAULTS[metric];
182
+ c.op = def.op; c.value = def.value;
183
+ if (def.subject) c.subject = def.subject;
184
+ else if (SUBJECTLESS_METRICS.has(metric)) c.subject = 'self';
185
+ if (def.ref != null) c.ref = def.ref;
186
+ else if (SPELL_REF_METRICS.has(metric) && ruleSpellId) c.ref = ruleSpellId;
187
+ else delete c.ref;
188
+ if (c.subject !== 'ally') delete c.ally;
189
+ }
190
+
191
+ /**
192
+ * Open the condition editor for one rule. `ctx` is `{ phase, index, owner, names, mutate }` where
193
+ * `mutate(fn)` edits the live rotation (fn receives the rotation) and `owner` is the Rotation tab's subject.
194
+ */
195
+ export function conditionEditor(app, ctx) {
196
+ const { phase, index, owner, names } = ctx;
197
+ const ruleNow = () => owner.get().phases[phase]?.[index];
198
+ const edit = (fn) => ctx.mutate((rot) => { const r = rot.phases[phase]?.[index]; if (r) fn(r); });
199
+ const editCond = (i, fn) => edit((r) => { const c = r.conditions[i]; if (c) fn(c, r); });
200
+ const ruleSpellId = () => { const r = ruleNow(); return r?.action.kind === 'spell' ? r.action.spellId : 0; };
201
+ const ruleSpell = () => (ruleSpellId() ? app.ds.spell(ruleSpellId()) : null);
202
+ const char = () => owner.char();
203
+
204
+ // ---- pickers for one field ----
205
+ const pickMetric = (cur, onPick) => {
206
+ const view = { filter: 'all', q: '' };
207
+ const open = () => {
208
+ const metrics = metricsForFilter(view.filter, cur, view.q);
209
+ const opts = [];
210
+ let last = null;
211
+ for (const m of metrics) {
212
+ const b = COND_METRIC_BUCKET[m];
213
+ if (b !== last && !view.q) { opts.push({ label: bold(b === 'common' ? 'Common' : 'Situational'), value: null }); last = b; }
214
+ opts.push({ label: `${m === cur ? green('● ') : ' '}${COND_METRIC_LABEL[m]}`, value: m });
215
+ }
216
+ app.pick(view.q ? `Conditions matching "${view.q}"` : 'What to check', opts, (m) => { if (m) onPick(m); else open(); }, { hint: 'Enter: choose /: search Esc: back' });
217
+ const top = app.top();
218
+ top.onKey = (a, k) => {
219
+ if (k !== '/') return false;
220
+ a.prompt('Search conditions: ', (txt) => { a.pop(); view.q = txt; open(); });
221
+ return true;
222
+ };
223
+ };
224
+ open();
225
+ };
226
+ const pickSpellRef = (cur, onPick) => {
227
+ const known = knownSpellsForSuggestionResolve(char(), owner.spellbook(), app.ds).sort((a, b) => a.name.localeCompare(b.name));
228
+ const opts = known.map((s) => ({ label: `${s.id === cur ? green('● ') : ' '}${s.name}${s.rank ? dim(` (Rank ${s.rank})`) : ''}`, value: s.id }));
229
+ if (cur != null && !known.some((s) => s.id === cur)) opts.unshift({ label: `${green('● ')}${names.spell(cur)}`, value: cur });
230
+ app.pick('Which spell or aura', opts, onPick);
231
+ };
232
+ const pickItemRef = (cur, includeEquipped, onPick) => {
233
+ const seen = new Set();
234
+ const opts = [];
235
+ for (const st of owner.bag()) {
236
+ if (seen.has(st.item)) continue; seen.add(st.item);
237
+ const it = app.ds.item(st.item);
238
+ if (it) opts.push({ label: `${it.entry === cur ? green('● ') : ' '}${it.name}`, value: it.entry });
239
+ }
240
+ if (includeEquipped) {
241
+ for (const v of Object.values(owner.equipped() ?? {})) {
242
+ const e = equippedEntry(v);
243
+ if (e == null || seen.has(e)) continue; seen.add(e);
244
+ const it = app.ds.item(e);
245
+ if (it) opts.push({ label: `${e === cur ? green('● ') : ' '}${it.name} ${dim('(worn)')}`, value: e });
246
+ }
247
+ }
248
+ if (cur != null && !seen.has(cur)) opts.unshift({ label: `${green('● ')}${names.item(cur)}`, value: cur });
249
+ if (!opts.length) { app.flashMsg('Nothing in the bags to pick.'); return; }
250
+ app.pick('Which item', opts, onPick);
251
+ };
252
+ const pickNamed = (title, pairs, cur, onPick) => app.pick(title, pairs.map(([v, n]) => ({ label: `${v === cur ? green('● ') : ' '}${n}`, value: v })), onPick);
253
+
254
+ // ---- the rows describing one condition's reference ----
255
+ const refRow = (c, i) => {
256
+ const m = c.metric;
257
+ const set = (ref) => editCond(i, (x) => { x.ref = ref; });
258
+ if (SPELL_REF_METRICS.has(m)) return { text: `Spell: ${cyan(c.ref ? names.spell(c.ref) : 'choose…')}`, act: () => pickSpellRef(c.ref, set) };
259
+ if (m === 'itemCount' || m === 'itemEquipped') return { text: `Item: ${cyan(c.ref ? names.item(c.ref) : 'choose…')}`, act: () => pickItemRef(c.ref, m === 'itemEquipped', set) };
260
+ if (m === 'totemPresent') return { text: `Totem slot: ${cyan(TOTEM_ELEMENTS[c.ref ?? 0] ?? '?')}`, act: () => pickNamed('Which totem slot', TOTEM_ELEMENTS.map((n, k) => [k, n]), c.ref ?? 0, set) };
261
+ if (m === 'weaponImbuePresent') { const hands = [[0, 'main hand'], [1, 'off hand'], [2, 'any open weapon hand']]; return { text: `Hand: ${cyan(hands.find(([v]) => v === (c.ref ?? 0))?.[1] ?? '?')}`, act: () => pickNamed('Which hand', hands, c.ref ?? 0, set) }; }
262
+ if (m === 'creatureTypeMask') {
263
+ const types = [...CREATURE_TYPE_NAME].map(([t, n]) => [1 << (t - 1), n]);
264
+ return { text: `Creature type: ${cyan(creatureMaskName(c.ref))}`, act: () => pickNamed('Which creature type', types, c.ref, set) };
265
+ }
266
+ if (m === 'dispellablePresent') return { text: `Aura kind: ${cyan(DISPEL_TYPE_NAME.get(c.ref ?? 0) ?? 'any')}`, act: () => pickNamed('Which kind of aura', DISPEL_CATEGORIES.map((t) => [t, DISPEL_TYPE_NAME.get(t) ?? String(t)]), c.ref, set) };
267
+ if (REF_NUMBER[m]) return { text: `${REF_NUMBER[m]}: ${cyan(String(c.ref ?? COND_METRIC_DEFAULTS[m].ref ?? 0))}`, act: () => numberPrompt(app, REF_NUMBER[m], c.ref ?? COND_METRIC_DEFAULTS[m].ref ?? 0, set) };
268
+ return null;
269
+ };
270
+
271
+ // ---- the rows describing the comparison ----
272
+ const compareRows = (c, i) => {
273
+ const m = c.metric;
274
+ if (isBool(m)) {
275
+ const [yes, no] = BOOL_LABEL[m];
276
+ return [{ text: `Is: ${cyan(condYes(c) ? yes : no)}`, act: () => pickNamed('Fire when', [[1, yes], [0, no]], condYes(c) ? 1 : 0, (v) => editCond(i, (x) => { x.op = '=='; x.value = v; })) }];
277
+ }
278
+ if (NAMED_VALUE[m]) {
279
+ const nv = NAMED_VALUE[m];
280
+ const pairs = nv.options();
281
+ const isNot = c.op === '!=';
282
+ const rows = [{ text: `Compare: ${cyan(isNot ? nv.opWords[1] : nv.opWords[0])}`, act: () => pickNamed('Compare', [['==', nv.opWords[0]], ['!=', nv.opWords[1]]], isNot ? '!=' : '==', (op) => editCond(i, (x) => { x.op = op; })) }];
283
+ rows.push({ text: `Value: ${cyan(pairs.find(([v]) => v === c.value)?.[1] ?? String(c.value))}`, act: () => pickNamed(COND_METRIC_LABEL[m], pairs, c.value, (v) => editCond(i, (x) => { x.value = v; })) });
284
+ return rows;
285
+ }
286
+ return [
287
+ { text: `Compare: ${cyan(OP_LABEL[c.op] ?? c.op)}`, act: () => pickNamed('Compare', OPS.map((o) => [o, OP_LABEL[o]]), c.op, (op) => editCond(i, (x) => { x.op = op; })) },
288
+ { text: `Value: ${cyan(`${c.value}${VALUE_UNIT[m] ? ` ${VALUE_UNIT[m]}` : ''}`)}`, act: () => numberPrompt(app, `Value${VALUE_UNIT[m] ? ` (${VALUE_UNIT[m]})` : ''}`, c.value, (n) => editCond(i, (x) => { x.value = n; })) },
289
+ ];
290
+ };
291
+
292
+ // ---- the ally filter rows ----
293
+ const allyRows = (c, i) => {
294
+ const fl = c.ally ?? {};
295
+ const who = fl.role ? `role:${fl.role}` : fl.notRole ? `not:${fl.notRole}` : 'any';
296
+ const seats = [['player', app.g.state.char.name || 'You'], ...(app.g.state.squad ?? []).map((m, k) => [allySideAt(k), m.name])];
297
+ const setAlly = (fn) => editCond(i, (x) => {
298
+ const f = { ...(x.ally ?? {}) }; fn(f);
299
+ if (f.role == null && f.notRole == null && f.classId == null && !f.unit) delete x.ally; else x.ally = f;
300
+ });
301
+ return [
302
+ { text: ` Which ally: ${cyan(ALLY_WHO.find(([v]) => v === who)?.[1])}`, act: () => pickNamed('Which ally', ALLY_WHO, who, (v) => setAlly((f) => { delete f.role; delete f.notRole; const [k, r] = v.split(':'); if (k === 'role') f.role = r; else if (k === 'not') f.notRole = r; })) },
303
+ { text: ` Only this class: ${cyan(fl.classId != null ? CLASS_NAMES[fl.classId] : 'any class')}`, act: () => pickNamed('Only this class', [[null, 'any class'], ...PLAYER_CLASS_IDS.map((id) => [id, CLASS_NAMES[id]])], fl.classId ?? null, (v) => setAlly((f) => { if (v == null) delete f.classId; else f.classId = v; })) },
304
+ { text: ` One exact ally: ${cyan(fl.unit ? (seats.find(([s]) => s === fl.unit)?.[1] ?? fl.unit) : 'anyone')}`, act: () => pickNamed('One exact ally', [[null, 'anyone'], ...seats], fl.unit ?? null, (v) => setAlly((f) => { if (v == null) delete f.unit; else f.unit = v; })) },
305
+ ];
306
+ };
307
+
308
+ // ---- one condition's menu ----
309
+ const openCond = (i) => {
310
+ liveMenu(app, 'cond', `Condition ${i + 1}`, () => {
311
+ const r = ruleNow(); const c = r?.conditions[i];
312
+ if (!c) return [{ text: dim('This condition is gone. Esc to go back.'), header: true }];
313
+ const rows = [{ text: bold(condSentence(c, names)), header: true }, { text: '', header: true }];
314
+ rows.push({ text: `Check: ${cyan(COND_METRIC_LABEL[c.metric] ?? c.metric)}`, detail: () => [bold('What to check'), '', 'The whole catalog: health and power, auras and stacks, casting, crowd control, counts of enemies and allies, distances, battleground and raid state.'], act: () => pickMetric(c.metric, (m) => editCond(i, (x) => applyMetric(x, m, ruleSpellId()))) });
315
+ if (!SUBJECTLESS_METRICS.has(c.metric)) {
316
+ const subjects = ALLY_SUBJECT_METRICS.has(c.metric) || c.subject === 'ally' ? [...SUBJECTS, 'ally'] : SUBJECTS;
317
+ rows.push({ text: `Who: ${cyan(SUBJECT_LABEL[c.subject] ?? c.subject)}`, act: () => pickNamed('Who', subjects.map((s) => [s, SUBJECT_LABEL[s]]), c.subject, (s) => editCond(i, (x) => { x.subject = s; if (s !== 'ally') delete x.ally; })) });
318
+ if (c.subject === 'ally') rows.push(...allyRows(c, i));
319
+ }
320
+ const ref = refRow(c, i);
321
+ if (ref) rows.push(ref);
322
+ rows.push(...compareRows(c, i));
323
+ if (i > 0) rows.push({ text: `Joins the one above with: ${cyan(c.join === 'or' ? 'or' : 'and')}`, detail: () => [bold('and / or'), '', '"and" means both must hold. "or" starts a new group, so "A and B or C" fires on (A and B) or (C).'], act: () => editCond(i, (x) => { if (x.join === 'or') delete x.join; else x.join = 'or'; }) });
324
+ rows.push({ text: '', header: true });
325
+ if (r.conditions.length > 1) {
326
+ if (i > 0) rows.push({ text: 'Move up', act: () => { edit((rr) => { [rr.conditions[i - 1], rr.conditions[i]] = [rr.conditions[i], rr.conditions[i - 1]]; }); app.pop(); openCond(i - 1); } });
327
+ if (i < r.conditions.length - 1) rows.push({ text: 'Move down', act: () => { edit((rr) => { [rr.conditions[i + 1], rr.conditions[i]] = [rr.conditions[i], rr.conditions[i + 1]]; }); app.pop(); openCond(i + 1); } });
328
+ }
329
+ rows.push({ text: red('Remove this condition'), act: () => { edit((rr) => { rr.conditions.splice(i, 1); }); app.pop(); } });
330
+ return rows;
331
+ });
332
+ };
333
+
334
+ // ---- the rule's list of conditions ----
335
+ const addCondition = () => pickMetric(undefined, (m) => {
336
+ let at = 0;
337
+ edit((r) => {
338
+ const c = { subject: 'self', metric: 'healthPct', op: '<', value: 50 };
339
+ applyMetric(c, m, ruleSpellId());
340
+ r.conditions.push(c);
341
+ at = r.conditions.length - 1;
342
+ });
343
+ openCond(at);
344
+ });
345
+ const quickPresets = () => {
346
+ const sp = ruleSpell();
347
+ const opts = [{ label: 'Always (no conditions)', value: [] }, ...presetLists(sp, char()).map((p) => ({ label: p.label, detail: () => [bold(p.label), '', conditionsSentence(p.conds, names)], value: p.conds }))];
348
+ const spellName = sp?.name ?? 'this spell';
349
+ opts.push(
350
+ { label: 'My health is below …%', value: 'hp', prompt: { metric: 'healthPct', subject: 'self', op: '<' } },
351
+ { label: 'My mana/power is above …%', value: 'pw', prompt: { metric: 'powerPct', subject: 'self', op: '>' } },
352
+ { label: "The enemy's health is below …%", value: 'ehp', prompt: { metric: 'healthPct', subject: 'target', op: '<' } },
353
+ { label: "The enemy's health is above …%", value: 'ehp2', prompt: { metric: 'healthPct', subject: 'target', op: '>' } },
354
+ ...(sp ? [
355
+ { label: `The enemy does not have ${spellName}`, value: [cond('target', 'debuffPresent', '==', 0, sp.id)] },
356
+ { label: `I do not have ${spellName}`, value: [cond('self', 'buffPresent', '==', 0, sp.id)] },
357
+ { label: `${spellName} has under 3 s left on the enemy`, value: [cond('target', 'debuffRemainingMs', '<', 3000, sp.id)] },
358
+ ] : []),
359
+ { label: 'The enemy is casting', value: [cond('target', 'castingPresent', '==', 1)] },
360
+ { label: 'More than one living enemy', value: [cond('self', 'enemyCount', '>', 1)] },
361
+ { label: 'I am in melee range', value: [cond('self', 'inMelee', '==', 1)] },
362
+ { label: 'I am at range', value: [cond('self', 'inMelee', '==', 0)] },
363
+ );
364
+ const apply = (conds) => edit((r) => { r.conditions = structuredClone(conds); });
365
+ app.pick('Replace the conditions with…', opts.map((o) => ({ label: o.label, detail: o.detail, value: o })), (o) => {
366
+ if (Array.isArray(o.value)) { apply(o.value); return; }
367
+ app.prompt('Percent: ', (txt) => {
368
+ const n = Number(txt);
369
+ if (!(n >= 0 && n <= 100)) { app.flashMsg('Enter a number from 0 to 100.'); return; }
370
+ apply([{ ...o.prompt, value: n }]);
371
+ app.afterAction();
372
+ });
373
+ });
374
+ };
375
+
376
+ liveMenu(app, 'conds', 'When it fires', () => {
377
+ const r = ruleNow();
378
+ if (!r) return [{ text: dim('This rule is gone. Esc to go back.'), header: true }];
379
+ const rows = [{ text: bold(ruleSentence(r, names)), header: true }, { text: dim(r.conditions.length ? `Fires only when ${conditionsSentence(r.conditions, names)}.` : 'No conditions: fires whenever it is the first usable row.'), header: true }, { text: '', header: true }];
380
+ r.conditions.forEach((c, i) => rows.push({
381
+ text: `${i === 0 ? dim('when ') : c.join === 'or' ? yellow('or ') : dim('and ')}${condSentence(c, names)}`,
382
+ detail: () => [bold(condSentence(c, names)), '', 'Enter to change what it checks, who it is about, the comparison, or remove it.'],
383
+ act: () => openCond(i),
384
+ }));
385
+ rows.push({ text: green('+ Add a condition'), detail: () => [bold('Add a condition'), '', 'Pick from the whole catalog, then set who it is about and the comparison.'], act: addCondition });
386
+ rows.push({ text: 'Quick presets…', detail: () => [bold('Quick presets'), '', 'Replace the list with a recommended set: the smart default for this spell, keep-up, on cast, low health, packs.'], act: quickPresets });
387
+ if (r.conditions.length) rows.push({ text: 'Clear all (always)', act: () => edit((rr) => { rr.conditions = []; }) });
388
+ return rows;
389
+ });
390
+ }
@@ -3,6 +3,7 @@
3
3
  // beside them in backups/. A delete or a restore always leaves a backup of what it replaced.
4
4
  import { bold, dim, green, red, yellow, cyan } from './text.mjs';
5
5
  import { className } from '../domain/core/ids.ts';
6
+ import { paperdollArt } from './paperdoll.mjs';
6
7
 
7
8
  const when = (ms) => new Date(ms).toLocaleString();
8
9
 
@@ -18,9 +19,31 @@ export async function savePicker(app, { onPlay, onNew }) {
18
19
  // two processes on one save would overwrite each other's progress on every action.
19
20
  const busy = (m) => repo.lockedBy?.(m.slot) ?? null;
20
21
  const refuse = (m, pid) => { app.say(red(`${m.name} is open in another terminal (pid ${pid}). Close it there first.`)); reopen(); }; // Enter popped the picker; put it back
22
+ // Each hero stands in their gear under their line: the saves are read once here for the character they hold.
23
+ const chars = new Map();
24
+ for (const m of saves) { try { chars.set(m.slot, (await repo.load(m.slot))?.state?.char ?? null); } catch { /* unreadable: no sprite */ } }
25
+ // A hero's first drawing unpacks their body's art and can take a moment, so paging past them draws only
26
+ // the text: the art waits until the cursor rests on them. One drawn once comes back at once.
27
+ const drawn = new Set();
28
+ let settle = null;
29
+ const restingOn = (m) => {
30
+ if (drawn.has(m.slot)) return true;
31
+ clearTimeout(settle);
32
+ settle = setTimeout(() => {
33
+ if (app.top() !== view || options[view.cursor]?.value.meta?.slot !== m.slot) return;
34
+ drawn.add(m.slot); app.render();
35
+ }, 250);
36
+ return false;
37
+ };
21
38
  const options = saves.map((m) => ({
22
39
  label: `${bold(m.name)} ${dim(`L${m.level} ${className(m.cls)}`)}${busy(m) ? ` ${yellow('open in another terminal')}` : ''}`,
23
- detail: [bold(m.name), `Level ${m.level} ${className(m.cls)}`, dim(`last played ${when(m.updatedAt)}`), '', dim('Enter: play m: back up, restore, export, delete')],
40
+ detail: () => {
41
+ const text = [bold(m.name), `Level ${m.level} ${className(m.cls)}`, dim(`last played ${when(m.updatedAt)}`), '', dim('Enter: play m: back up, restore, export, delete')];
42
+ if (!restingOn(m)) return text;
43
+ const ch = chars.get(m.slot), L = app.layout();
44
+ const art = ch ? paperdollArt(ch, L.detailW - 2, L.mainH - text.length - 1) : null;
45
+ return art ? [...text, '', ...art] : text;
46
+ },
24
47
  value: { kind: 'play', meta: m },
25
48
  }));
26
49
  options.push({ label: green('New character'), value: { kind: 'new' } });