azeroth-shell 0.1.2 → 0.2.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 (63) hide show
  1. package/README.md +326 -82
  2. package/bin/azsh.mjs +21 -19
  3. package/package.json +9 -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 +135 -0
  10. package/src/cli/iconGrid.mjs +100 -0
  11. package/src/cli/modelArt.mjs +295 -0
  12. package/src/cli/paperdoll.mjs +76 -0
  13. package/src/cli/perfCheck.mjs +56 -0
  14. package/src/cli/repl.mjs +32 -7
  15. package/src/cli/rotationText.mjs +6 -6
  16. package/src/cli/sixel.mjs +307 -0
  17. package/src/cli/term.mjs +106 -12
  18. package/src/cli/text.mjs +5 -3
  19. package/src/cli/tui.mjs +231 -95
  20. package/src/cli/tuiAutopilot.mjs +347 -0
  21. package/src/cli/tuiBg.mjs +370 -41
  22. package/src/cli/tuiCheck.mjs +362 -27
  23. package/src/cli/tuiCity.mjs +118 -13
  24. package/src/cli/tuiCompanions.mjs +25 -15
  25. package/src/cli/tuiCreate.mjs +223 -0
  26. package/src/cli/tuiDungeon.mjs +1 -1
  27. package/src/cli/tuiFight.mjs +423 -86
  28. package/src/cli/tuiFightPanes.mjs +27 -8
  29. package/src/cli/tuiFloor.mjs +1 -1
  30. package/src/cli/tuiGear.mjs +10 -6
  31. package/src/cli/tuiLoot.mjs +1 -0
  32. package/src/cli/tuiPaperdoll.mjs +107 -0
  33. package/src/cli/tuiPartyStage.mjs +177 -0
  34. package/src/cli/tuiProfessions.mjs +4 -2
  35. package/src/cli/tuiRotationCond.mjs +390 -0
  36. package/src/cli/tuiSaves.mjs +10 -1
  37. package/src/cli/tuiSearch.mjs +75 -22
  38. package/src/cli/tuiStage.mjs +304 -0
  39. package/src/cli/tuiTalentTree.mjs +124 -0
  40. package/src/cli/tuiViews.mjs +284 -110
  41. package/src/cli/tuiWorld.mjs +2 -2
  42. package/src/data/gear-art.bin.br +0 -0
  43. package/src/data/hd-art.json +114 -0
  44. package/src/data/icon-art.bin.gz +0 -0
  45. package/src/data/model-art.bin.gz +0 -0
  46. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  47. package/src/domain/bg/match.ts +85 -24
  48. package/src/domain/bg/strategies.ts +144 -29
  49. package/src/domain/bg/types.ts +2 -0
  50. package/src/domain/bg/wsg.ts +35 -20
  51. package/src/domain/quests/doQuest.ts +24 -6
  52. package/src/domain/rotation/build.ts +3031 -3031
  53. package/src/domain/rotation/classes/paladin.ts +16 -11
  54. package/src/domain/rotation/evaluate.ts +3 -0
  55. package/src/engine/combat/applySpell.ts +20 -9
  56. package/src/engine/combat/applySpellLate.ts +16 -12
  57. package/src/engine/combat/areaAura.ts +284 -283
  58. package/src/engine/combat/fighterFactory.ts +1 -1
  59. package/src/engine/combat/pickAction.ts +39 -1
  60. package/src/engine/combat/procsDamage.ts +31 -1
  61. package/src/engine/combat/sessionChase.ts +7 -1
  62. package/src/engine/combat/sessionMotionKit.ts +40 -2
  63. 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,17 @@ 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 */ } }
21
25
  const options = saves.map((m) => ({
22
26
  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')],
27
+ detail: () => {
28
+ 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')];
29
+ const ch = chars.get(m.slot), L = app.layout();
30
+ const art = ch ? paperdollArt(ch, L.detailW - 2, L.mainH - text.length - 1) : null;
31
+ return art ? [...text, '', ...art] : text;
32
+ },
24
33
  value: { kind: 'play', meta: m },
25
34
  }));
26
35
  options.push({ label: green('New character'), value: { kind: 'new' } });
@@ -1,4 +1,5 @@
1
- // World search (s on the World tab): "where do I find X?" Type a name and every place that answers is a
1
+ // World search (s on the World tab): "where do I find X?" It starts on the zone you stand in (its vendors,
2
+ // trainers, wares, nodes, creatures); a in the window widens it to the whole world. Every place that answers is a
2
3
  // row: the vendors that sell an item and the zone each stands in, the creatures that drop it, the node that
