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.
- package/README.md +326 -82
- package/bin/azsh.mjs +21 -19
- package/package.json +9 -1
- package/src/autopilot/plan.ts +8 -11
- package/src/autopilot/view.ts +12 -11
- package/src/cli/combatLogFormat.ts +22 -0
- package/src/cli/gzLoader.mjs +47 -6
- package/src/cli/hdArt.mjs +81 -0
- package/src/cli/iconArt.mjs +135 -0
- package/src/cli/iconGrid.mjs +100 -0
- package/src/cli/modelArt.mjs +295 -0
- package/src/cli/paperdoll.mjs +76 -0
- package/src/cli/perfCheck.mjs +56 -0
- package/src/cli/repl.mjs +32 -7
- package/src/cli/rotationText.mjs +6 -6
- package/src/cli/sixel.mjs +307 -0
- package/src/cli/term.mjs +106 -12
- package/src/cli/text.mjs +5 -3
- package/src/cli/tui.mjs +231 -95
- package/src/cli/tuiAutopilot.mjs +347 -0
- package/src/cli/tuiBg.mjs +370 -41
- package/src/cli/tuiCheck.mjs +362 -27
- package/src/cli/tuiCity.mjs +118 -13
- package/src/cli/tuiCompanions.mjs +25 -15
- package/src/cli/tuiCreate.mjs +223 -0
- package/src/cli/tuiDungeon.mjs +1 -1
- package/src/cli/tuiFight.mjs +423 -86
- package/src/cli/tuiFightPanes.mjs +27 -8
- package/src/cli/tuiFloor.mjs +1 -1
- package/src/cli/tuiGear.mjs +10 -6
- package/src/cli/tuiLoot.mjs +1 -0
- package/src/cli/tuiPaperdoll.mjs +107 -0
- package/src/cli/tuiPartyStage.mjs +177 -0
- package/src/cli/tuiProfessions.mjs +4 -2
- package/src/cli/tuiRotationCond.mjs +390 -0
- package/src/cli/tuiSaves.mjs +10 -1
- package/src/cli/tuiSearch.mjs +75 -22
- package/src/cli/tuiStage.mjs +304 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiViews.mjs +284 -110
- package/src/cli/tuiWorld.mjs +2 -2
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +114 -0
- package/src/data/icon-art.bin.gz +0 -0
- package/src/data/model-art.bin.gz +0 -0
- package/src/data/rotations/pve/paladin/retribution.json +2 -2
- package/src/domain/bg/match.ts +85 -24
- package/src/domain/bg/strategies.ts +144 -29
- package/src/domain/bg/types.ts +2 -0
- package/src/domain/bg/wsg.ts +35 -20
- package/src/domain/quests/doQuest.ts +24 -6
- package/src/domain/rotation/build.ts +3031 -3031
- package/src/domain/rotation/classes/paladin.ts +16 -11
- package/src/domain/rotation/evaluate.ts +3 -0
- package/src/engine/combat/applySpell.ts +20 -9
- package/src/engine/combat/applySpellLate.ts +16 -12
- package/src/engine/combat/areaAura.ts +284 -283
- package/src/engine/combat/fighterFactory.ts +1 -1
- package/src/engine/combat/pickAction.ts +39 -1
- package/src/engine/combat/procsDamage.ts +31 -1
- package/src/engine/combat/sessionChase.ts +7 -1
- package/src/engine/combat/sessionMotionKit.ts +40 -2
- package/src/quests/quests.ts +60 -0
package/src/cli/tuiViews.mjs
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
// The tabs and dialogs of the windowed game. A view is `{ id, title, hint, rows(app), keys, detail }`; a row is
|
|
2
2
|
// `{ text, header?, act?, detail?, keys? }`. Views read the Game facade and DataStore; every change is a facade call.
|
|
3
|
-
import { bold, dim, cyan, yellow, green, red, gray, magenta, brightBlue, brightYellow, qualityPaint, QUALITY, questText, wrapLines, fit, secondsText, plural } from './text.mjs';
|
|
3
|
+
import { bold, dim, cyan, yellow, green, red, gray, magenta, brightBlue, brightYellow, qualityPaint, QUALITY, questText, wrapLines, fit, secondsText, plural, vis } from './text.mjs';
|
|
4
4
|
import { ruleSentence, conditionsSentence, condSentence, PHASE_LABEL } from './rotationText.mjs';
|
|
5
5
|
import { startFight, startAutopilot } from './tuiFight.mjs';
|
|
6
|
-
import { className } from '../domain/core/ids.ts';
|
|
6
|
+
import { className, raceName } from '../domain/core/ids.ts';
|
|
7
|
+
import { nativeDisplayId, wornDisplay } from '../domain/character/displayForm.ts';
|
|
7
8
|
import { moneyText } from '../domain/economy/money.ts';
|
|
8
9
|
import { maxHealth, maxPower } from '../domain/character/character.ts';
|
|
9
10
|
import { effectiveStats, effectiveArmor, attackPower, powerType } from '../domain/character/characterStats.ts';
|
|
10
11
|
import { critChanceDisplay, dodgeChanceDisplay, hitChance, weaponSkillIdForHand, skillBonusFor } from '../domain/character/characterRatings.ts';
|
|
11
|
-
import { COMBAT_SKILL, isLevelRangeCombatSkill, combatSkillDisplayName } from '../domain/character/combatSkills.ts';
|
|
12
|
+
import { COMBAT_SKILL, isLevelRangeCombatSkill, combatSkillDisplayName, isSpellFitByClassAndRace } from '../domain/character/combatSkills.ts';
|
|
12
13
|
import { skillMaxForLevel } from '../engine/combat/mitigation.ts';
|
|
13
14
|
import { knownUsableSpells } from '../domain/character/characterSpells.ts';
|
|
14
15
|
import { bagCapacity, EQUIP_SLOTS, BAG_SLOTS, SLOT_LABEL, itemSlotTargets, DUAL_WIELD_SPELL_ID } from '../domain/items/equipment.ts';
|
|
15
16
|
import { equippedEntry } from '../domain/items/itemInstance.ts';
|
|
16
|
-
import {
|
|
17
|
+
import { stackBoundToHolder } from '../domain/raid/partyVault.ts';
|
|
18
|
+
import { knows, learnGrantIds } from '../domain/character/spellbook.ts';
|
|
17
19
|
import { availablePoints, canSpend, canRefund, spentInTree } from '../domain/character/talents.ts';
|
|
18
20
|
import { offeredQuests, chainFollowUpOffer, grayLogQuests } from '../quests/quests.ts';
|
|
19
21
|
import { isRepeatableQuest, questRepeatsAvailable } from '../domain/quests/questlog.ts';
|
|
@@ -27,8 +29,10 @@ import { squadJoinsPull } from './squad.mjs';
|
|
|
27
29
|
import { barsDialog, playerBarsOwner } from './tuiBars.mjs';
|
|
28
30
|
import { petWorldRows, petDialog, hasPetClass } from './tuiPet.mjs';
|
|
29
31
|
import { portalRows } from './tuiDungeon.mjs';
|
|
32
|
+
import { conditionEditor, defaultConditionsForSpell, defaultItemRule } from './tuiRotationCond.mjs';
|
|
33
|
+
import { allyIndexOfSide } from '../engine/core/events.ts';
|
|
30
34
|
import { bulkDisenchantDialog, learnAllDialog } from './tuiBulk.mjs';
|
|
31
|
-
import { gearExtraRows, durabilityText, repairOne, slotRepairLines, enchantLines } from './tuiGear.mjs';
|
|
35
|
+
import { gearExtraRows, durabilityText, repairOne, repairAll, slotRepairLines, enchantLines } from './tuiGear.mjs';
|
|
32
36
|
import { outfitterDialog } from './tuiOutfitter.mjs';
|
|
33
37
|
import { castWithPicker } from './tuiEnchantTarget.mjs';
|
|
34
38
|
import { settingsDialog } from './tuiSettings.mjs';
|
|
@@ -37,6 +41,12 @@ import { pickPocket, pickPocketTag, journeyRows, afterDeparture, ridingRows, obj
|
|
|
37
41
|
import { talentBuildsMenu, myTalentLink } from './tuiTalents.mjs';
|
|
38
42
|
import { simLabDialog } from './tuiSimLab.mjs';
|
|
39
43
|
import { autopilotSettingsDialog } from './tuiAutopilotSettings.mjs';
|
|
44
|
+
import { preflightCard } from './tuiAutopilot.mjs';
|
|
45
|
+
import { creatureArt } from './modelArt.mjs';
|
|
46
|
+
import { withIcon, iconsOn } from './iconArt.mjs';
|
|
47
|
+
import { paperdollGrid, paperdollPlan } from './tuiPaperdoll.mjs';
|
|
48
|
+
import { talentGrid, talentPlan, talentTone, talentIcon } from './tuiTalentTree.mjs';
|
|
49
|
+
import { paperdollArt } from './paperdoll.mjs';
|
|
40
50
|
import { reputationDialog } from './tuiRep.mjs';
|
|
41
51
|
import { honorDialog, honorRankLine } from './tuiHonor.mjs';
|
|
42
52
|
import { openWorldSearch } from './tuiSearch.mjs';
|
|
@@ -56,7 +66,24 @@ export function conPaint(playerLevel, level) {
|
|
|
56
66
|
|
|
57
67
|
// ---- detail panes --------------------------------------------------------------------------------
|
|
58
68
|
|
|
69
|
+
// A spell's tooltip text with the tokens the bake left in filled from its own effects: $v max target
|
|
70
|
+
// level, $q misc value (Resurrection's mana), $f damage multiplier (Ferocious Bite). A leading id reads
|
|
71
|
+
// another spell's effect ($3026q1). Anything else stays as written.
|
|
72
|
+
export function spellText(ds, sp) {
|
|
73
|
+
const text = sp?.description?.trim();
|
|
74
|
+
if (!text?.includes('$')) return text;
|
|
75
|
+
const fx = (id, i) => (id ? ds.spell(Number(id)) : sp)?.rawEffects?.[(Number(i) || 1) - 1];
|
|
76
|
+
return text
|
|
77
|
+
.replace(/\$v\b/g, (m) => sp.maxTargetLevel != null ? String(sp.maxTargetLevel) : m)
|
|
78
|
+
.replace(/\$(\d*)q(\d?)/g, (m, id, i) => fx(id, i)?.miscValue != null ? String(fx(id, i).miscValue) : m)
|
|
79
|
+
.replace(/\$(\d*)f(\d)/g, (m, id, i) => fx(id, i)?.dmgMultiplier != null ? String(fx(id, i).dmgMultiplier) : m);
|
|
80
|
+
}
|
|
81
|
+
|
|
59
82
|
export function itemLines(app, item, extra = []) {
|
|
83
|
+
return withIcon(item?.icon, itemLinesText(app, item, extra));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function itemLinesText(app, item, extra = []) {
|
|
60
87
|
if (!item) return ['Unknown item'];
|
|
61
88
|
const out = [qualityPaint(item.quality)(bold(item.name))];
|
|
62
89
|
const kind = [item.slot, item.typeName].filter(Boolean).join(' · ');
|
|
@@ -68,12 +95,37 @@ export function itemLines(app, item, extra = []) {
|
|
|
68
95
|
if (item.armor) out.push(`${item.armor} Armor`);
|
|
69
96
|
if (item.block) out.push(`${item.block} Block`);
|
|
70
97
|
for (const s of item.stats ?? []) if (s.value) out.push(`${s.value > 0 ? '+' : ''}${s.value} ${STAT_LABEL[s.stat] ?? s.stat}`);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
98
|
+
for (const id of new Set(item.equipSpells ?? [])) {
|
|
99
|
+
const text = spellText(app.ds, app.ds.spell(id));
|
|
100
|
+
if (text) out.push(green(`Equip: ${text}`));
|
|
101
|
+
}
|
|
102
|
+
// ITEM_SPELLTRIGGER_CHANCE_ON_HIT 2.
|
|
103
|
+
for (const p of item.procs ?? []) {
|
|
104
|
+
const text = p.trigger === 2 && spellText(app.ds, app.ds.spell(p.spell));
|
|
105
|
+
if (text) out.push(green(`Chance on hit: ${text}`));
|
|
106
|
+
}
|
|
107
|
+
// The use spell's own tooltip text carries the Well Fed rider; a few still hold unresolved $ tokens.
|
|
108
|
+
const useText = spellText(app.ds, app.ds.spell(item.useSpell ?? item.useInCombat?.spellId));
|
|
109
|
+
if (useText && !useText.includes('$')) out.push(green(`Use: ${useText}`));
|
|
110
|
+
else {
|
|
111
|
+
// Same tick count rest.ts heals for: floor(duration / tick).
|
|
112
|
+
if (item.consumable) out.push(green(`Use: Restores ${item.consumable.amountPerTick * Math.max(1, Math.floor(item.consumable.durationMs / item.consumable.tickMs))} ${item.consumable.effect === 'drink' ? 'mana' : 'health'} over ${Math.round(item.consumable.durationMs / 1000)} sec. Must remain seated.`));
|
|
113
|
+
if (item.useInCombat?.effect === 'restore_health') out.push(green(`Use: Restores ${item.useInCombat.min} to ${item.useInCombat.max} health.`));
|
|
114
|
+
if (item.useInCombat?.effect === 'restore_power') out.push(green(`Use: Restores ${item.useInCombat.min} to ${item.useInCombat.max} mana.`));
|
|
115
|
+
}
|
|
74
116
|
if (item.requiredLevel > 1) out.push(`Requires Level ${item.requiredLevel}`);
|
|
75
117
|
if (item.allowClass?.length) out.push(`Classes: ${item.allowClass.map(className).join(', ')}`);
|
|
76
118
|
if (item.bagSlots) out.push(`${item.bagSlots} Slot Bag`);
|
|
119
|
+
const set = item.setId && app.ds.itemSet(item.setId);
|
|
120
|
+
if (set) {
|
|
121
|
+
const worn = new Set(Object.values(app.ch?.equipped ?? {}).map(equippedEntry));
|
|
122
|
+
const have = set.items.filter((e) => worn.has(e)).length;
|
|
123
|
+
out.push('', yellow(`${set.name ?? 'Item set'} (${have}/${set.items.length})`));
|
|
124
|
+
for (const b of set.bonuses) {
|
|
125
|
+
const text = spellText(app.ds, app.ds.spell(b.spell));
|
|
126
|
+
if (text) out.push((have >= b.pieces ? green : gray)(`(${b.pieces}) Set: ${text}`));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
77
129
|
if (item.description) out.push(yellow(`"${item.description}"`));
|
|
78
130
|
if (item.sellPrice) out.push(dim(`Sells for ${moneyText(item.sellPrice)}`));
|
|
79
131
|
out.push(...extra);
|
|
@@ -81,6 +133,10 @@ export function itemLines(app, item, extra = []) {
|
|
|
81
133
|
}
|
|
82
134
|
|
|
83
135
|
export function spellLines(app, sp, extra = []) {
|
|
136
|
+
return withIcon(sp?.icon, spellLinesText(app, sp, extra));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function spellLinesText(app, sp, extra = []) {
|
|
84
140
|
if (!sp) return ['Unknown spell'];
|
|
85
141
|
const out = [bold(sp.name) + (sp.rank ? dim(` Rank ${sp.rank}`) : '')];
|
|
86
142
|
const bits = [];
|
|
@@ -90,7 +146,8 @@ export function spellLines(app, sp, extra = []) {
|
|
|
90
146
|
if (sp.rangeMax) bits.push(`${sp.rangeMax} yd range`);
|
|
91
147
|
out.push(dim(bits.join(' ')));
|
|
92
148
|
if (sp.reagents?.length) out.push(dim('Reagents: ' + sp.reagents.map((r) => `${app.itemLabel(r.item)}${r.count > 1 ? ` x${r.count}` : ''}`).join(', ')));
|
|
93
|
-
|
|
149
|
+
const text = spellText(app.ds, sp);
|
|
150
|
+
if (text) out.push(yellow(text)); // the pane wraps it, beside the icon while they share its rows
|
|
94
151
|
const cd = app.g.spellCooldownRemaining(sp.id);
|
|
95
152
|
if (cd > 0) out.push(red(`Cooldown: ${secondsText(cd)} left`));
|
|
96
153
|
out.push(...extra);
|
|
@@ -113,12 +170,25 @@ export function creatureLines(app, c) {
|
|
|
113
170
|
out.push(...wanted);
|
|
114
171
|
if ((app.g.state.squad ?? []).length) out.push(squadJoinsPull(app.g, app.ds, c.entry) ? cyan('Your companions join this fight.') : dim('You take this one alone (squad mode, m on Companions).'));
|
|
115
172
|
out.push('', dim('Enter: fight live f: auto-resolve'));
|
|
173
|
+
// The creature itself under the text, as large as the pane has room for (modelArt.mjs).
|
|
174
|
+
const L = app.layout?.();
|
|
175
|
+
const art = L && c.displayId ? creatureArt(c.displayId, L.detailW - 2, L.mainH - out.length - 1) : null;
|
|
176
|
+
if (art) out.push('', ...art);
|
|
116
177
|
return out;
|
|
117
178
|
}
|
|
118
179
|
|
|
180
|
+
/** A person in the world: the text, then the NPC's creature drawn under it as large as the pane has room for. */
|
|
181
|
+
export function npcLines(app, entry, text) {
|
|
182
|
+
const L = app.layout?.(), id = app.ds.creature(entry)?.displayId;
|
|
183
|
+
if (!L || !id) return text;
|
|
184
|
+
const w = L.detailW - 2, used = text.reduce((n, line) => n + (vis(line) > w ? wrapLines(line, w).length : 1), 0);
|
|
185
|
+
const art = creatureArt(id, w, L.mainH - used - 1);
|
|
186
|
+
return art ? [...text, '', ...art] : text;
|
|
187
|
+
}
|
|
188
|
+
|
|
119
189
|
export function questLines(app, q, qs) {
|
|
120
190
|
const ch = app.ch;
|
|
121
|
-
const out = [bold(q.title) + dim(` level ${q.questLevel}${q.minLevel > ch.level ? red(` requires ${q.minLevel}`) : ''}`)];
|
|
191
|
+
const out = [conPaint(ch.level, q.questLevel)(bold(q.title)) + dim(` level ${q.questLevel}${q.minLevel > ch.level ? red(` requires ${q.minLevel}`) : ''}`)];
|
|
122
192
|
if (qs) out.push(qs.status === 'complete' ? green('Ready to turn in') : qs.status === 'failed' ? red('Failed') : yellow('In progress'));
|
|
123
193
|
out.push(...objectiveLines(app, q, qs).map((o) => ` ${o}`));
|
|
124
194
|
const rew = rewardLines(app, q);
|
|
@@ -142,7 +212,7 @@ export function objectiveLines(app, q, qs) {
|
|
|
142
212
|
|
|
143
213
|
export function rewardLines(app, q) {
|
|
144
214
|
const out = [];
|
|
145
|
-
if (q.rewXP) out.push(`${q.rewXP} experience`);
|
|
215
|
+
if (q.rewXP) out.push(magenta(`${q.rewXP} experience`));
|
|
146
216
|
if (q.rewMoney) out.push(yellow(moneyText(q.rewMoney)));
|
|
147
217
|
for (const it of q.rewItems) out.push(`${app.itemLabel(it.item)}${it.count > 1 ? ` x${it.count}` : ''}`);
|
|
148
218
|
if (q.rewChoiceItems?.length) out.push('Choose one of: ' + q.rewChoiceItems.map((it) => app.itemLabel(it.item)).join(', '));
|
|
@@ -154,17 +224,17 @@ export function rewardLines(app, q) {
|
|
|
154
224
|
|
|
155
225
|
export function makeTabs(app) {
|
|
156
226
|
return [
|
|
157
|
-
{ label: 'World', view: worldView(app) },
|
|
158
|
-
{ label: 'Quests', view: questsView(app) },
|
|
159
|
-
{ label: 'Bags', view: bagsView(app) },
|
|
160
|
-
{ label: 'Gear', view: gearView(app) },
|
|
161
|
-
{ label: 'Spells', view: spellsView(app) },
|
|
162
|
-
{ label: 'Rotation', view: rotationView(app) },
|
|
163
|
-
{ label: 'Talents', view: talentsView(app) },
|
|
164
|
-
{ label: 'Travel', view: travelView(app) },
|
|
165
|
-
{ label: 'Professions', short: 'Profs', view: professionsView(app) },
|
|
166
|
-
{ label: 'Companions', short: 'Party', view: companionsView(app) },
|
|
167
|
-
{ label: 'Character', short: 'Char', view: charView(app) },
|
|
227
|
+
{ label: 'World', icon: 'inv_misc_map_01', view: worldView(app) },
|
|
228
|
+
{ label: 'Quests', icon: 'inv_misc_note_01', view: questsView(app) },
|
|
229
|
+
{ label: 'Bags', icon: 'inv_misc_bag_08', view: bagsView(app) },
|
|
230
|
+
{ label: 'Gear', icon: 'inv_chest_chain_05', view: gearView(app) },
|
|
231
|
+
{ label: 'Spells', icon: 'inv_misc_book_09', view: spellsView(app) },
|
|
232
|
+
{ label: 'Rotation', icon: 'ability_dualwield', view: rotationView(app) },
|
|
233
|
+
{ label: 'Talents', icon: 'spell_nature_naturetouchgrow', view: talentsView(app) },
|
|
234
|
+
{ label: 'Travel', icon: 'spell_nature_swiftness', view: travelView(app) },
|
|
235
|
+
{ label: 'Professions', short: 'Profs', icon: 'trade_blacksmithing', view: professionsView(app) },
|
|
236
|
+
{ label: 'Companions', short: 'Party', icon: 'spell_holy_prayerofhealing', view: companionsView(app) },
|
|
237
|
+
{ label: 'Character', short: 'Char', icon: 'inv_misc_head_human_01', view: charView(app) },
|
|
168
238
|
];
|
|
169
239
|
}
|
|
170
240
|
|
|
@@ -173,28 +243,30 @@ function worldView(app) {
|
|
|
173
243
|
id: 'world', title: 'World', hint: 'Enter: open / fight',
|
|
174
244
|
keys: {
|
|
175
245
|
s: { hint: 'search', fn: (a) => openWorldSearch(a) },
|
|
246
|
+
S: { fn: (a) => openWorldSearch(a, '', true) },
|
|
176
247
|
f: { hint: 'quick fight', fn: (a, row) => { if (row?.data?.creature) a.cmds.commands.fight(String(row.data.creature.entry)); } },
|
|
177
248
|
t: { fn: (a, row) => { const c = row?.data?.creature; if (!c) return; if (a.ch.cls !== 3) { a.flashMsg('Only hunters tame beasts.'); return; } a.result(a.g.tamePet(c.entry), `You tame ${c.name}.`); } },
|
|
178
249
|
P: { fn: (a, row) => { if (row?.data?.creature) pickPocket(a, row.data.creature); } },
|
|
179
|
-
a: { hint: 'autopilot', fn: (a) =>
|
|
250
|
+
a: { hint: 'autopilot', fn: (a) => a.push(preflightCard(a)) },
|
|
180
251
|
},
|
|
181
252
|
rows(a) {
|
|
182
253
|
const ds = a.ds, g = a.g, z = a.zone(), ch = a.ch;
|
|
183
254
|
if (!z) return [{ text: 'You are nowhere.', header: true }];
|
|
184
255
|
const rows = [...journeyRows(a), ...meetingStoneQueueRows(a), { text: `${z.name} ${dim(`levels ${z.levelRange[0]}-${z.levelRange[1]}${z.faction ? `, ${z.faction}` : ''}`)}`, header: true }];
|
|
185
256
|
const people = [];
|
|
186
|
-
for (const t of z.trainers ?? []) if (t.cls === ch.cls) { const d = ds.trainer(t.entry); if (d) people.push({ text: `${d.name} ${dim(`<${className(t.cls)} Trainer>`)}`, detail: () => [bold(d.name), `${className(t.cls)} trainer`, '', dim('Enter: see what you can learn')], act: () => a.push(trainerDialog(a, d)) }); }
|
|
187
|
-
|
|
188
|
-
for (const
|
|
257
|
+
for (const t of z.trainers ?? []) if (t.cls === ch.cls) { const d = ds.trainer(t.entry); if (d) people.push({ text: `${d.name} ${dim(`<${className(t.cls)} Trainer>`)}`, detail: () => npcLines(a, d.entry, [bold(d.name), `${className(t.cls)} trainer`, '', dim('Enter: see what you can learn')]), act: () => a.push(trainerDialog(a, d)) }); }
|
|
258
|
+
const innEntries = new Set((z.innkeepers ?? []).map((i) => i.entry)); // innkeepers sell too, but list once, as the inn
|
|
259
|
+
for (const v of z.vendors ?? []) { const d = !innEntries.has(v) && ds.vendor(v); if (d) people.push({ text: `${d.name} ${dim(`<${d.subname ?? 'Vendor'}>`)}${d.canRepair ? dim(' repairs') : ''}`, detail: () => npcLines(a, d.entry, [bold(d.name), d.subname ?? 'Vendor', ...(d.canRepair ? ['Repairs armor and weapons'] : []), '', dim('Enter: browse wares')]), act: () => a.push(vendorDialog(a, d)) }); }
|
|
260
|
+
for (const i of z.innkeepers ?? []) people.push({ text: `${i.name} ${dim('<Innkeeper>')}`, detail: () => npcLines(a, i.entry, [bold(i.name), 'Innkeeper', '', dim('Enter: browse wares, make this your home')]), act: () => a.push(innDialog(a, i)) });
|
|
189
261
|
for (const p of z.profTrainers ?? []) {
|
|
190
262
|
const d = ds.profTrainer(p);
|
|
191
263
|
if (!d) continue;
|
|
192
264
|
const prof = ds.profession(d.skill)?.name ?? d.skill;
|
|
193
265
|
const sk = ch.skills[d.skill];
|
|
194
|
-
people.push({ text: `${d.name} ${dim(`<${prof} Trainer>`)}${sk ? dim(` ${sk.current}/${sk.max}`) : ''}`, detail: () => [bold(d.name), `${prof} trainer`, sk ? `Your skill: ${sk.current}/${sk.max}` : `You do not know ${prof} yet.`, '', dim('Enter: ranks and recipes')], act: () => a.push(profTrainerDialog(a, d)) });
|
|
266
|
+
people.push({ text: `${d.name} ${dim(`<${prof} Trainer>`)}${sk ? dim(` ${sk.current}/${sk.max}`) : ''}`, detail: () => npcLines(a, d.entry, [bold(d.name), `${prof} trainer`, sk ? `Your skill: ${sk.current}/${sk.max}` : `You do not know ${prof} yet.`, '', dim('Enter: ranks and recipes')]), act: () => a.push(profTrainerDialog(a, d)) });
|
|
195
267
|
}
|
|
196
268
|
people.push(...ridingRows(a));
|
|
197
|
-
for (const f of z.flightMasters ?? []) people.push({ text: `${f.name} ${dim('<Flight Master>')}`, detail: () => [bold(f.name), 'Flight master', '', dim('Enter: open the travel tab')], act: () => a.switchTab(7) });
|
|
269
|
+
for (const f of z.flightMasters ?? []) people.push({ text: `${f.name} ${dim('<Flight Master>')}`, detail: () => npcLines(a, f.entry, [bold(f.name), 'Flight master', '', dim('Enter: open the travel tab')]), act: () => a.switchTab(7) });
|
|
198
270
|
// City services: the bank in capitals and banker towns, the auction house in capitals, the mailbox by every inn.
|
|
199
271
|
const city = cityAccess(a);
|
|
200
272
|
const services = [];
|
|
@@ -223,19 +295,13 @@ function worldView(app) {
|
|
|
223
295
|
rows.push({ text: `${conPaint(ch.level, m.level)(m.name)} ${dim(`L${m.level}`)}${wanted ? yellow(' !') : ''}${ch.cls === 4 ? pickPocketTag(a, m) : ''}`, data: { creature: m }, detail: () => creatureLines(a, m), act: () => startFight(a, m.entry) });
|
|
224
296
|
}
|
|
225
297
|
}
|
|
226
|
-
// Last
|
|
227
|
-
// class, race and exclusive-group gates applied); `canAccept` false means visible but level- or
|
|
228
|
-
// reputation-locked (shown grey, like a silver !).
|
|
229
|
-
const have = new Map(g.state.questlog.quests.map((qs) => [qs.entry, qs]));
|
|
230
|
-
const offered = offeredQuests(ds, z.id, ch, g.state.questlog).filter((o) => !have.has(o.quest.entry) && (o.canAccept || o.quest.minLevel <= ch.level + 5));
|
|
298
|
+
// Last: hand-ins and the zone's offers (the Quests tab shows the same list under its log).
|
|
231
299
|
const ready = g.state.questlog.quests.filter((qs) => qs.status === 'complete').map((qs) => ({ qs, q: ds.quest(qs.entry) })).filter((x) => x.q);
|
|
300
|
+
const offered = zoneOfferRows(a);
|
|
232
301
|
if (offered.length || ready.length) {
|
|
233
302
|
rows.push({ text: '', header: true }, { text: 'Quests', header: true });
|
|
234
|
-
for (const { q, qs } of ready) rows.push({ text: `${green('?')} ${q.title}
|
|
235
|
-
|
|
236
|
-
const q = o.quest;
|
|
237
|
-
rows.push({ text: `${o.canAccept ? yellow('!') : dim('·')} ${o.canAccept ? q.title : dim(q.title)} ${dim(`L${q.questLevel}`)}${!o.canAccept && q.minLevel > ch.level ? dim(` at level ${q.minLevel}`) : ''}`, detail: () => questLines(a, q), act: () => a.push(questDialog(a, q, 'offer')) });
|
|
238
|
-
}
|
|
303
|
+
for (const { q, qs } of ready) rows.push({ text: `${green('?')} ${conPaint(ch.level, q.questLevel)(`${q.title} L${q.questLevel}`)} ${green('turn in')}`, detail: () => questLines(a, q, qs), act: () => a.push(questDialog(a, q, 'log')) });
|
|
304
|
+
rows.push(...offered);
|
|
239
305
|
}
|
|
240
306
|
return rows;
|
|
241
307
|
},
|
|
@@ -270,9 +336,23 @@ export function landOnTab(app) {
|
|
|
270
336
|
const v = app.tabs[i]?.view; if (v) { v.cursor = 0; v.scroll = 0; }
|
|
271
337
|
}
|
|
272
338
|
|
|
339
|
+
/** The quests this zone offers that are not in the log: the same offer list the autopilot reads (chain, class,
|
|
340
|
+
* race and exclusive-group gates applied). `canAccept` false means visible but level- or reputation-locked
|
|
341
|
+
* (shown grey, like a silver !); quests more than five levels above you are left out. */
|
|
342
|
+
export function zoneOfferRows(a) {
|
|
343
|
+
const z = a.zone(), ch = a.ch;
|
|
344
|
+
if (!z) return [];
|
|
345
|
+
const have = new Set(a.g.state.questlog.quests.map((qs) => qs.entry));
|
|
346
|
+
const offered = offeredQuests(a.ds, z.id, ch, a.g.state.questlog).filter((o) => !have.has(o.quest.entry) && (o.canAccept || o.quest.minLevel <= ch.level + 5));
|
|
347
|
+
return offered.map((o) => {
|
|
348
|
+
const q = o.quest;
|
|
349
|
+
return { text: `${o.canAccept ? yellow('!') : dim('·')} ${o.canAccept ? conPaint(ch.level, q.questLevel)(`${q.title} L${q.questLevel}`) : dim(`${q.title} L${q.questLevel}`)}${!o.canAccept && q.minLevel > ch.level ? dim(` at level ${q.minLevel}`) : ''}`, detail: () => questLines(a, q), act: () => a.push(questDialog(a, q, 'offer')) };
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
273
353
|
function questsView(app) {
|
|
274
354
|
return {
|
|
275
|
-
id: 'quests', title: 'Quest log', hint: 'Enter: open', empty: 'Your quest log is empty. Look for ! in the world.',
|
|
355
|
+
id: 'quests', title: 'Quest log', hint: 'Enter: open', empty: 'Your quest log is empty and this zone offers nothing. Look for ! in the world.',
|
|
276
356
|
keys: {
|
|
277
357
|
d: { hint: 'do quest', fn: (a, row) => { if (row?.data) doQuest(a, row.data.q); } },
|
|
278
358
|
x: { hint: 'abandon', fn: (a, row) => { if (row?.data) a.confirm(`Abandon ${row.data.q.title}?`, () => a.result(a.g.abandonQuest(row.data.q.entry), `Abandoned ${row.data.q.title}.`)); } },
|
|
@@ -285,8 +365,14 @@ function questsView(app) {
|
|
|
285
365
|
rows.push({ text: `${log.length} of 20 quests`, header: true });
|
|
286
366
|
for (const x of log) {
|
|
287
367
|
const st = x.qs.status === 'complete' ? green('(Complete)') : x.qs.status === 'failed' ? red('(Failed)') : a.cmds.questBlacklist.has(x.q.entry) ? dim('(autopilot skips)') : '';
|
|
288
|
-
rows.push({ text: `${x.q.title}
|
|
368
|
+
rows.push({ text: `${conPaint(a.ch.level, x.q.questLevel)(`${x.q.title} L${x.q.questLevel}`)} ${st}`, data: x, detail: () => questLines(a, x.q, x.qs), act: () => a.push(questDialog(a, x.q, 'log')) });
|
|
289
369
|
}
|
|
370
|
+
// Below the log: what this zone offers that you have not taken, so you need not scroll the World tab for it.
|
|
371
|
+
const offered = zoneOfferRows(a);
|
|
372
|
+
if (offered.length) {
|
|
373
|
+
if (log.length) rows.push({ text: '', header: true });
|
|
374
|
+
rows.push({ text: `Available in ${a.zone()?.name ?? 'this zone'} (${offered.length})`, header: true }, ...offered);
|
|
375
|
+
} else if (!log.length) return [];
|
|
290
376
|
return rows;
|
|
291
377
|
},
|
|
292
378
|
};
|
|
@@ -299,14 +385,19 @@ export function questDialog(app, q, mode) {
|
|
|
299
385
|
rows(a) {
|
|
300
386
|
const W = a.layout().listW - 4;
|
|
301
387
|
const ch = a.ch, s = qs();
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
rows.push({ text:
|
|
308
|
-
|
|
309
|
-
|
|
388
|
+
// Each part reads apart: the title in its level colour, cyan section headings, the story in plain
|
|
389
|
+
// text, the objective in bold with its counts, rewards in their own paint.
|
|
390
|
+
const P = (t) => ({ text: t, header: true, plain: true });
|
|
391
|
+
const rows = [P(`${conPaint(ch.level, q.questLevel)(bold(q.title))} ${dim(`L${q.questLevel}`)}`)];
|
|
392
|
+
const text = (t, paint = (l) => l) => wrapLines(questText(t, ch), W).map((l) => P(paint(l)));
|
|
393
|
+
const section = (title, lines) => { if (lines.length) rows.push(P(''), { text: title, header: true }, ...lines); };
|
|
394
|
+
const story = () => section(s?.status === 'complete' ? 'Completion' : 'Description',
|
|
395
|
+
text(s?.status === 'complete' ? q.offerRewardText || q.endText || '' : q.detailsText || q.requestItemsText || ''));
|
|
396
|
+
const objectives = () => section('Objectives', [...text(q.objectivesText ?? '', bold), ...objectiveLines(a, q, s).map((o) => P(` ${o}`))]);
|
|
397
|
+
// Offered, the story leads as the giver tells it; in the log, what is left to do comes first.
|
|
398
|
+
if (mode === 'offer' || s?.status === 'complete') { story(); objectives(); } else { objectives(); story(); }
|
|
399
|
+
section('Rewards', rewardLines(a, q).map((r) => P(` ${r}`)));
|
|
400
|
+
rows.push(P(''));
|
|
310
401
|
if (mode === 'offer') {
|
|
311
402
|
rows.push({ text: green('Accept the quest'), act: () => { if (a.result(a.g.acceptQuest(q.entry), `Quest accepted: ${q.title}`)) a.pop(); } });
|
|
312
403
|
rows.push({ text: dim('Decline'), act: () => a.pop() });
|
|
@@ -315,7 +406,7 @@ export function questDialog(app, q, mode) {
|
|
|
315
406
|
// A repeatable with a fixed reward hands in every round the bags pay for in one go.
|
|
316
407
|
const rounds = s?.status === 'complete' && isRepeatableQuest(q) && questRewardChoices(q).length <= 1 ? questRepeatsAvailable(q, a.g.state.inventory, a.ch.zoneId) : 0;
|
|
317
408
|
if (rounds >= 2) rows.push({ text: green(`Hand it in ${rounds} times`), act: () => repeatTurnIn(a, q) });
|
|
318
|
-
else rows.push({ text:
|
|
409
|
+
else rows.push({ text: 'Do the quest', act: () => doQuest(a, q) });
|
|
319
410
|
rows.push({ text: red('Abandon the quest'), act: () => a.confirm(`Abandon ${q.title}?`, () => { if (a.result(a.g.abandonQuest(q.entry), `Abandoned ${q.title}.`)) a.popTo('__none__'); }) });
|
|
320
411
|
rows.push({ text: dim('Close'), act: () => a.pop() });
|
|
321
412
|
}
|
|
@@ -331,7 +422,7 @@ function doQuest(app, q) {
|
|
|
331
422
|
const s = app.g.state.questlog.quests.find((x) => x.entry === q.entry);
|
|
332
423
|
if (s?.status === 'complete' && q.rewChoiceItems?.length) { turnIn(app, q); return; }
|
|
333
424
|
app.stack = [];
|
|
334
|
-
startAutopilot(app,
|
|
425
|
+
startAutopilot(app, Infinity, undefined, q.entry);
|
|
335
426
|
}
|
|
336
427
|
|
|
337
428
|
function repeatTurnIn(app, q) {
|
|
@@ -386,7 +477,7 @@ function bagsView(app) {
|
|
|
386
477
|
const slotOf = (a, row) => (row?.data && !('keyring' in row.data) ? a.g.state.inventory.stacks.indexOf(row.data) : -1);
|
|
387
478
|
const onStack = (fn) => (a, row) => { const i = slotOf(a, row); if (i >= 0) fn(a, i); };
|
|
388
479
|
return {
|
|
389
|
-
id: 'bags', title: 'Bags', hint: 'Enter: use / equip / open
|
|
480
|
+
id: 'bags', title: 'Bags', hint: 'Enter: use / equip / open', empty: 'Your bags are empty.',
|
|
390
481
|
keys: {
|
|
391
482
|
m: { hint: 'item menu', fn: onStack(itemMenu) },
|
|
392
483
|
s: { hint: 'sell', fn: (a, row) => { if (!a.vendor) { a.flashMsg('Open a vendor first (World tab).'); return; } const i = slotOf(a, row); if (i >= 0) sellStack(a, i); } },
|
|
@@ -414,6 +505,7 @@ function bagsView(app) {
|
|
|
414
505
|
const tag = s.wrappedItemEntry ? dim(' gift') : it?.slot ? dim(` ${it.slot}`) : it?.consumable ? dim(` ${it.consumable.effect}`) : verb === 'Open' ? dim(' open') : verb === 'Learn' ? dim(' learn') : it?.questItem ? dim(' quest') : '';
|
|
415
506
|
rows.push({
|
|
416
507
|
text: `${name}${s.count > 1 ? ` x${s.count}` : ''}${tag}${ups.has(s.item) ? green(' ▲') : ''}`, data: s,
|
|
508
|
+
icon: it?.icon, sub: dim(`${it?.typeName ?? ''}${it?.sellPrice ? ` sells ${moneyText(it.sellPrice * s.count)}` : ''}`),
|
|
417
509
|
detail: () => itemLines(a, it, [
|
|
418
510
|
'', dim(`Enter: ${verb ? verb.toLowerCase() : 'more'} m: more${s.count > 1 ? ' /: split' : ''} [ ]: move d: destroy`),
|
|
419
511
|
...(a.vendor ? [dim(`s: sell to ${a.vendor.name}`)] : []),
|
|
@@ -433,9 +525,19 @@ function wornItem(app, slot) {
|
|
|
433
525
|
return entry == null ? null : app.ds.item(entry) ?? null;
|
|
434
526
|
}
|
|
435
527
|
|
|
436
|
-
/** The engine's equipment helper: what in your bags
|
|
528
|
+
/** The engine's equipment helper: what in your bags, or unbound in a companion's (a crafter's product), beats what you wear. */
|
|
437
529
|
function upgradeList(app) {
|
|
438
|
-
return app.g.suggestedEquipmentUpgrades()
|
|
530
|
+
return app.g.suggestedEquipmentUpgrades();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Companions you own, squad first, each once. */
|
|
534
|
+
const ownedCompanions = (g) => [...new Map([...(g.state.squad ?? []), ...Object.values(g.state.botRoster ?? {})].map((c) => [c.id, c])).values()];
|
|
535
|
+
|
|
536
|
+
/** Wear `entry` in `slot`, first taking one unbound copy from companion `holderId` when it is theirs. */
|
|
537
|
+
function equipFrom(a, entry, slot, holderId) {
|
|
538
|
+
if (holderId && !a.result(a.g.takeItemFromBot(holderId, entry, 1), '')) return false;
|
|
539
|
+
const r = a.g.equip(entry, slot);
|
|
540
|
+
return a.result(r, r.ok ? `You equip ${a.ds.item(entry)?.name ?? entry}.` : '');
|
|
439
541
|
}
|
|
440
542
|
|
|
441
543
|
/** Lines comparing a candidate with the item worn in `slot`: armor, damage and stat deltas. */
|
|
@@ -459,42 +561,63 @@ function gearView(app) {
|
|
|
459
561
|
id: 'gear', title: 'Equipment', hint: 'Enter: change what you wear',
|
|
460
562
|
keys: {
|
|
461
563
|
u: { hint: 'equip all upgrades', fn: (a) => {
|
|
462
|
-
if (!upgradeList(a).length) { a.flashMsg(
|
|
463
|
-
|
|
564
|
+
if (!upgradeList(a).length) { a.flashMsg("Nothing in your bags or your companions' beats what you wear."); return; }
|
|
565
|
+
// Best first, re-scored after each piece (two rings, a two-hander). Works in suggest mode too,
|
|
566
|
+
// where autoEquipUpgrades stands down. Each entry is tried once, so displaced pieces cannot ping-pong.
|
|
567
|
+
let n = 0;
|
|
568
|
+
const tried = new Set();
|
|
569
|
+
for (let i = 0; i < 16; i++) {
|
|
570
|
+
const u = upgradeList(a).find((x) => !tried.has(x.entry));
|
|
571
|
+
if (!u) break;
|
|
572
|
+
tried.add(u.entry);
|
|
573
|
+
if (u.holderId && !a.g.takeItemFromBot(u.holderId, u.entry, 1).ok) continue;
|
|
574
|
+
if (a.g.equip(u.entry, u.slot).ok) n++;
|
|
575
|
+
}
|
|
464
576
|
a.say(n ? green(`You put on ${plural(n, 'upgrade')}.`) : red('The upgrades could not be equipped.'));
|
|
465
577
|
} },
|
|
466
578
|
x: { hint: 'take off', fn: (a, row) => { if (row?.data?.it) a.result(a.g.unequip(row.data.slot), `You take off ${row.data.it.name}.`); } },
|
|
467
579
|
r: { hint: 'repair', fn: (a, row) => { if (row?.data?.it) repairOne(a, row.data.slot); } },
|
|
580
|
+
R: { hint: 'repair all', fn: (a) => repairAll(a) },
|
|
468
581
|
o: { hint: 'outfits', fn: (a) => a.push(outfitterDialog(a)) },
|
|
469
582
|
},
|
|
583
|
+
// Drawn as the character sheet (tuiPaperdoll.mjs) when colour is on and the window has room.
|
|
584
|
+
grid: (a, rows, w, h) => paperdollGrid(a, rows, w, h, (cols, lines) => playerArt(a, cols, lines)?.lines ?? null),
|
|
470
585
|
rows(a) {
|
|
471
586
|
const rows = [];
|
|
472
587
|
const ups = upgradeList(a);
|
|
473
|
-
|
|
474
|
-
|
|
588
|
+
const extras = gearExtraRows(a);
|
|
589
|
+
const sheet = paperdollPlan(a, extras.filter((r) => !r.header).length);
|
|
590
|
+
if (ups.length && !sheet) {
|
|
591
|
+
rows.push({ text: `${green('▲')} Better gear for you (${ups.length}) ${dim('Enter: put on u: put on all')}`, header: true });
|
|
475
592
|
for (const u of ups) {
|
|
476
593
|
const it = a.ds.item(u.entry), cur = u.currentEntry != null ? a.ds.item(u.currentEntry) : null;
|
|
477
594
|
rows.push({
|
|
478
|
-
text: ` ${fit(SLOT_LABEL[u.slot] ?? u.slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? u.entry)} ${green(`+${Math.round(u.delta)}`)}${cur ? dim(` replaces ${cur.name}`) : ''}${u.displaces?.length ? yellow(` frees ${u.displaces.map((s) => SLOT_LABEL[s] ?? s).join(', ')}`) : ''}`,
|
|
595
|
+
text: ` ${fit(SLOT_LABEL[u.slot] ?? u.slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? u.entry)} ${green(`+${Math.round(u.delta)}`)}${cur ? dim(` replaces ${cur.name}`) : ''}${u.displaces?.length ? yellow(` frees ${u.displaces.map((s) => SLOT_LABEL[s] ?? s).join(', ')}`) : ''}${u.holderId ? cyan(` ${u.holderName} has it`) : ''}`,
|
|
479
596
|
data: { upgrade: u },
|
|
480
|
-
detail: () => itemLines(a, it, compareLines(a, it, cur)),
|
|
481
|
-
act: () =>
|
|
597
|
+
detail: () => itemLines(a, it, [...compareLines(a, it, cur), ...(u.holderId ? ['', dim(`In ${u.holderName}'s bags. Enter takes it and puts it on.`)] : [])]),
|
|
598
|
+
act: () => equipFrom(a, u.entry, u.slot, u.holderId),
|
|
482
599
|
});
|
|
483
600
|
}
|
|
484
601
|
rows.push({ text: '', header: true });
|
|
485
602
|
}
|
|
486
|
-
rows.push({ text: `What you wear ${dim('Enter: pick from bags' + (cityAccess(a).bank ? '
|
|
603
|
+
if (!sheet) rows.push({ text: `What you wear ${dim('Enter: pick from bags' + (cityAccess(a).bank ? ', bank' : '') + ' and companions x: take off')}`, header: true });
|
|
487
604
|
for (const slot of [...EQUIP_SLOTS, ...BAG_SLOTS]) {
|
|
488
605
|
const it = wornItem(a, slot);
|
|
489
606
|
const up = ups.some((u) => u.slot === slot);
|
|
490
607
|
const dur = it ? durabilityText(a, slot) : '';
|
|
608
|
+
const kind = it ? [it.typeName, it.armor ? `${it.armor} armor` : '', it.weapon ? `${it.weapon.dmgMin}-${it.weapon.dmgMax}` : ''].filter(Boolean).join(' ') : '';
|
|
609
|
+
// The best piece waiting for this slot, said in the tooltip (the sheet has no upgrade rows).
|
|
610
|
+
const best = ups.filter((u) => u.slot === slot).sort((x, y) => y.delta - x.delta)[0];
|
|
611
|
+
const bestLines = best ? ['', green(`▲ Better: ${a.itemName(best.entry)} (+${Math.round(best.delta)})${best.holderId ? `, ${best.holderName} has it` : ''}`), dim('Enter: pick it u: put on every upgrade')] : [];
|
|
491
612
|
rows.push({
|
|
492
613
|
text: `${fit(SLOT_LABEL[slot], 10)} ${it ? qualityPaint(it.quality)(it.name) : dim('—')}${up ? green(' ▲') : ''}${dur ? ` ${dur}` : ''}`, data: { slot, it },
|
|
493
|
-
|
|
614
|
+
icon: it?.icon, sub: it ? dim(`${fit('', 10)} ${kind}`) : undefined,
|
|
615
|
+
cell: { slot, it, up, dur, sub: dim(kind) },
|
|
616
|
+
detail: () => (it ? itemLines(a, it, [...enchantLines(a, a.ch, slot), '', ...(dur ? [`Durability ${dur}`] : []), dim('Enter: change x: take off'), ...slotRepairLines(a, slot), ...bestLines]) : [bold(SLOT_LABEL[slot]), dim('Nothing worn here.'), '', dim('Enter: pick something to wear'), ...bestLines]),
|
|
494
617
|
act: () => a.push(slotDialog(a, slot)),
|
|
495
618
|
});
|
|
496
619
|
}
|
|
497
|
-
rows.push(...
|
|
620
|
+
rows.push(...(sheet ? extras.filter((r) => !r.header).slice(0, sheet.extras) : extras));
|
|
498
621
|
return rows;
|
|
499
622
|
},
|
|
500
623
|
};
|
|
@@ -513,19 +636,18 @@ function slotDialog(app, slot) {
|
|
|
513
636
|
const ups = new Map(upgradeList(a).filter((u) => u.slot === slot).map((u) => [u.entry, u]));
|
|
514
637
|
const rows = [{ text: `${bold(label)}: ${worn ? qualityPaint(worn.quality)(worn.name) : dim('empty')}`, header: true }, { text: '', header: true }];
|
|
515
638
|
if (worn) rows.push({ text: dim(`Take off ${worn.name}`), act: () => { if (a.result(a.g.unequip(slot), `You take off ${worn.name}.`)) a.pop(); } });
|
|
516
|
-
const row = (s, from) => {
|
|
639
|
+
const row = (s, from, comp) => {
|
|
517
640
|
const it = ds.item(s.item);
|
|
518
641
|
const up = ups.get(s.item);
|
|
519
642
|
const low = it.requiredLevel > ch.level;
|
|
520
643
|
const name = qualityPaint(it.quality)(it.name);
|
|
521
644
|
return {
|
|
522
|
-
text: ` ${low ? dim(name) : name}${s.count > 1 ? dim(` x${s.count}`) : ''}${up ? green(` ▲ +${Math.round(up.delta)}`) : ''}${low ? red(` L${it.requiredLevel}`) : ''}${from === 'bank' ? dim(' in bank') : ''}`,
|
|
645
|
+
text: ` ${low ? dim(name) : name}${s.count > 1 ? dim(` x${s.count}`) : ''}${up ? green(` ▲ +${Math.round(up.delta)}`) : ''}${low ? red(` L${it.requiredLevel}`) : ''}${from === 'bank' ? dim(' in bank') : ''}${comp ? cyan(` ${comp.name}`) : ''}`,
|
|
523
646
|
data: { s, it },
|
|
524
|
-
detail: () => itemLines(a, it, [...compareLines(a, it, worn), '', dim(from === 'bank' ? 'Enter: take it out of the bank and wear it' : 'Enter: wear it')]),
|
|
647
|
+
detail: () => itemLines(a, it, [...compareLines(a, it, worn), '', dim(from === 'bank' ? 'Enter: take it out of the bank and wear it' : comp ? `Enter: take it from ${comp.name} and wear it` : 'Enter: wear it')]),
|
|
525
648
|
act: () => {
|
|
526
649
|
if (from === 'bank' && !a.result(a.g.bankWithdraw(s.item, 1), '')) return;
|
|
527
|
-
|
|
528
|
-
if (a.result(r, r.ok ? `You equip ${it.name}.` : '')) a.pop();
|
|
650
|
+
if (equipFrom(a, s.item, slot, comp?.id)) a.pop();
|
|
529
651
|
},
|
|
530
652
|
};
|
|
531
653
|
};
|
|
@@ -539,6 +661,12 @@ function slotDialog(app, slot) {
|
|
|
539
661
|
if (!bank.length) rows.push({ text: dim(' Nothing in the bank fits either.'), header: true });
|
|
540
662
|
for (const s of bank) rows.push(row(s, 'bank'));
|
|
541
663
|
}
|
|
664
|
+
// Unbound pieces a companion carries (a crafter's product, a BoE they looted) can come straight over.
|
|
665
|
+
const held = ownedCompanions(a.g).flatMap((c) => (c.bags?.stacks ?? []).filter((s) => fits(s) && !stackBoundToHolder(s, ds)).map((s) => ({ s, c })));
|
|
666
|
+
if (held.length) {
|
|
667
|
+
rows.push({ text: '', header: true }, { text: `In your companions' bags (${held.length})`, header: true });
|
|
668
|
+
for (const { s, c } of held) rows.push(row(s, 'companion', c));
|
|
669
|
+
}
|
|
542
670
|
return rows;
|
|
543
671
|
},
|
|
544
672
|
};
|
|
@@ -557,6 +685,7 @@ function spellsView(app) {
|
|
|
557
685
|
const cd = a.g.spellCooldownRemaining(sp.id);
|
|
558
686
|
return {
|
|
559
687
|
text: `${sp.name}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''}${sp.cost ? dim(` ${sp.cost} ${sp.powerType}`) : ''}${cd > 0 ? red(` ${secondsText(cd)}`) : ''}`,
|
|
688
|
+
icon: sp.icon, sub: dim(`${sp.castTimeMs ? `${(sp.castTimeMs / 1000).toFixed(1)}s cast` : sp.channelMs ? 'channeled' : 'instant'}${sp.cooldownMs ? ` ${secondsText(sp.cooldownMs)} cooldown` : ''}`),
|
|
560
689
|
detail: () => spellLines(a, sp),
|
|
561
690
|
act: () => castWithPicker(a, sp.id),
|
|
562
691
|
};
|
|
@@ -575,6 +704,11 @@ function rotNames(app) {
|
|
|
575
704
|
item: (id) => app.itemName(id),
|
|
576
705
|
creature: (e) => app.ds.creature(e)?.name,
|
|
577
706
|
object: (e) => app.ds.gameObject?.(e)?.name,
|
|
707
|
+
seat: (unit) => {
|
|
708
|
+
if (unit === 'player') return app.g.state.char.name || 'You';
|
|
709
|
+
const i = allyIndexOfSide(unit);
|
|
710
|
+
return (i >= 0 ? (app.g.state.squad ?? [])[i]?.name : undefined) ?? unit;
|
|
711
|
+
},
|
|
578
712
|
};
|
|
579
713
|
}
|
|
580
714
|
|
|
@@ -587,6 +721,7 @@ function rotOwner(app) {
|
|
|
587
721
|
return {
|
|
588
722
|
name: 'your', comp: null, get: () => g.rotation(), set: (r) => g.setRotation(r), reset: () => g.resetRotation(),
|
|
589
723
|
spells: () => knownUsableSpells(app.ch, g.state.spellbook, app.ds), bag: () => g.state.inventory.stacks,
|
|
724
|
+
char: () => app.ch, spellbook: () => g.state.spellbook, equipped: () => app.ch.equipped,
|
|
590
725
|
profiles: () => g.rotationProfiles(), add: (o) => g.addRotationProfile(o), rename: (pid, n) => g.renameRotationProfile(pid, n),
|
|
591
726
|
del: (pid) => g.deleteRotationProfile(pid), select: (pid) => g.selectRotationProfile(pid),
|
|
592
727
|
};
|
|
@@ -595,6 +730,7 @@ function rotOwner(app) {
|
|
|
595
730
|
return {
|
|
596
731
|
name: `${comp.name}'s`, comp, get: () => g.companionRotation(comp.id), set: (r) => g.setCompanionRotation(comp.id, r), reset: () => g.resetCompanionRotation(comp.id),
|
|
597
732
|
spells: () => knownUsableSpells(companionCharacter(comp, lvl, app.ds), companionSpellbook(comp, lvl, app.ds), app.ds), bag: () => comp.bags?.stacks ?? [],
|
|
733
|
+
char: () => companionCharacter(comp, lvl, app.ds), spellbook: () => companionSpellbook(comp, lvl, app.ds), equipped: () => comp.equipped,
|
|
598
734
|
profiles: () => g.companionRotationProfiles(comp.id), add: (o) => g.addCompanionRotationProfile(comp.id, o), rename: (pid, n) => g.renameCompanionRotationProfile(comp.id, pid, n),
|
|
599
735
|
del: (pid) => g.deleteCompanionRotationProfile(comp.id, pid), select: (pid) => g.selectCompanionRotationProfile(comp.id, pid),
|
|
600
736
|
};
|
|
@@ -677,9 +813,13 @@ function rotationView(app) {
|
|
|
677
813
|
rows.push({ text: '', header: true }, { text: PHASE_LABEL[phase], header: true });
|
|
678
814
|
rules.forEach((rule, index) => {
|
|
679
815
|
const conds = conditionsSentence(rule.conditions, names);
|
|
680
|
-
|
|
816
|
+
// With icons a rule is two lines and the condition takes the second; without, it trails the sentence.
|
|
817
|
+
const icon = iconsOn() ? a.ds.spell(rule.action?.spellId ?? rule.before?.spellId)?.icon : undefined;
|
|
818
|
+
const when = conds !== 'always' ? dim(`when ${conds}`) : '';
|
|
819
|
+
const text = `${rule.enabled ? green('✓') : dim('·')} ${rule.enabled ? ruleSentence(rule, names) : dim(ruleSentence(rule, names))}${!icon && when ? ` ${when}` : ''}`;
|
|
681
820
|
rows.push({
|
|
682
821
|
text, data: { phase, index, rule },
|
|
822
|
+
icon, sub: when,
|
|
683
823
|
detail: () => ruleLines(a, rule, names),
|
|
684
824
|
act: () => ruleMenu(a, { phase, index, rule }),
|
|
685
825
|
});
|
|
@@ -712,7 +852,7 @@ function ruleMenu(app, data) {
|
|
|
712
852
|
const names = rotNames(app);
|
|
713
853
|
const options = [
|
|
714
854
|
{ label: rule.enabled ? 'Turn it off' : green('Turn it on'), detail: [rule.enabled ? 'Auto-play skips a rule that is off.' : 'Auto-play considers this rule again.'], value: 'toggle' },
|
|
715
|
-
{ label: 'Change when it fires', detail: ['
|
|
855
|
+
{ label: 'Change when it fires', detail: ['Every condition the game knows: health and power, auras and stacks, casting, crowd control, enemy and ally counts, distances, battleground and raid state. Combine them with and / or.', '', dim(`Now: ${conditionsSentence(rule.conditions, names)}`)], value: 'cond' },
|
|
716
856
|
{ label: 'Move up', detail: ['Earlier rules are tried first.'], value: 'up' },
|
|
717
857
|
{ label: 'Move down', detail: ['Later rules only fire when everything above them is unusable or gated off.'], value: 'down' },
|
|
718
858
|
{ label: 'Add a rule after this one', value: 'add' },
|
|
@@ -766,10 +906,20 @@ function addRule(app, phase, afterIndex) {
|
|
|
766
906
|
const options = known.map((sp) => ({ label: `${sp.name}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''}`, detail: () => spellLines(app, sp), value: { kind: 'spell', spellId: sp.id } }));
|
|
767
907
|
const combatItems = [...new Set(o.bag().map((s) => s.item))].map((e) => app.ds.item(e)).filter((it) => it?.useInCombat);
|
|
768
908
|
for (const it of combatItems) options.push({ label: `Use ${qualityPaint(it.quality)(it.name)}`, detail: () => itemLines(app, it), value: { kind: 'item', itemEntry: it.entry } });
|
|
769
|
-
app.pick(`Add to ${PHASE_LABEL[phase].split(' (')[0].toLowerCase()}: what to do`, options, (
|
|
909
|
+
app.pick(`Add to ${PHASE_LABEL[phase].split(' (')[0].toLowerCase()}: what to do`, options, (picked) => {
|
|
770
910
|
const id = `tui-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
|
|
911
|
+
// A new rule starts with the engine's smart default for what it does: a seal or buff only when it is
|
|
912
|
+
// missing, a DoT when the enemy lacks it, a form when not in it, an interrupt when the enemy casts, a
|
|
913
|
+
// potion below a health band. The same gate the recommended list would give it; c changes it.
|
|
914
|
+
let action = picked, conditions = [];
|
|
915
|
+
if (picked.kind === 'spell') {
|
|
916
|
+
const sp = app.ds.spell(picked.spellId);
|
|
917
|
+
if (sp) conditions = defaultConditionsForSpell(sp, o.char());
|
|
918
|
+
} else if (picked.kind === 'item') {
|
|
919
|
+
({ action, conditions } = defaultItemRule(app, o, picked.itemEntry));
|
|
920
|
+
}
|
|
771
921
|
editRotation(app, (rot) => {
|
|
772
|
-
const rule = { id, action, conditions
|
|
922
|
+
const rule = { id, action, conditions, enabled: true };
|
|
773
923
|
const list = rot.phases[phase] ??= [];
|
|
774
924
|
list.splice(afterIndex == null ? list.length : afterIndex + 1, 0, rule);
|
|
775
925
|
});
|
|
@@ -777,39 +927,14 @@ function addRule(app, phase, afterIndex) {
|
|
|
777
927
|
const view = app.tabs[5].view;
|
|
778
928
|
const at = app.rowsOf(view).findIndex((r) => r.data?.rule?.id === id);
|
|
779
929
|
if (at >= 0) { view.cursor = at; app.ensureVisible(view, app.rowsOf(view)); }
|
|
780
|
-
|
|
930
|
+
const when = conditions.length ? `only when ${conditionsSentence(conditions, rotNames(app))}` : 'with no conditions';
|
|
931
|
+
app.say(green(`Rule added to ${phase}, ${when}. Enter on it to change when it fires.`));
|
|
781
932
|
});
|
|
782
933
|
}
|
|
783
934
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
const presets = [
|
|
788
|
-
{ label: 'Always', value: [] },
|
|
789
|
-
{ label: 'My health is below …%', value: 'hp', prompt: { metric: 'healthPct', subject: 'self', op: '<' } },
|
|
790
|
-
{ label: 'My mana/power is above …%', value: 'pw', prompt: { metric: 'powerPct', subject: 'self', op: '>' } },
|
|
791
|
-
{ label: "The enemy's health is below …%", value: 'ehp', prompt: { metric: 'healthPct', subject: 'target', op: '<' } },
|
|
792
|
-
{ label: "The enemy's health is above …%", value: 'ehp2', prompt: { metric: 'healthPct', subject: 'target', op: '>' } },
|
|
793
|
-
...(spellRef ? [
|
|
794
|
-
{ label: `The enemy does not have ${spellName}`, value: [{ subject: 'target', metric: 'debuffPresent', op: '==', value: 0, ref: spellRef }] },
|
|
795
|
-
{ label: `I do not have ${spellName}`, value: [{ subject: 'self', metric: 'buffPresent', op: '==', value: 0, ref: spellRef }] },
|
|
796
|
-
{ label: `${spellName} has under 3 s left on the enemy`, value: [{ subject: 'target', metric: 'debuffRemainingMs', op: '<', value: 3000, ref: spellRef }] },
|
|
797
|
-
] : []),
|
|
798
|
-
{ label: 'The enemy is casting', value: [{ subject: 'target', metric: 'castingPresent', op: '==', value: 1 }] },
|
|
799
|
-
{ label: 'More than one living enemy', value: [{ subject: 'self', metric: 'enemyCount', op: '>', value: 1 }] },
|
|
800
|
-
{ label: 'I am in melee range', value: [{ subject: 'self', metric: 'inMelee', op: '==', value: 1 }] },
|
|
801
|
-
{ label: 'I am at range', value: [{ subject: 'self', metric: 'inMelee', op: '==', value: 0 }] },
|
|
802
|
-
];
|
|
803
|
-
const apply = (conds) => editRotation(app, (rot) => { rot.phases[phase][index].conditions = conds; });
|
|
804
|
-
app.pick(`${spellName}: fire only when…`, presets.map((p) => ({ label: p.label, value: p })), (p) => {
|
|
805
|
-
if (Array.isArray(p.value)) { apply(p.value); return; }
|
|
806
|
-
app.prompt('Percent: ', (txt) => {
|
|
807
|
-
const n = Number(txt);
|
|
808
|
-
if (!(n >= 0 && n <= 100)) { app.flashMsg('Enter a number from 0 to 100.'); return; }
|
|
809
|
-
apply([{ ...p.prompt, value: n }]);
|
|
810
|
-
app.afterAction();
|
|
811
|
-
});
|
|
812
|
-
});
|
|
935
|
+
/** Enter on a rule, "Change when it fires": the full condition editor (tuiRotationCond.mjs). */
|
|
936
|
+
function editCondition(app, { phase, index }) {
|
|
937
|
+
conditionEditor(app, { phase, index, owner: rotOwner(app), names: rotNames(app), mutate: (fn) => editRotation(app, fn) });
|
|
813
938
|
}
|
|
814
939
|
|
|
815
940
|
function pickSpec(app) {
|
|
@@ -823,6 +948,9 @@ function pickSpec(app) {
|
|
|
823
948
|
function talentsView(app) {
|
|
824
949
|
return {
|
|
825
950
|
id: 'talents', title: 'Talents', hint: 'Enter: spend a point',
|
|
951
|
+
// Drawn as the three trees (tuiTalentTree.mjs) when colour is on and they fit; a narrower tooltip makes room.
|
|
952
|
+
detailShare: 0.34,
|
|
953
|
+
grid: (a, rows, w, h) => talentGrid(a, rows, w, h),
|
|
826
954
|
keys: {
|
|
827
955
|
u: { hint: 'refund', fn: (a, row) => row?.data && a.result(a.g.refundTalent(row.data.id), `Refunded a point from ${row.data.name}.`) },
|
|
828
956
|
R: { hint: 'respec', fn: (a) => { const cost = a.g.respecCost(); a.confirm(`Reset all talents for ${moneyText(cost) || 'free'}?`, () => a.result(a.g.respec(), 'Your talents are reset.')); } },
|
|
@@ -831,6 +959,16 @@ function talentsView(app) {
|
|
|
831
959
|
rows(a) {
|
|
832
960
|
const ch = a.ch;
|
|
833
961
|
const free = availablePoints(ch);
|
|
962
|
+
if (talentPlan(a)) {
|
|
963
|
+
return a.ds.talentTrees(ch.cls).flatMap((tree, k) => [...tree.talents].sort((x, y) => x.tier - y.tier || x.col - y.col).map((t) => {
|
|
964
|
+
const rank = ch.talents?.[t.id] ?? 0;
|
|
965
|
+
return {
|
|
966
|
+
text: t.name, data: t, node: { tree: k, t, rank, tone: talentTone(ch, t, a.ds), icon: talentIcon(a.ds, t) },
|
|
967
|
+
detail: () => talentLines(a, t, rank, canSpend(ch, t.id, a.ds)),
|
|
968
|
+
act: () => a.result(a.g.spendTalent(t.id), `You learn ${t.name} (${(ch.talents?.[t.id] ?? 0) + 1}/${t.maxRank}).`),
|
|
969
|
+
};
|
|
970
|
+
}));
|
|
971
|
+
}
|
|
834
972
|
const rows = [{ text: ch.level < 10 ? dim('Talents unlock at level 10.') : `${plural(free, 'point')} to spend`, header: true }];
|
|
835
973
|
if (ch.level >= 10) rows.push({ text: `Builds ${dim(`premade specs, saved loadouts, talent strings ${myTalentLink(a) || 'nothing spent yet'}`)}`, detail: () => [bold('Talent builds'), '', 'Switch to a premade spec, a build you saved, or a pasted talent string. Spent points cost a respec.', '', dim('Enter (or B): open')], act: () => talentBuildsMenu(a) });
|
|
836
974
|
for (const tree of a.ds.talentTrees(ch.cls)) {
|
|
@@ -842,6 +980,7 @@ function talentsView(app) {
|
|
|
842
980
|
const paint = rank >= t.maxRank ? brightYellow : can.ok ? green : dim;
|
|
843
981
|
rows.push({
|
|
844
982
|
text: `${paint(t.name)} ${dim(`${rank}/${t.maxRank}`)}${t.prereq ? dim(` needs ${a.ds.talent(t.prereq.talentId)?.name ?? '?'}`) : ''}`, data: t,
|
|
983
|
+
icon: talentIcon(a.ds, t), sub: dim(`tier ${t.tier + 1}`),
|
|
845
984
|
detail: () => talentLines(a, t, rank, can),
|
|
846
985
|
act: () => a.result(a.g.spendTalent(t.id), `You learn ${t.name} (${(ch.talents?.[t.id] ?? 0) + 1}/${t.maxRank}).`),
|
|
847
986
|
});
|
|
@@ -853,9 +992,14 @@ function talentsView(app) {
|
|
|
853
992
|
}
|
|
854
993
|
|
|
855
994
|
function talentLines(app, t, rank, can) {
|
|
995
|
+
return withIcon(talentIcon(app.ds, t), talentLinesText(app, t, rank, can));
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function talentLinesText(app, t, rank, can) {
|
|
856
999
|
const out = [bold(t.name) + dim(` tier ${t.tier + 1}, rank ${rank}/${t.maxRank}`)];
|
|
857
|
-
|
|
858
|
-
if (rank
|
|
1000
|
+
// The pane wraps the descriptions to its own width (beside the icon while they share its rows).
|
|
1001
|
+
if (rank > 0) out.push('', cyan('Current rank'), t.ranks[rank - 1]?.description ?? '');
|
|
1002
|
+
if (rank < t.maxRank) out.push('', cyan(rank ? 'Next rank' : 'Rank 1'), t.ranks[rank]?.description ?? '');
|
|
859
1003
|
out.push('', can.ok ? green('Enter to spend a point') : red(can.reason ?? 'Cannot spend here yet'));
|
|
860
1004
|
if (rank > 0 && canRefund(app.ch, t.id, app.ds).ok) out.push(dim('u refunds a point'));
|
|
861
1005
|
return out;
|
|
@@ -921,9 +1065,32 @@ function weaponSkillRows(a, L) {
|
|
|
921
1065
|
return rows;
|
|
922
1066
|
}
|
|
923
1067
|
|
|
1068
|
+
/**
|
|
1069
|
+
* Your character as a sprite for a pane: the form or costume you wear when the creature pack has it, else
|
|
1070
|
+
* you in your gear (paperdoll.mjs), else your bare race and gender body. `label` names the form when that
|
|
1071
|
+
* is what shows; null without art.
|
|
1072
|
+
*/
|
|
1073
|
+
export function playerArt(app, maxCols, maxRows) {
|
|
1074
|
+
const ch = app.ch;
|
|
1075
|
+
const worn = wornDisplay(ch, app.ds, Date.now());
|
|
1076
|
+
const wornArt = worn?.displayId ? creatureArt(worn.displayId, maxCols, maxRows) : null;
|
|
1077
|
+
if (wornArt) return { lines: wornArt, label: worn.label };
|
|
1078
|
+
const geared = paperdollArt(ch, maxCols, maxRows);
|
|
1079
|
+
if (geared) return { lines: geared, label: null };
|
|
1080
|
+
const own = creatureArt(nativeDisplayId(ch.race, ch.gender), maxCols, maxRows);
|
|
1081
|
+
return own ? { lines: own, label: null } : null;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
924
1084
|
function charView(app) {
|
|
925
1085
|
return {
|
|
926
|
-
id: 'char', title: 'Character', hint: '',
|
|
1086
|
+
id: 'char', title: 'Character', hint: '',
|
|
1087
|
+
/** The detail pane holds your character: the body of your race and gender, or the form you are in. */
|
|
1088
|
+
detail(a) {
|
|
1089
|
+
const L = a.layout(), ch = a.ch;
|
|
1090
|
+
const art = playerArt(a, L.detailW - 2, L.mainH - 2);
|
|
1091
|
+
if (!art) return [];
|
|
1092
|
+
return [`${bold(ch.name)} ${dim(`${raceName(ch.race)} ${className(ch.cls)}${art.label ? `, ${art.label}` : ''}`)}`, '', ...art.lines];
|
|
1093
|
+
},
|
|
927
1094
|
rows(a) {
|
|
928
1095
|
const ch = a.ch, ds = a.ds;
|
|
929
1096
|
const st = effectiveStats(ch, ds);
|
|
@@ -1064,6 +1231,7 @@ export function vendorDialog(app, vendor) {
|
|
|
1064
1231
|
const usedBy = uses.get(o.itemEntry);
|
|
1065
1232
|
rows.push({
|
|
1066
1233
|
text: `${ok ? name : dim(name)} ${yellow(price)}${o.stockRemaining != null ? dim(` ${o.stockRemaining} left`) : ''}${it?.requiredLevel > a.ch.level ? red(` L${it.requiredLevel}`) : ''}${usedBy ? cyan(' reagent') : ''}${why}`, data: o,
|
|
1234
|
+
icon: it?.icon, sub: dim([it?.typeName, it?.slot].filter(Boolean).join(' · ')),
|
|
1067
1235
|
detail: () => itemLines(a, it, ['', `Price: ${yellow(price)}`, ...(o.basePricePerPurchase !== o.pricePerPurchase ? [dim(`(${moneyText(o.basePricePerPurchase)} before reputation)`)] : []),
|
|
1068
1236
|
...(usedBy ? ['', cyan('Your party crafts with it'), ...usedBy.slice(0, 6).map((n) => ` ${n}`), ...(usedBy.length > 6 ? [dim(` and ${usedBy.length - 6} more`)] : []), dim('Crafting buys it for you when you run short.')] : [])]),
|
|
1069
1237
|
act: () => buy(a, o, 1),
|
|
@@ -1079,6 +1247,7 @@ export function vendorDialog(app, vendor) {
|
|
|
1079
1247
|
const total = (it?.sellPrice ?? 0) * s.count;
|
|
1080
1248
|
rows.push({
|
|
1081
1249
|
text: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''} ${total ? yellow(moneyText(total)) : dim('no value')}`, data: s,
|
|
1250
|
+
icon: it?.icon, sub: dim(it?.typeName ?? ''),
|
|
1082
1251
|
detail: () => itemLines(a, it, ['', dim('Enter: sell the stack (you can buy it back on the Buyback tab) d: destroy')]),
|
|
1083
1252
|
act: () => sellStack(a, slot),
|
|
1084
1253
|
});
|
|
@@ -1125,8 +1294,10 @@ function buy(app, offer, n) {
|
|
|
1125
1294
|
|
|
1126
1295
|
export function trainerDialog(app, trainer) {
|
|
1127
1296
|
const known = () => new Set(app.g.state.spellbook.known);
|
|
1297
|
+
// Weapon masters list every class's weapons; hide the rows the class/race gate would refuse.
|
|
1298
|
+
const fits = (ts) => learnGrantIds(ts.spell, app.ds).every((g) => isSpellFitByClassAndRace(g, app.ch.race, app.ch.cls, app.ds.combatSkillMeta(), Math.max(app.ch.level, ts.reqLevel)));
|
|
1128
1299
|
const learnable = () => trainer.spells
|
|
1129
|
-
.filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell))
|
|
1300
|
+
.filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell) && fits(ts))
|
|
1130
1301
|
.map((ts) => ({ ts, sp: app.ds.spell(ts.spell) })).filter((x) => x.sp)
|
|
1131
1302
|
.sort((x, y) => x.ts.reqLevel - y.ts.reqLevel || x.sp.name.localeCompare(y.sp.name));
|
|
1132
1303
|
return {
|
|
@@ -1140,11 +1311,12 @@ export function trainerDialog(app, trainer) {
|
|
|
1140
1311
|
const can = ts.cost <= a.ch.gold;
|
|
1141
1312
|
rows.push({
|
|
1142
1313
|
text: `${can ? sp.name : dim(sp.name)}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''} ${yellow(moneyText(ts.cost) || 'free')}`, data: sp,
|
|
1314
|
+
icon: sp.icon, sub: dim(`level ${ts.reqLevel}${sp.cost ? ` ${sp.cost} ${sp.powerType}` : ''}`),
|
|
1143
1315
|
detail: () => spellLines(a, sp, ['', `Cost: ${yellow(moneyText(ts.cost) || 'free')}`]),
|
|
1144
1316
|
act: () => a.result(a.g.learnFromTrainer(trainer.entry, sp.id), `You have learned ${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''}.`),
|
|
1145
1317
|
});
|
|
1146
1318
|
}
|
|
1147
|
-
const later = trainer.spells.filter((ts) => ts.reqLevel > a.ch.level && !known().has(ts.spell)).map((ts) => ({ ts, sp: a.ds.spell(ts.spell) })).filter((x) => x.sp).sort((x, y) => x.ts.reqLevel - y.ts.reqLevel).slice(0, 12);
|
|
1319
|
+
const later = trainer.spells.filter((ts) => ts.reqLevel > a.ch.level && !known().has(ts.spell) && fits(ts)).map((ts) => ({ ts, sp: a.ds.spell(ts.spell) })).filter((x) => x.sp).sort((x, y) => x.ts.reqLevel - y.ts.reqLevel).slice(0, 12);
|
|
1148
1320
|
if (later.length) {
|
|
1149
1321
|
rows.push({ text: '', header: true }, { text: 'Coming up', header: true });
|
|
1150
1322
|
for (const { sp, ts } of later) rows.push({ text: dim(`${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''} level ${ts.reqLevel} ${moneyText(ts.cost)}`), header: true });
|
|
@@ -1158,11 +1330,13 @@ export function innDialog(app, inn) {
|
|
|
1158
1330
|
return {
|
|
1159
1331
|
id: `inn-${inn.entry}`, title: inn.name, hint: 'Enter: choose', noDetail: true,
|
|
1160
1332
|
rows(a) {
|
|
1161
|
-
const bound = a.ch.hearthBind?.name === inn.name;
|
|
1333
|
+
const bound = a.ch.hearthBind?.name === inn.name, wares = a.ds.vendor(inn.entry);
|
|
1162
1334
|
return [
|
|
1163
|
-
{ text: `${bold(inn.name)} ${dim('<Innkeeper>')}`, header: true },
|
|
1335
|
+
{ text: `${bold(inn.name)} ${dim('<Innkeeper>')}`, header: true },
|
|
1336
|
+
// Rested xp needs no button: it builds here in game, and at the full offline rate after logging out here.
|
|
1337
|
+
{ text: dim(`${a.ch.restedXp ? `You are rested (${a.ch.restedXp} xp).` : 'You are not rested.'} Staying here builds rest, logging out here builds it faster.`), header: true }, { text: '', header: true },
|
|
1338
|
+
...(wares ? [{ text: 'Browse wares', act: () => a.push(vendorDialog(a, wares)) }] : []),
|
|
1164
1339
|
{ text: bound ? dim('This inn is already your home.') : 'Make this inn your home', act: () => { if (!bound) a.result(a.g.bindHearth(), `${inn.name} sets your hearthstone to ${a.zone()?.name}.`); } },
|
|
1165
|
-
{ text: 'Rest here for a while (60 s)', act: () => { a.idleTick(60, true); a.say(dim(`You rest at the inn. HP ${a.hpText()} ${a.pwText()}`)); } },
|
|
1166
1340
|
{ text: dim('Goodbye'), act: () => a.pop() },
|
|
1167
1341
|
];
|
|
1168
1342
|
},
|