3
4
  // yields it, who crafts it; an NPC by name and where it is; a dungeon and its entrance; a gather node and the
4
5
  // zones it grows in; a zone, a quest, an object. Enter acts on the place: open it when you are already there
@@ -63,6 +64,51 @@ function searchPool(ds) {
63
64
  return [...ix.all.filter((r) => WORLD_KINDS.has(r.kind) && !(r.kind === 'object' && nodeNames.has(r.lower))), ...pi.nodeRows];
64
65
  }
65
66
 
67
+ const zoneCache = new WeakMap();
68
+
69
+ /**
70
+ * What one zone holds: the search pool cut to its NPCs, nodes, objects, dungeon doors and quest givers,
71
+ * plus every item its vendors sell, its nodes yield and its creatures and objects drop, each with the
72
+ * sources here (`items`: item entry → sources, the itemSources shape). Keyed on the wiki index so a
73
+ * rebuilt index (catalogs arriving) rebuilds this too.
74
+ */
75
+ export function zoneSearchIndex(ds, zoneId) {
76
+ const ix = wikiIndex(ds);
77
+ let byZone = zoneCache.get(ix);
78
+ if (!byZone) zoneCache.set(ix, (byZone = new Map()));
79
+ const hit = byZone.get(zoneId);
80
+ if (hit) return hit;
81
+ const z = ds.zone(zoneId), pi = placeIndex(ds);
82
+ const nodeNames = new Set(pi.nodeRows.map((r) => r.lower));
83
+ const items = new Map();
84
+ const add = (item, src) => {
85
+ const list = items.get(item);
86
+ if (!list) { items.set(item, [src]); return; }
87
+ const same = list.find((s) => s.kind === src.kind && s.ref === src.ref);
88
+ if (!same) list.push(src);
89
+ else if ((src.chance ?? 0) > (same.chance ?? 0)) Object.assign(same, src);
90
+ };
91
+ const loot = (key, holder) => { for (const row of ds.lootTable(key)) add(row.item, { ...holder, chance: Math.abs(row.chance), ...(row.chance < 0 || row.questRequired ? { questOnly: true } : {}) }); };
92
+ for (const e of z?.vendors ?? []) for (const item of ds.vendor(e)?.items ?? []) add(item, { kind: 'vendor', ref: e });
93
+ for (const id of z?.nodes ?? []) for (const y of ds.gatherNode(id)?.yield ?? []) add(y.item, { kind: 'gather', ref: id, zone: zoneId, chance: y.chance });
94
+ for (const e of z?.huntable ?? []) { const c = ds.creature(e); if (c?.lootId) loot(c.lootId, { kind: 'drop', ref: e }); }
95
+ for (const ge of z?.objects ?? []) { const go = ds.gameObject(ge); if (go?.lootId && !nodeNames.has(go.name.toLowerCase())) loot(`go:${go.lootId}`, { kind: 'object', ref: ge }); }
96
+ const isHere = (r) => {
97
+ switch (r.kind) {
98
+ case 'item': return items.has(r.id);
99
+ case 'npc': return (pi.npcZones.get(r.id) ?? []).includes(zoneId);
100
+ case 'node': return r.zones.includes(zoneId);
101
+ case 'object': return (pi.objectZones.get(r.id) ?? []).includes(zoneId);
102
+ case 'dungeon': return ds.instance(r.id)?.portalZone === zoneId;
103
+ case 'quest': return (ds.quest(r.id)?.starterZones ?? []).includes(zoneId);
104
+ default: return false;
105
+ }
106
+ };
107
+ const built = { pool: searchPool(ds).filter(isHere), items };
108
+ byZone.set(zoneId, built);
109
+ return built;
110
+ }
111
+
66
112
  /**
67
113
  * wikiSearch's buckets (exact, prefix, substring, then subsequence only when nothing contains the words),
68
114
  * with one change for typos: the loose bucket ranks by how little longer the name is than what was typed,
@@ -155,10 +201,10 @@ function npcRow(app, entry, { indent = '', note = '' } = {}) {
155
201
  const where = dungeon ? `${cyan(dungeon.name)}${dungeon.portalZone ? dim(`, entrance in ${zoneName(app, dungeon.portalZone)}`) : ''}` : zonesLabel(app, zones);
156
202
  const paint = hostile && c ? conPaint(ch.level, c.level) : (s) => s;
157
203
  const here = () => {
204
+ if (inn) { const i = (app.zone()?.innkeepers ?? []).find((x) => x.entry === entry); if (i) { app.push(innDialog(app, i)); return; } }
158
205
  if (v) { app.push(vendorDialog(app, v)); return; }
159
206
  if (pt) { app.push(profTrainerDialog(app, pt)); return; }
160
207
  if (tr) { app.push(trainerDialog(app, tr)); return; }
161
- if (inn) { const i = (app.zone()?.innkeepers ?? []).find((x) => x.entry === entry); if (i) app.push(innDialog(app, i)); return; }
162
208
  if (hostile && app.zone()?.huntable.includes(entry)) { startFight(app, entry); return; }
163
209
  app.flashMsg(`${name} is around here somewhere.`);
164
210
  };
@@ -232,7 +278,7 @@ function questRow(app, q, { indent = '', note = '' } = {}) {
232
278
  const zones = q.starterZones ?? [];
233
279
  const qs = app.g.state.questlog.quests.find((s) => s.entry === q.entry);
234
280
  return {
235
- text: `${indent}${yellow('!')} ${q.title} ${dim(`Quest L${q.questLevel}`)}${note} ${dim('from')} ${q.starterName || '?'}${zones.length ? `, ${zonesLabel(app, zones)}` : ''}`,
281
+ text: `${indent}${yellow('!')} ${conPaint(app.ch.level, q.questLevel)(q.title)} ${dim(`Quest L${q.questLevel}`)}${note} ${dim('from')} ${q.starterName || '?'}${zones.length ? `, ${zonesLabel(app, zones)}` : ''}`,
236
282
  detail: () => [...questLines(app, q, qs), '', cyan('Starts in'), ...(zones.length ? zones.map((z) => ` ${zoneName(app, z)}`) : [dim(' unknown')]), '', travelHint(app, zones)],
237
283
  act: () => goToOne(app, zones, `Where ${q.title} starts`, () => app.push(questDialog(app, q, qs ? 'log' : 'offer'))),
238
284
  };
@@ -249,12 +295,12 @@ function recipeRow(app, r, { indent = '', note = '' } = {}) {
249
295
  };
250
296
  }
251
297
 
252
- /** An item hit: the item line, then every place that gives it, best first. */
253
- function itemRows(app, entry) {
298
+ /** An item hit: the item line, then every place that gives it, best first. `only` limits it to those sources (a zone search). */
299
+ function itemRows(app, entry, only) {
254
300
  const ds = app.ds, it = ds.item(entry);
255
301
  if (!it) return [];
256
302
  const rows = [{ text: `${qualityPaint(it.quality)(bold(it.name))} ${dim(`Item${it.typeName ? ` ${it.typeName}` : ''}${it.itemLevel ? ` ilvl ${it.itemLevel}` : ''}`)}`, detail: () => [...itemLines(app, it), '', dim('Enter: the wiki page, every source')], act: () => app.push(wikiPage(app, 'item', entry)) }];
257
- const { sources, more } = wikiSourceIndexSync(ds).itemSources(entry);
303
+ const { sources, more } = only ? { sources: only, more: 0 } : wikiSourceIndexSync(ds).itemSources(entry);
258
304
  const here = app.ch.zoneId;
259
305
  const pi = placeIndex(ds);
260
306
  const nodeNames = new Set(pi.nodeRows.map((r) => r.lower));
@@ -312,11 +358,11 @@ function itemRows(app, entry) {
312
358
  return rows;
313
359
  }
314
360
 
315
- /** The rows for one search hit. */
316
- function hitRows(app, r) {
361
+ /** The rows for one search hit. `zoneItems` (a zone search) keeps an item to its sources in that zone. */
362
+ function hitRows(app, r, zoneItems) {
317
363
  const ds = app.ds;
318
364
  switch (r.kind) {
319
- case 'item': return itemRows(app, r.id);
365
+ case 'item': return itemRows(app, r.id, zoneItems?.get(r.id));
320
366
  case 'npc': return [npcRow(app, r.id)];
321
367
  case 'zone': { const z = ds.zone(r.id); return z ? [zoneRow(app, z)] : []; }
322
368
  case 'dungeon': { const d = ds.instance(r.id); return d ? [dungeonRow(app, d)] : []; }
@@ -330,24 +376,31 @@ function hitRows(app, r) {
330
376
 
331
377
  // ---- the dialog ----------------------------------------------------------------------------------------------
332
378
 
333
- /** The search window. `query` starts it filled in. */
334
- export function worldSearchDialog(app, query = '') {
335
- const s = { query };
379
+ /** The search window. `query` starts it filled in; `all` searches the whole world instead of the zone you stand in. */
380
+ export function worldSearchDialog(app, query = '', all = false) {
381
+ const s = { query, all };
382
+ const top = () => { view.cursor = 0; view.scroll = 0; };
336
383
  const view = {
337
- id: 'world-search', title: 'Search the world', hint: 'Enter: go there / open',
384
+ id: 'world-search', title: 'Search', hint: 'Enter: go there / open',
338
385
  keys: {
339
- s: { hint: 'search', fn: (a) => a.prompt('Find in the world: ', (t) => { s.query = t; view.cursor = 0; view.scroll = 0; a.render(); }, s.query) },
386
+ s: { hint: 'search', fn: (a) => a.prompt(s.all ? 'Find in the world: ' : `Find in ${zoneName(a, a.ch.zoneId)}: `, (t) => { s.query = t; top(); a.render(); }, s.query) },
387
+ a: { hint: 'this zone / all', fn: (a) => { s.all = !s.all; top(); a.flashMsg(s.all ? 'Searching the whole world.' : `Searching ${zoneName(a, a.ch.zoneId)} only.`); } },
340
388
  w: { hint: 'wiki', fn: (a, row) => { if (row?.data?.entry) a.push(wikiPage(a, 'npc', row.data.entry)); else a.flashMsg('Open the wiki with /.'); } },
341
389
  },
342
390
  rows(a) {
343
- const rows = [H(s.query ? `Find: ${yellow(s.query)}` : dim('Press s and type a name: an item, a vendor, a creature, a dungeon, an ore or herb, a zone...')), H('')];
391
+ const zone = zoneName(a, a.ch.zoneId);
392
+ view.title = s.all ? 'Search the world' : `Search ${zone}`;
393
+ const scope = s.all ? dim(' (whole world, a: this zone only)') : dim(` (in ${zone}, a: search everywhere)`);
394
+ const rows = [H(s.query ? `Find: ${yellow(s.query)}${scope}` : dim(`Press s and type a name: ${s.all ? 'an item, a vendor, a creature, a dungeon, an ore or herb, a zone...' : `a vendor, a trainer, an item sold or dropped in ${zone}...`}`)), H('')];
344
395
  if (!s.query.trim()) {
345
- rows.push(H(dim(' "mining pick" lists who sells one and where they stand; "copper vein" the zones it is mined in;')), H(dim(' "deadmines" the entrance; "hogger" where he prowls. Enter travels there, or opens it when you are there.')));
396
+ if (s.all) rows.push(H(dim(' "mining pick" lists who sells one and where they stand; "copper vein" the zones it is mined in;')), H(dim(' "deadmines" the entrance; "hogger" where he prowls. Enter travels there, or opens it when you are there.')));
397
+ else rows.push(H(dim(` Only what is in ${zone}: its people, its wares, its nodes and creatures. Enter opens it.`)), H(dim(' a searches the whole world instead.')));
346
398
  return rows;
347
399
  }
348
- const hits = worldSearch(searchPool(a.ds), wikiTokens(s.query));
349
- if (!hits.length) { rows.push(H(dim(' Nothing by that name. The wiki (/) searches spells and talents too.'))); return rows; }
350
- for (const r of hits.slice(0, MAX_HITS)) rows.push(...hitRows(a, r));
400
+ const zi = s.all ? null : zoneSearchIndex(a.ds, a.ch.zoneId);
401
+ const hits = worldSearch(zi ? zi.pool : searchPool(a.ds), wikiTokens(s.query));
402
+ if (!hits.length) { rows.push(H(dim(s.all ? ' Nothing by that name. The wiki (/) searches spells and talents too.' : ` Nothing by that name in ${zone}. Press a to search the whole world.`))); return rows; }
403
+ for (const r of hits.slice(0, MAX_HITS)) rows.push(...hitRows(a, r, zi?.items));
351
404
  if (hits.length > MAX_HITS) rows.push(H(''), H(dim(` ${hits.length - MAX_HITS} more matches. Narrow the search.`)));
352
405
  return rows;
353
406
  },
@@ -355,9 +408,9 @@ export function worldSearchDialog(app, query = '') {
355
408
  return view;
356
409
  }
357
410
 
358
- /** s on the World tab: open the search and start typing. */
359
- export function openWorldSearch(app, query = '') {
360
- const v = worldSearchDialog(app, query);
411
+ /** s on the World tab: open the search on the zone you stand in and start typing (`all`: the whole world). */
412
+ export function openWorldSearch(app, query = '', all = false) {
413
+ const v = worldSearchDialog(app, query, all);
361
414
  app.push(v);
362
415
  if (!query) v.keys.s.fn(app);
363
416
  }