azeroth-shell 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +362 -85
- package/bin/azsh.mjs +35 -19
- package/package.json +12 -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 +145 -0
- package/src/cli/iconGrid.mjs +100 -0
- package/src/cli/modelArt.mjs +298 -0
- package/src/cli/oocPrep.mjs +32 -0
- package/src/cli/paperdoll.mjs +76 -0
- package/src/cli/perfCheck.mjs +56 -0
- package/src/cli/repl.mjs +48 -7
- package/src/cli/rotationText.mjs +6 -6
- package/src/cli/sixel.mjs +319 -0
- package/src/cli/term.mjs +106 -12
- package/src/cli/text.mjs +21 -3
- package/src/cli/tui.mjs +262 -102
- package/src/cli/tuiAutopilot.mjs +356 -0
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiBg.mjs +370 -41
- package/src/cli/tuiCheck.mjs +633 -37
- package/src/cli/tuiCity.mjs +146 -41
- package/src/cli/tuiCompanionKit.mjs +6 -6
- package/src/cli/tuiCompanions.mjs +211 -41
- package/src/cli/tuiCreate.mjs +230 -0
- package/src/cli/tuiDungeon.mjs +57 -21
- package/src/cli/tuiFight.mjs +467 -88
- package/src/cli/tuiFightPanes.mjs +42 -14
- package/src/cli/tuiFloor.mjs +21 -9
- package/src/cli/tuiGear.mjs +17 -14
- package/src/cli/tuiLoot.mjs +6 -6
- package/src/cli/tuiMap.mjs +214 -0
- package/src/cli/tuiPaperdoll.mjs +108 -0
- package/src/cli/tuiPartyStage.mjs +206 -0
- package/src/cli/tuiPet.mjs +16 -8
- package/src/cli/tuiProfessions.mjs +27 -25
- package/src/cli/tuiRotationCond.mjs +390 -0
- package/src/cli/tuiSaves.mjs +24 -1
- package/src/cli/tuiSearch.mjs +79 -27
- package/src/cli/tuiSettings.mjs +3 -4
- package/src/cli/tuiStage.mjs +360 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiTalents.mjs +3 -4
- package/src/cli/tuiViews.mjs +633 -172
- package/src/cli/tuiWiki.mjs +2 -3
- package/src/cli/tuiWorld.mjs +90 -7
- 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-worn.bin.br +0 -0
- package/src/data/model-art.bin.gz +0 -0
- package/src/data/rotations/pve/paladin/retribution.json +2 -2
- package/src/data/tables/creatures.json.gz +0 -0
- package/src/data/world-map-sixel.bin.br +0 -0
- package/src/data/world-map.bin.gz +0 -0
- 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/character/character.ts +6 -0
- package/src/domain/pet/pet.ts +31 -0
- 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 +3494 -3449
- package/src/engine/combat/procsDamage.ts +31 -1
- package/src/engine/combat/sessionChase.ts +19 -2
- package/src/engine/combat/sessionMotionKit.ts +40 -2
- package/src/engine/game/loadReconcile.ts +21 -1
- package/src/engine/game/pets.ts +33 -1
- package/src/engine/game/types.ts +4 -0
- package/src/quests/quests.ts +60 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// The party stage: a dungeon pull or a squad fight drawn the way the duel is, every body small on one floor.
|
|
2
|
+
// The party stands down the left (you in your gear, the companions in theirs, the pets a size under), the pack
|
|
3
|
+
// down the right, and each column stands where its unit really is: its yards projected onto the line from
|
|
4
|
+
// where the party opened to where the pack stood, so the tank runs in, the healer hangs back and a mob that
|
|
5
|
+
// chases a caster crosses over. Under every sprite a narrow plate (name, HP, power) and a state row (a cast
|
|
6
|
+
// bar filling under the spell's name, crowd control, dead); hits rise over the head; missiles cross the lane.
|
|
7
|
+
// The boxes shrink with the crowd: the widest size whose rows and columns fit, down to the smallest, after
|
|
8
|
+
// which the furthest foes and the pets are hidden and counted on the floor.
|
|
9
|
+
import { dim, gray, fit } from './text.mjs';
|
|
10
|
+
import { SIZES, canvas, put, putLines, putRight, floorLine, narrowPlate, narrowState, auraRows, putMissiles, putPopsAbove, POP_MS, LUNGE_MS } from './tuiStage.mjs';
|
|
11
|
+
import { creatureArt, artWidth } from './modelArt.mjs';
|
|
12
|
+
import { paperdollArt } from './paperdoll.mjs';
|
|
13
|
+
import { playerArt, wornNow, wornArt } from './tuiViews.mjs';
|
|
14
|
+
import { iconsOn } from './iconArt.mjs';
|
|
15
|
+
import { allyIndexOfSide } from '../engine/core/events.ts';
|
|
16
|
+
import { combatDistanceYd, DEFAULT_OPEN_YD, MELEE_CONTACT_YD } from '../engine/combat/combatGeometry.ts';
|
|
17
|
+
|
|
18
|
+
const GAP = 1; // columns between two columns of the same side
|
|
19
|
+
const LANE = 6; // columns kept open between a party column and a foe column, for the glyphs that cross
|
|
20
|
+
const PLATE_MIN = 9; // a plate's narrowest: a 5-cell bar and "100%"
|
|
21
|
+
const PLATE_FULL = 14; // and its widest when the stage has width to spare: a 10-cell bar, a 12-letter name
|
|
22
|
+
const SWAP_YD = 2; // how much clearer of a neighbour a unit must be, in yards, before the two trade places
|
|
23
|
+
const SIZE_ORDER = ['xs', 's', 'm', 'l'];
|
|
24
|
+
// Who stands nearest the foes among units on the same spot (the melee pile opens on one): the healer last.
|
|
25
|
+
const ROLE_RANK = { healer: 0, damage: 2, tank: 3 };
|
|
26
|
+
|
|
27
|
+
/** The companion an ally seat holds: ally_N is squad[N] (the humans first), else the one of that name. */
|
|
28
|
+
function companionOf(app, v) {
|
|
29
|
+
const squad = app.g.state.squad ?? [];
|
|
30
|
+
const i = allyIndexOfSide(v.side);
|
|
31
|
+
const byIndex = i >= 0 && i < squad.length ? squad[i] : null;
|
|
32
|
+
return byIndex?.name === v.name ? byIndex : squad.find((c) => c.name === v.name) ?? byIndex;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A unit's sprite in a box: you or a companion in your form or gear, a creature by its display id. */
|
|
36
|
+
function spriteOf(app, u, cols, rows) {
|
|
37
|
+
if (u.isPlayer) return playerArt(app, cols, rows, u.v)?.lines ?? null;
|
|
38
|
+
if (u.v.creatureEntry != null) return creatureArt(app.ds.creature(u.v.creatureEntry)?.displayId, cols, rows);
|
|
39
|
+
const comp = u.friend ? companionOf(app, u.v) : null;
|
|
40
|
+
if (!comp) return null;
|
|
41
|
+
return wornArt(wornNow(app.ds, comp, u.v), cols, rows) ?? paperdollArt(comp, cols, rows);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The box a unit gets when the big ones get `size`: pets a size under, a boss a size over. */
|
|
45
|
+
function sizeOf(u, size) {
|
|
46
|
+
const i = SIZE_ORDER.indexOf(size);
|
|
47
|
+
if (u.small) return SIZE_ORDER[Math.max(0, i - 1)];
|
|
48
|
+
if (u.rank === 3) return SIZE_ORDER[Math.min(SIZE_ORDER.length - 1, i + 1)];
|
|
49
|
+
return size;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The party stage W x H: H lines each W wide, or null when the rows or the columns leave the sprites no
|
|
54
|
+
* room (the frames list draws instead). `inline` is the autopilot's run panel (the ability bar's keys dim).
|
|
55
|
+
*/
|
|
56
|
+
export function partyStage(app, ctl, W, H, { inline }) {
|
|
57
|
+
const s = ctl.session, now = ctl.clock(), mem = ctl.frames;
|
|
58
|
+
const pv = s.playerView(), pet = s.petView(), focus = s.enemyView();
|
|
59
|
+
const icons = iconsOn();
|
|
60
|
+
const petSides = new Set((s.companionPets?.() ?? []).map((p) => p.side));
|
|
61
|
+
const rankOf = (v) => { const c = v.creatureEntry != null ? app.ds.creature(v.creatureEntry) : null; return c?.rank ?? c?.scaling?.rank ?? 0; };
|
|
62
|
+
const order = (v, fallback) => ROLE_RANK[v.groupRole] ?? (v.mode === 'ranged' ? 1 : fallback);
|
|
63
|
+
const units = [
|
|
64
|
+
{ v: pv, key: 'player', isPlayer: true, friend: true, role: pv.groupRole, order: order(pv, 2.5) },
|
|
65
|
+
...(pet ? [{ v: pet, key: pet.side ?? pet.name, friend: true, small: true, order: 2 }] : []),
|
|
66
|
+
...s.alliesView().map((v) => ({ v, key: v.side, friend: true, small: petSides.has(v.side) || !!v.summoned, role: v.groupRole, order: order(v, 2) })),
|
|
67
|
+
...s.enemiesView().map((v) => ({ v, key: v.side, foe: true, focused: v === focus, rank: rankOf(v), order: v === focus ? 0 : 1 })),
|
|
68
|
+
];
|
|
69
|
+
// Rows the scene keeps whatever the sprites get: the plate (3), the state row, the floor and the ability bar
|
|
70
|
+
// (dropped under auto-play when rows are short); the aura strip only when the sprites still get a box after it.
|
|
71
|
+
const barLines = ctl.abilityRows(W, W >= 140 ? 6 : 4, { live: !inline });
|
|
72
|
+
let barRows = barLines.length;
|
|
73
|
+
const auraH = icons ? 3 : 1;
|
|
74
|
+
let fixed = 3 + 1 + 1 + barRows;
|
|
75
|
+
if (H - fixed < SIZES.xs[1] && ctl.auto) { barRows = 0; fixed = 5; }
|
|
76
|
+
const showAuras = H - fixed - auraH >= SIZES.s[1];
|
|
77
|
+
if (showAuras) fixed += auraH;
|
|
78
|
+
const artRows = H - fixed;
|
|
79
|
+
if (artRows < SIZES.xs[1]) return null;
|
|
80
|
+
// The widest size whose rows fit and whose columns fit the width: every column its sprite's width or a plate
|
|
81
|
+
// at least PLATE_MIN wide, a gap between neighbours, one lane between the sides, a margin at each edge. When
|
|
82
|
+
// even the smallest does not fit, the foes you do not face go first (from the far end), then the pets, then
|
|
83
|
+
// the party's tail, and the floor counts them.
|
|
84
|
+
const margin = Math.max(GAP, Math.floor(W / 40));
|
|
85
|
+
const columnOf = (u, size) => {
|
|
86
|
+
const box = SIZES[sizeOf(u, size)];
|
|
87
|
+
const art = spriteOf(app, u, box[0], Math.min(box[1], artRows));
|
|
88
|
+
const cw = Math.max(PLATE_MIN, art ? artWidth(art) : 0);
|
|
89
|
+
return { ...u, art, aw: artWidth(art), cw, plateW: cw };
|
|
90
|
+
};
|
|
91
|
+
const widthOf = (list) => list.reduce((n, col) => n + col.cw, 0) + (list.length - 1) * GAP + (LANE - GAP) + 2 * margin;
|
|
92
|
+
let cols = null, hidden = 0;
|
|
93
|
+
for (const size of ['m', 's', 'xs'].filter((k) => SIZES[k][1] <= artRows)) {
|
|
94
|
+
const all = units.map((u) => columnOf(u, size));
|
|
95
|
+
if (widthOf(all) <= W) { cols = all; break; }
|
|
96
|
+
if (size !== 'xs') continue;
|
|
97
|
+
const drop = (pick) => { const i = all.findLastIndex(pick); if (i < 0) return false; all.splice(i, 1); hidden++; return true; };
|
|
98
|
+
while (widthOf(all) > W && (drop((u) => u.foe && !u.focused) || drop((u) => u.small) || drop((u) => u.friend && !u.isPlayer)));
|
|
99
|
+
if (widthOf(all) <= W) cols = all;
|
|
100
|
+
}
|
|
101
|
+
if (!cols) return null;
|
|
102
|
+
// Width to spare goes to the plates, up to PLATE_FULL each, so names and bars get longer as the crowd thins.
|
|
103
|
+
const spare = Math.floor((W - widthOf(cols)) / cols.length);
|
|
104
|
+
if (spare > 0) for (const col of cols) { col.cw = Math.max(col.cw, Math.min(col.cw + spare, PLATE_FULL)); col.plateW = col.cw; }
|
|
105
|
+
// Where each unit stands in its half: by its distance in yards to the nearest living unit of the other side,
|
|
106
|
+
// from the far edge at the widest gap this fight has seen to the lane at melee contact (the duel's rule, one
|
|
107
|
+
// unit at a time). The place it opened at is its own; the closing since then goes to whoever walked it, so
|
|
108
|
+
// a mob that runs onto the healer crosses to the lane while the healer stays put, and a mage walking into
|
|
109
|
+
// range slides in while its mob stands. Melee units park all round a mob, so this is read off distances, not
|
|
110
|
+
// bearings: a rogue behind its target still stands on the party's side.
|
|
111
|
+
// A corpse, or a body with nobody living left to measure against (the last foe falls, the win holds), stays
|
|
112
|
+
// where it last stood: without this the whole party walks back to where it opened under the loot.
|
|
113
|
+
const posOf = (u) => s.unitPosYd?.(u.key);
|
|
114
|
+
mem.openPos ??= new Map(); mem.openD ??= new Map(); mem.lastC ??= new Map();
|
|
115
|
+
const nearest = (u) => {
|
|
116
|
+
let best = null;
|
|
117
|
+
for (const o of units) {
|
|
118
|
+
if (!!o.friend === !!u.friend || o.v.hp <= 0) continue;
|
|
119
|
+
const d = combatDistanceYd(posOf(u), posOf(o));
|
|
120
|
+
if (d != null && (!best || d < best.d)) best = { o, d };
|
|
121
|
+
}
|
|
122
|
+
return best;
|
|
123
|
+
};
|
|
124
|
+
const near = new Map(units.map((u) => [u.key, nearest(u)]));
|
|
125
|
+
mem.openYd = Math.max(mem.openYd ?? DEFAULT_OPEN_YD, ...[...near.values()].map((n) => n?.d ?? 0));
|
|
126
|
+
const frac = (d) => Math.max(0, Math.min(1, (d - MELEE_CONTACT_YD) / Math.max(1, mem.openYd - MELEE_CONTACT_YD)));
|
|
127
|
+
const walked = (u) => combatDistanceYd(mem.openPos.get(u.key), posOf(u)) ?? 0;
|
|
128
|
+
const closingOf = (u) => {
|
|
129
|
+
const n = near.get(u.key), p = posOf(u);
|
|
130
|
+
if (p && !mem.openPos.has(u.key)) mem.openPos.set(u.key, { ...p });
|
|
131
|
+
if (!n) return mem.openD.has(u.key) ? 1 - frac(mem.openD.get(u.key)) : 1; // no positions: everyone at the lane
|
|
132
|
+
if (!mem.openD.has(u.key)) mem.openD.set(u.key, n.d);
|
|
133
|
+
const w = walked(u), wo = walked(n.o);
|
|
134
|
+
const share = w + wo > 0 ? w / (w + wo) : u.friend ? 1 : 0;
|
|
135
|
+
const c0 = 1 - frac(mem.openD.get(u.key));
|
|
136
|
+
return Math.max(0, Math.min(1, c0 + (1 - frac(n.d) - c0) * share));
|
|
137
|
+
};
|
|
138
|
+
for (const col of cols) {
|
|
139
|
+
const held = mem.lastC.get(col.key);
|
|
140
|
+
if (held != null && (col.v.hp <= 0 || !near.get(col.key))) { col.c = held; continue; }
|
|
141
|
+
col.c = closingOf(col);
|
|
142
|
+
mem.lastC.set(col.key, col.c);
|
|
143
|
+
}
|
|
144
|
+
// The party's half and the pack's, the free width shared by head count, the lane between. In each half the
|
|
145
|
+
// columns stand by their closing, the nearest to the lane innermost; on the same spot the healer, the
|
|
146
|
+
// ranged, the melee, then the tank, and the foe you face before the rest. Then nobody overlaps: a column
|
|
147
|
+
// is pushed away from the one before it, and what runs past the half's far edge is pushed back in turn.
|
|
148
|
+
const side = (friend) => cols.filter((col) => !!col.friend === friend);
|
|
149
|
+
const widthOfSide = (list) => list.reduce((n, col) => n + col.cw, 0) + Math.max(0, list.length - 1) * GAP;
|
|
150
|
+
const friends = side(true), foes = side(false);
|
|
151
|
+
const free = Math.max(0, W - 2 * margin - LANE - widthOfSide(friends) - widthOfSide(foes));
|
|
152
|
+
const partyR = margin + widthOfSide(friends) + Math.floor((free * friends.length) / Math.max(1, cols.length));
|
|
153
|
+
const foeL = Math.min(W - margin - widthOfSide(foes), partyR + LANE);
|
|
154
|
+
const q = (col) => Math.round(col.c * 50);
|
|
155
|
+
friends.sort((a, b) => q(a) - q(b) || a.order - b.order);
|
|
156
|
+
foes.sort((a, b) => q(b) - q(a) || a.order - b.order);
|
|
157
|
+
// The lineup holds: two neighbours trade places only when one is more than SWAP_YD clearer of the other, so a
|
|
158
|
+
// melee pile that jitters by a yard does not flicker. A newcomer takes its sorted slot; the rest keep last
|
|
159
|
+
// frame's order among theirs, then adjacent pairs out of order by more than the leeway swap until none are.
|
|
160
|
+
mem.lineup ??= new Map();
|
|
161
|
+
const leeway = SWAP_YD / Math.max(1, mem.openYd - MELEE_CONTACT_YD);
|
|
162
|
+
const hold = (list, key) => {
|
|
163
|
+
const known = list.filter((col) => mem.lineup.has(col.key)).sort((a, b) => mem.lineup.get(a.key) - mem.lineup.get(b.key));
|
|
164
|
+
let k = 0;
|
|
165
|
+
for (let i = 0; i < list.length; i++) if (mem.lineup.has(list[i].key)) list[i] = known[k++];
|
|
166
|
+
for (let swapped = true; swapped;) {
|
|
167
|
+
swapped = false;
|
|
168
|
+
for (let i = 0; i + 1 < list.length; i++) {
|
|
169
|
+
if (key(list[i]) - key(list[i + 1]) <= leeway) continue;
|
|
170
|
+
[list[i], list[i + 1]] = [list[i + 1], list[i]]; swapped = true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
list.forEach((col, i) => mem.lineup.set(col.key, i));
|
|
174
|
+
};
|
|
175
|
+
hold(friends, (col) => col.c);
|
|
176
|
+
hold(foes, (col) => -col.c);
|
|
177
|
+
const place = (list, L, R) => {
|
|
178
|
+
for (const col of list) col.x = col.friend ? L + col.c * (R - col.cw - L) : R - col.cw - col.c * (R - col.cw - L);
|
|
179
|
+
for (let i = 1; i < list.length; i++) list[i].x = Math.max(list[i].x, list[i - 1].x + list[i - 1].cw + GAP);
|
|
180
|
+
for (let i = list.length - 1; i >= 0; i--) list[i].x = Math.min(list[i].x, i === list.length - 1 ? R - list[i].cw : list[i + 1].x - GAP - list[i].cw);
|
|
181
|
+
for (const col of list) col.x = Math.max(0, Math.round(col.x));
|
|
182
|
+
};
|
|
183
|
+
place(friends, margin, partyR);
|
|
184
|
+
place(foes, foeL, W - margin);
|
|
185
|
+
const zone = artRows, stateRow = zone + 3, auraTop = zone + 4, floorRow = auraTop + (showAuras ? auraH : 0), barTop = floorRow + 1;
|
|
186
|
+
const c = canvas(W, H);
|
|
187
|
+
for (const col of cols) {
|
|
188
|
+
const v = col.v, dead = v.hp <= 0;
|
|
189
|
+
// The sprite stands on its plate, centred over it; a swing lunges it two columns toward the other side.
|
|
190
|
+
if (col.art) {
|
|
191
|
+
const lunge = now - (mem.lunge?.get(col.key) ?? -Infinity) < LUNGE_MS ? (col.friend ? 2 : -2) : 0;
|
|
192
|
+
col.ax = col.x + Math.max(0, Math.floor((col.cw - col.aw) / 2)) + lunge; col.top = zone - col.art.length;
|
|
193
|
+
putLines(c, col.ax, col.top, col.art);
|
|
194
|
+
}
|
|
195
|
+
putLines(c, col.x, zone, narrowPlate(app, v, { ...col, dead, w: col.cw }));
|
|
196
|
+
const state = narrowState(v, now, col.cw, mem.casts?.get(col.key));
|
|
197
|
+
if (state) put(c, col.x, stateRow, state);
|
|
198
|
+
if (showAuras && !col.small && !dead) putLines(c, col.x, auraTop, auraRows(app, mem, v, col.key, col.cw, icons));
|
|
199
|
+
putPopsAbove(c, col, (mem.pops.get(col.key) ?? []).filter((p) => now - p.t < POP_MS), now, zone);
|
|
200
|
+
}
|
|
201
|
+
putMissiles(c, s, cols, now, Math.max(0, zone - 1 - Math.floor(Math.min(artRows, 10) / 2)), (id) => app.ds.spell(id)?.name);
|
|
202
|
+
c[floorRow] = floorLine(W, gray);
|
|
203
|
+
if (hidden) putRight(c, W - 1, floorRow, dim(` +${hidden} more in the fight `));
|
|
204
|
+
if (barRows) putLines(c, 0, barTop, barLines);
|
|
205
|
+
return c.map((l) => fit(l, W));
|
|
206
|
+
}
|
package/src/cli/tuiPet.mjs
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// Your pet (hunter, warlock): the page with command, react, food and abilities; the stable; the pet trainer.
|
|
2
2
|
// Ports phonecraft playChrome.ts's feed modal, render/petBar.ts, panels/stablePanel.ts and the pet trainer rows.
|
|
3
|
-
import { bold, dim, cyan, yellow, green, red, itemText } from './text.mjs';
|
|
3
|
+
import { bold, dim, cyan, yellow, green, red, itemText, coinText } from './text.mjs';
|
|
4
4
|
import { itemLines, spellLines } from './tuiViews.mjs';
|
|
5
|
-
import {
|
|
6
|
-
import { LOYALTY_NAMES, petHasHappiness, petKnownSpells, petAutocastOn } from '../domain/pet/pet.ts';
|
|
5
|
+
import { LOYALTY_NAMES, HAPPINESS_MAX, petHasHappiness, petKnownSpells, petAutocastOn } from '../domain/pet/pet.ts';
|
|
7
6
|
import { MAX_PET_STABLES, STABLE_SLOT_PRICES } from '../domain/pet/petStable.ts';
|
|
8
7
|
import { zonePetTrainers } from '../world/world.ts';
|
|
9
8
|
import { MAX_SOUL_SHARD_CAP } from '../autopilot/config.ts';
|
|
@@ -15,7 +14,7 @@ export const hasPetClass = (app) => app.ch.cls === HUNTER || app.ch.cls === WARL
|
|
|
15
14
|
function petStatus(app, pet) {
|
|
16
15
|
const cdef = app.ds.creature(pet.creature);
|
|
17
16
|
const bits = [`${pet.health <= 0 ? red('dead') : `${pet.health} health`}`];
|
|
18
|
-
if (cdef && petHasHappiness(cdef)) bits.push(`happiness ${pet.happiness}%`, LOYALTY_NAMES[pet.loyalty ?? 1] ?? '');
|
|
17
|
+
if (cdef && petHasHappiness(cdef)) bits.push(`happiness ${Math.round((pet.happiness / HAPPINESS_MAX) * 100)}%`, LOYALTY_NAMES[pet.loyalty ?? 1] ?? '');
|
|
19
18
|
if (pet.trainingPoints != null) bits.push(`${pet.trainingPoints} training points`);
|
|
20
19
|
return bits.filter(Boolean).join(', ');
|
|
21
20
|
}
|
|
@@ -81,7 +80,7 @@ function stableRows(app) {
|
|
|
81
80
|
rows.push({ text: ` Slot ${i + 1}: ${p ? `${p.name} ${dim(app.ds.creature(p.creature)?.name ?? '')}` : dim('empty')}`, detail: () => [bold(`Stable slot ${i + 1}`), '', p ? `Enter: call ${p.name}${ch.pet ? `, stabling ${ch.pet.name} in its place` : ''}.` : 'Empty.'], act: () => { if (p) app.result(g.callStabledPet(i + 1), `${p.name} comes to you.`); } });
|
|
82
81
|
}
|
|
83
82
|
if (ch.pet || ch.storedPet) rows.push({ text: `Stable ${(ch.pet ?? ch.storedPet).name}`, act: () => { const r = g.stablePet(); app.result(r, r.ok ? `Stabled in slot ${r.slot}.` : ''); } });
|
|
84
|
-
if (slots < MAX_PET_STABLES) rows.push({ text: `Buy a stable slot ${
|
|
83
|
+
if (slots < MAX_PET_STABLES) rows.push({ text: `Buy a stable slot ${coinText(STABLE_SLOT_PRICES[slots] ?? 0)}`, act: () => { const r = g.buyStableSlot(); app.result(r, r.ok ? `A stable slot for ${coinText(r.spent)}.` : ''); } });
|
|
85
84
|
return rows;
|
|
86
85
|
}
|
|
87
86
|
|
|
@@ -99,13 +98,22 @@ export function petTrainerDialog(app, trainer) {
|
|
|
99
98
|
id: `pettrainer-${trainer.entry}`, title: trainer.name, hint: 'Enter: buy the rank',
|
|
100
99
|
rows(a) {
|
|
101
100
|
const known = new Set(a.ch.petSpells ?? []);
|
|
102
|
-
const rows = [H(`${bold(trainer.name)} ${dim('<Pet Trainer>')} ${
|
|
101
|
+
const rows = [H(`${bold(trainer.name)} ${dim('<Pet Trainer>')} ${coinText(a.ch.gold)}`), H(dim('A rank bought here is yours for good; Beast Training teaches it to whichever pet is out.')), H('')];
|
|
102
|
+
const pet = a.ch.pet, untrainCost = a.g.untrainPetCost();
|
|
103
|
+
if (pet && untrainCost != null) {
|
|
104
|
+
const taught = pet.taughtSpells?.length ?? 0;
|
|
105
|
+
rows.push({
|
|
106
|
+
text: `Untrain ${pet.name} ${coinText(untrainCost)}${taught ? '' : dim(' nothing taught')}`,
|
|
107
|
+
detail: () => [bold('Untrain pet'), '', `${pet.name} forgets the ${taught} abilit${taught === 1 ? 'y' : 'ies'} you taught it and gets every training point back to spend again. Its innate abilities stay, and the ranks you bought stay in Beast Training.`, '', `Cost: ${coinText(untrainCost)}. It climbs with each untrain, up to 10 gold, and falls back to 10 silver a day later.`, '', dim('Enter: untrain')],
|
|
108
|
+
act: () => a.confirm(`Untrain ${pet.name} for ${coinText(untrainCost)}?`, () => { const r = a.g.untrainPet(trainer.entry); a.result(r, r.ok ? `${pet.name} forgets its training: ${a.ch.pet?.trainingPoints ?? 0} training points to spend.` : ''); }),
|
|
109
|
+
}, H(''));
|
|
110
|
+
}
|
|
103
111
|
const list = trainer.spells.map((ts) => ({ ts, sp: a.ds.spell(ts.spell) })).filter((x) => x.sp).sort((x, y) => x.ts.reqLevel - y.ts.reqLevel || x.sp.name.localeCompare(y.sp.name));
|
|
104
112
|
for (const { ts, sp } of list) {
|
|
105
113
|
const have = known.has(sp.id), low = ts.reqLevel > a.ch.level;
|
|
106
114
|
rows.push({
|
|
107
|
-
text: `${have ? green('✓ ') : ' '}${low || have ? dim(sp.name) : sp.name}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''} ${
|
|
108
|
-
detail: () => spellLines(a, sp, ['', `Cost: ${
|
|
115
|
+
text: `${have ? green('✓ ') : ' '}${low || have ? dim(sp.name) : sp.name}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''} ${coinText(ts.cost, 'free')}${low ? red(` level ${ts.reqLevel}`) : ''}`,
|
|
116
|
+
detail: () => spellLines(a, sp, ['', `Cost: ${coinText(ts.cost, 'free')}`]),
|
|
109
117
|
act: () => { if (!have) a.result(a.g.buyPetSpell(trainer.entry, sp.id), `You learn ${sp.name} for your pets.`); },
|
|
110
118
|
});
|
|
111
119
|
}
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// Send goods. Ported from phonecraft's professionsPanel.ts and the vendorPanel.ts send-goods overlay.
|
|
4
4
|
// A craft buys short vendor reagents (vials, thread, flux) from your purse on the way: the buy-in phonecraft
|
|
5
5
|
// shows as a coin chip on Create. Views only read; every change is a Game facade call.
|
|
6
|
-
import { bold, dim, cyan, yellow, green, red, gray, orange, qualityPaint, fit, plural, secondsText, wrapLines, itemText } from './text.mjs';
|
|
6
|
+
import { bold, dim, cyan, yellow, green, red, gray, orange, qualityPaint, fit, plural, secondsText, wrapLines, itemText, coinText } from './text.mjs';
|
|
7
7
|
import { itemLines } from './tuiViews.mjs';
|
|
8
8
|
import { missionDialog, companionDialog } from './tuiCompanions.mjs';
|
|
9
|
-
import {
|
|
9
|
+
import { coinsText } from '../domain/economy/money.ts';
|
|
10
10
|
import { className } from '../domain/core/ids.ts';
|
|
11
11
|
import { countItem, emptyInventory } from '../domain/items/inventory.ts';
|
|
12
12
|
import { equippedEntry } from '../domain/items/itemInstance.ts';
|
|
@@ -102,7 +102,7 @@ function craftCheck(app, cr, r, env) {
|
|
|
102
102
|
if (chk.reason !== 'Missing reagents') return { ok: false, reason: chk.reason, cost: 0, buys: [], max: 0 };
|
|
103
103
|
const b = vendorBuyInReady(app.ds, app.ch, r.reagents, cr.inv);
|
|
104
104
|
if (!b.ok) {
|
|
105
|
-
const reason = b.blocked.length ? `Missing ${b.blocked.map((i) => app.itemLabel(i)).join(', ')}` : `Need ${
|
|
105
|
+
const reason = b.blocked.length ? `Missing ${b.blocked.map((i) => app.itemLabel(i)).join(', ')}` : `Need ${coinText(b.cost)} for ${buyList(app, b.buys)}`;
|
|
106
106
|
return { ok: false, short: true, reason, cost: b.cost, buys: b.buys, max: 0 };
|
|
107
107
|
}
|
|
108
108
|
const again = canCraft(cr.char, applyVendorBuys(app.ds, cr.inv, b.buys), r, env.focus, null, env.tools);
|
|
@@ -112,7 +112,7 @@ function craftEnv(app) { return { focus: zoneFocus(app), tools: sharedTools(app)
|
|
|
112
112
|
|
|
113
113
|
/** One status chip for a recipe row: how many you can make, what the buy-in costs, or why not. */
|
|
114
114
|
function craftChip(chk) {
|
|
115
|
-
if (chk.ok) return green(`${chk.max} ready`) + (chk.cost ? yellow(` buys ${
|
|
115
|
+
if (chk.ok) return green(`${chk.max} ready`) + (chk.cost ? yellow(` buys ${coinText(chk.cost)}`) : '');
|
|
116
116
|
return (chk.short ? dim : red)(chk.reason ?? 'cannot make');
|
|
117
117
|
}
|
|
118
118
|
|
|
@@ -143,7 +143,7 @@ function craftMany(app, cr, r, want, all = false) {
|
|
|
143
143
|
if (!made) { app.say(red(reason ?? 'Cannot make that.')); return; }
|
|
144
144
|
const prod = app.ds.item(r.product.item);
|
|
145
145
|
app.say(green(`${cr.id ? `${cr.name} creates` : 'You create'} ${itemText(prod, r.name)}${units > 1 ? ` x${units}` : ''}.`)
|
|
146
|
-
+ (spent ? yellow(` Vendor reagents ${
|
|
146
|
+
+ (spent ? yellow(` Vendor reagents ${coinText(spent)}.`) : '')
|
|
147
147
|
+ (made < n && reason ? dim(` Stopped after ${made}: ${reason}`) : ''));
|
|
148
148
|
if (skill != null) app.say(cyan(`${whose(cr)} ${app.ds.profession(r.skill)?.name ?? r.skill} skill is now ${skill}.`));
|
|
149
149
|
for (const l of other.reverse()) app.say(dim('· ' + coinsText(l)));
|
|
@@ -193,7 +193,7 @@ function reagentLines(app, reagents, tools, inv) {
|
|
|
193
193
|
const vendor = !ok && it && unlimitedVendorFor(app.ds, rg.item, app.ch.race) != null;
|
|
194
194
|
const lot = Math.max(1, it?.buyCount ?? 1);
|
|
195
195
|
const cost = vendor ? Math.ceil((rg.count - have) / lot) * it.buyPrice : 0;
|
|
196
|
-
out.push(` ${(ok ? green : vendor ? yellow : red)(`${have}/${rg.count}`)} ${app.itemLabel(rg.item)}${vendor ? dim(` vendor ${
|
|
196
|
+
out.push(` ${(ok ? green : vendor ? yellow : red)(`${have}/${rg.count}`)} ${app.itemLabel(rg.item)}${vendor ? dim(` vendor ${coinText(cost)}`) : ''}`);
|
|
197
197
|
}
|
|
198
198
|
for (const t of tools ?? []) {
|
|
199
199
|
const ok = countItem(inv, t) > 0 || sharedTools(app).has(t);
|
|
@@ -214,7 +214,7 @@ function recipeDetail(app, cr, r, chk, who) {
|
|
|
214
214
|
out.push(...wearerLines(app, r.product.item, who));
|
|
215
215
|
out.push('');
|
|
216
216
|
if (chk.ok) {
|
|
217
|
-
if (chk.cost) out.push(yellow(`Buys ${buyList(app, chk.buys)} for ${
|
|
217
|
+
if (chk.cost) out.push(yellow(`Buys ${buyList(app, chk.buys)} for ${coinText(chk.cost)} from your purse.`));
|
|
218
218
|
out.push(green(`Enter: make one A: make ${chk.max} N: a number`));
|
|
219
219
|
} else out.push(red(chk.reason ?? 'Cannot make this.'));
|
|
220
220
|
if (cr.id) out.push('', dim(`${cr.name} crafts from the whole party's bags and banks. What they make lands in their bags.`));
|
|
@@ -246,7 +246,7 @@ export function recipeBook(app, crafterId, skill) {
|
|
|
246
246
|
const cr = current();
|
|
247
247
|
const sk = cr.char.skills[skill];
|
|
248
248
|
const env = craftEnv(a);
|
|
249
|
-
const rows = [H(`${bold(cr.name)} ${prof?.name ?? skill} ${sk ? `${sk.current}/${sk.max}` : dim('untrained')}${sk && prof ? dim(` ${currentRank(cr.char, prof)?.name ?? ''}`) : ''} ${
|
|
249
|
+
const rows = [H(`${bold(cr.name)} ${prof?.name ?? skill} ${sk ? `${sk.current}/${sk.max}` : dim('untrained')}${sk && prof ? dim(` ${currentRank(cr.char, prof)?.name ?? ''}`) : ''} ${coinText(a.ch.gold)}`)];
|
|
250
250
|
const filters = [haveOnly && 'only what you can make', search && `matching "${search}"`].filter(Boolean);
|
|
251
251
|
rows.push(H(dim(filters.length ? `Showing ${filters.join(', ')}.` : 'c: switch crafter h: only what you can make /: search')));
|
|
252
252
|
if (GATHER_LINES.has(skill)) rows.push(...gatherRows(a, skill, cr));
|
|
@@ -268,6 +268,7 @@ export function recipeBook(app, crafterId, skill) {
|
|
|
268
268
|
const name = DIFF_PAINT[diffOf(cr, skill, r)](r.name);
|
|
269
269
|
rows.push({
|
|
270
270
|
text: `${fit(name + (who.length ? green(' ▲') : ''), W)} ${craftChip(chk)}`, data: { recipe: r, chk, cr },
|
|
271
|
+
icon: prod?.icon, sub: dim((r.reagents ?? []).map((x) => `${a.ds.item(x.item)?.name ?? x.item}${x.count > 1 ? ` x${x.count}` : ''}`).join(', ')),
|
|
271
272
|
detail: () => recipeDetail(a, cr, r, chk, who),
|
|
272
273
|
act: () => (chk.ok ? craftMany(a, cr, r, 1) : a.say(red(chk.reason ?? `Cannot make ${prod?.name ?? r.name}.`))),
|
|
273
274
|
});
|
|
@@ -358,7 +359,7 @@ function enchantCheck(app, cr, e, env) {
|
|
|
358
359
|
if (e.reagents.every((rg) => countItem(cr.inv, rg.item) >= rg.count)) return { ok: true, cost: 0, buys: [] };
|
|
359
360
|
const b = vendorBuyInReady(app.ds, app.ch, e.reagents, cr.inv);
|
|
360
361
|
if (b.ok) return { ok: true, cost: b.cost, buys: b.buys };
|
|
361
|
-
return { ok: false, short: true, reason: b.blocked.length ? `Missing ${b.blocked.map((i) => app.itemLabel(i)).join(', ')}` : `Need ${
|
|
362
|
+
return { ok: false, short: true, reason: b.blocked.length ? `Missing ${b.blocked.map((i) => app.itemLabel(i)).join(', ')}` : `Need ${coinText(b.cost)}`, cost: b.cost, buys: b.buys };
|
|
362
363
|
}
|
|
363
364
|
|
|
364
365
|
/** Whose worn piece can take this enchant: you and the party, one option per matching slot. */
|
|
@@ -405,7 +406,7 @@ function enchantBench(app, crafterId) {
|
|
|
405
406
|
const cr = current(), ds = a.ds;
|
|
406
407
|
const sk = cr.char.skills.enchanting;
|
|
407
408
|
const env = craftEnv(a);
|
|
408
|
-
const rows = [H(`${bold(cr.name)} Enchanting ${sk ? `${sk.current}/${sk.max}` : dim('untrained')} ${dim(`rod: ${bestRodTier(cr.inv, ds, env.tools) ? rodNameForTier(bestRodTier(cr.inv, ds, env.tools), ds) : 'none'}`)} ${
|
|
409
|
+
const rows = [H(`${bold(cr.name)} Enchanting ${sk ? `${sk.current}/${sk.max}` : dim('untrained')} ${dim(`rod: ${bestRodTier(cr.inv, ds, env.tools) ? rodNameForTier(bestRodTier(cr.inv, ds, env.tools), ds) : 'none'}`)} ${coinText(a.ch.gold)}`), H(dim('c: switch enchanter'))];
|
|
409
410
|
const known = new Set(cr.char.knownEnchants ?? []);
|
|
410
411
|
const formulas = ds.enchants().filter((e) => known.has(e.id)).sort((x, y) => y.orange - x.orange || x.name.localeCompare(y.name));
|
|
411
412
|
const W = Math.max(20, Math.min(34, a.layout().listW - 31));
|
|
@@ -414,7 +415,7 @@ function enchantBench(app, crafterId) {
|
|
|
414
415
|
for (const e of formulas) {
|
|
415
416
|
const chk = enchantCheck(a, cr, e, env);
|
|
416
417
|
rows.push({
|
|
417
|
-
text: `${fit(DIFF_PAINT[diffOf(cr, 'enchanting', e)](e.name.replace(/^Enchant\s+/, '')), W)} ${chk.ok ? green('ready') + (chk.cost ? yellow(` buys ${
|
|
418
|
+
text: `${fit(DIFF_PAINT[diffOf(cr, 'enchanting', e)](e.name.replace(/^Enchant\s+/, '')), W)} ${chk.ok ? green('ready') + (chk.cost ? yellow(` buys ${coinText(chk.cost)}`) : '') : (chk.short ? dim : red)(chk.reason)}`,
|
|
418
419
|
detail: () => [bold(e.name), dim(`goes on: ${e.slots.join(', ')} ${rodNameForTier(e.minRodTier, ds)}`), '', ...reagentLines(a, e.reagents, [], cr.inv), '',
|
|
419
420
|
`${DIFF_PAINT[diffOf(cr, 'enchanting', e)](diffOf(cr, 'enchanting', e))} ${upPct(cr, 'enchanting', e) ? `${upPct(cr, 'enchanting', e)}% skill-up` : dim('no skill-up')}`, '',
|
|
420
421
|
chk.ok ? green('Enter: pick whose gear to enchant') : red(chk.reason)],
|
|
@@ -488,7 +489,7 @@ function partyBook(app) {
|
|
|
488
489
|
groups.set(r.skill, g);
|
|
489
490
|
}
|
|
490
491
|
const ready = [...byRecipe.values()].filter((o) => o.some((x) => x.chk.ok)).length;
|
|
491
|
-
const rows = [H(`${plural(byRecipe.size, 'recipe')} known across ${plural(all.length, 'crafter')}, ${ready} ready ${
|
|
492
|
+
const rows = [H(`${plural(byRecipe.size, 'recipe')} known across ${plural(all.length, 'crafter')}, ${ready} ready ${coinText(a.ch.gold)}`), H(dim(haveOnly ? 'Showing what someone can make now. h shows everything.' : 'Showing every known recipe. h shows only what someone can make.'))];
|
|
492
493
|
if (!groups.size) rows.push(H(''), H(dim(byRecipe.size ? ' Nobody can make anything right now. h shows every recipe.' : ' Nobody in the party knows a recipe yet.')));
|
|
493
494
|
const W = Math.max(20, Math.min(34, a.layout().listW - 34));
|
|
494
495
|
for (const [skill, list] of [...groups].sort((x, y) => x[0].localeCompare(y[0]))) {
|
|
@@ -526,9 +527,9 @@ function specRows(app, trainer) {
|
|
|
526
527
|
const name = sp?.name ?? it.kind.replace(/^(un)?learn_/, '');
|
|
527
528
|
const cost = it.costCopper ?? 0;
|
|
528
529
|
rows.push({
|
|
529
|
-
text: `${unlearn ? red(`Unlearn ${name}`) : green(`Become a specialist: ${name}`)}${cost ? ` ${
|
|
530
|
-
detail: () => [bold(name), '', unlearn ? 'Forget this specialisation and the recipes that go with it.' : 'A specialisation opens its own recipes. You can hold one at a time.', ...(cost ? ['', `Cost ${
|
|
531
|
-
act: (a) => (unlearn ? a.confirm(`Unlearn ${name}${cost ? ` for ${
|
|
530
|
+
text: `${unlearn ? red(`Unlearn ${name}`) : green(`Become a specialist: ${name}`)}${cost ? ` ${coinText(cost)}` : ''}`,
|
|
531
|
+
detail: () => [bold(name), '', unlearn ? 'Forget this specialisation and the recipes that go with it.' : 'A specialisation opens its own recipes. You can hold one at a time.', ...(cost ? ['', `Cost ${coinText(cost)}`] : [])],
|
|
532
|
+
act: (a) => (unlearn ? a.confirm(`Unlearn ${name}${cost ? ` for ${coinText(cost)}` : ''}? Its recipes are forgotten.`, () => applySpec(a, trainer, it, name, true)) : applySpec(a, trainer, it, name, false)),
|
|
532
533
|
});
|
|
533
534
|
}
|
|
534
535
|
rows.push(H(''));
|
|
@@ -568,21 +569,21 @@ export function profTrainerDialog(app, trainer) {
|
|
|
568
569
|
const before = a.ch.gold;
|
|
569
570
|
if (learn(a, t).ok) { n++; spent += before - a.ch.gold; }
|
|
570
571
|
}
|
|
571
|
-
a.say(n ? green(`You learn ${plural(n, 'recipe')}${spent ? ` for ${
|
|
572
|
+
a.say(n ? green(`You learn ${plural(n, 'recipe')}${spent ? ` for ${coinText(spent)}` : ''}.`) : dim('Nothing you can learn yet.'));
|
|
572
573
|
} },
|
|
573
574
|
},
|
|
574
575
|
rows(a) {
|
|
575
576
|
const ch = a.ch, sk = ch.skills[trainer.skill];
|
|
576
577
|
const mod = vendorPriceMultiplierFor(ds, ch, trainer.entry);
|
|
577
|
-
const rows = [H(`${bold(trainer.name)} ${dim(`<${prof?.name ?? trainer.skill} Trainer>`)} ${
|
|
578
|
+
const rows = [H(`${bold(trainer.name)} ${dim(`<${prof?.name ?? trainer.skill} Trainer>`)} ${coinText(ch.gold)}`), H(dim(sk ? `Your ${prof?.name} skill: ${sk.current}/${sk.max}` : `You do not know ${prof?.name ?? trainer.skill} yet.`)), H('')];
|
|
578
579
|
const nr = prof ? nextTrainerRank(ch, prof) : undefined;
|
|
579
580
|
if (nr) {
|
|
580
581
|
const cost = trainerSpellCost(nr.cost, mod);
|
|
581
582
|
const need = nextRankReqSkill(nr, sk);
|
|
582
583
|
const why = ch.level < nr.reqLevel ? `requires level ${nr.reqLevel}` : (sk?.current ?? 0) < need ? `requires ${prof.name} ${need}` : ch.gold < cost ? 'not enough money' : '';
|
|
583
584
|
rows.push(H('Rank'), {
|
|
584
|
-
text: `${why ? dim(`${sk ? 'Train' : 'Learn'} ${nr.name} ${prof.name}`) : green(`${sk ? 'Train' : 'Learn'} ${nr.name} ${prof.name}`)} ${
|
|
585
|
-
detail: () => [bold(`${nr.name} ${prof.name}`), `Raises your ${prof.name} cap to ${nr.cap}.`, '', `Requires level ${nr.reqLevel}${need ? `, ${prof.name} ${need}` : ''}`, `Cost ${
|
|
585
|
+
text: `${why ? dim(`${sk ? 'Train' : 'Learn'} ${nr.name} ${prof.name}`) : green(`${sk ? 'Train' : 'Learn'} ${nr.name} ${prof.name}`)} ${coinText(cost, 'free')}${why ? red(` ${why}`) : ''}`,
|
|
586
|
+
detail: () => [bold(`${nr.name} ${prof.name}`), `Raises your ${prof.name} cap to ${nr.cap}.`, '', `Requires level ${nr.reqLevel}${need ? `, ${prof.name} ${need}` : ''}`, `Cost ${coinText(cost, 'free')}`, '', prof.kind !== 'secondary' && !sk ? dim('You can hold two primary professions. Cooking, First Aid and Fishing do not count.') : ''],
|
|
586
587
|
act: () => a.result(a.g.learnProfession(trainer.entry), `You are now a${/^[AEIOU]/.test(nr.name) ? 'n' : ''} ${nr.name} ${prof.name}.`),
|
|
587
588
|
}, H(''));
|
|
588
589
|
} else if (sk) rows.push(H(dim(prof?.kind === 'secondary' && sk.max < 300 ? 'Expert and Artisan come from books, not trainers.' : 'You hold the highest rank they teach.')), H(''));
|
|
@@ -597,14 +598,14 @@ export function profTrainerDialog(app, trainer) {
|
|
|
597
598
|
const cost = trainerSpellCost(t.def.cost ?? 0, mod);
|
|
598
599
|
const prod = t.kind === 'recipe' ? ds.item(t.def.product.item) : null;
|
|
599
600
|
rows.push({
|
|
600
|
-
text: `${fit(ch.gold >= cost ? t.def.name : dim(t.def.name), W)} ${
|
|
601
|
-
detail: () => [...(prod ? itemLines(a, prod).filter((l) => !/Sells for/.test(l)) : [bold(t.def.name), dim(`goes on: ${t.def.slots.join(', ')}`)]), '', ...reagentLines(a, t.def.reagents, t.def.requiredTools, a.g.state.inventory), '', dim(`Requires ${prof?.name} ${t.def.orange} cost ${
|
|
601
|
+
text: `${fit(ch.gold >= cost ? t.def.name : dim(t.def.name), W)} ${coinText(cost, 'free')}`,
|
|
602
|
+
detail: () => [...(prod ? itemLines(a, prod).filter((l) => !/Sells for/.test(l)) : [bold(t.def.name), dim(`goes on: ${t.def.slots.join(', ')}`)]), '', ...reagentLines(a, t.def.reagents, t.def.requiredTools, a.g.state.inventory), '', dim(`Requires ${prof?.name} ${t.def.orange} cost ${coinText(cost, 'free')}`)],
|
|
602
603
|
act: () => a.result(learn(a, t), `You learn ${t.def.name}.`),
|
|
603
604
|
});
|
|
604
605
|
}
|
|
605
606
|
if (later.length) {
|
|
606
607
|
rows.push(H(''), H('Coming up'));
|
|
607
|
-
for (const t of later) rows.push(H(dim(` ${fit(t.def.name, W)} ${prof?.name} ${t.def.orange}${t.reqLevel > ch.level ? `, level ${t.reqLevel}` : ''} ${
|
|
608
|
+
for (const t of later) rows.push(H(dim(` ${fit(t.def.name, W)} ${prof?.name} ${t.def.orange}${t.reqLevel > ch.level ? `, level ${t.reqLevel}` : ''} ${coinText(trainerSpellCost(t.def.cost ?? 0, mod))}`)));
|
|
608
609
|
}
|
|
609
610
|
return rows;
|
|
610
611
|
},
|
|
@@ -751,6 +752,7 @@ export function professionsView(app) {
|
|
|
751
752
|
const away = cr.comp?.mission?.skill === prof.id;
|
|
752
753
|
return {
|
|
753
754
|
text: `${cr.id ? `${fit(cr.name, 10)} ` : ''}${fit(prof.name, 15)} ${fit(`${sk.current}/${sk.max}`, 8)}${ready ? green(`${ready} ready`) : GATHER_LINES.has(prof.id) ? dim(prof.kind) : ''}${away ? yellow(' away') : ''}`,
|
|
755
|
+
icon: prof.icon, sub: dim(currentRank(cr.char, prof)?.name ?? ''),
|
|
754
756
|
data: { cr, skill: prof.id },
|
|
755
757
|
detail: () => profRowLines(a, cr, prof, sk, ready),
|
|
756
758
|
act: () => a.push(recipeBook(a, cr.id, prof.id)),
|
|
@@ -759,7 +761,7 @@ export function professionsView(app) {
|
|
|
759
761
|
};
|
|
760
762
|
const you = crafterOf(a, null);
|
|
761
763
|
const mine = profRows(you);
|
|
762
|
-
const rows = [H(`Your professions ${
|
|
764
|
+
const rows = [H(`Your professions ${coinText(a.ch.gold)}`)];
|
|
763
765
|
if (mine.length) rows.push(...mine);
|
|
764
766
|
else rows.push(H(dim(' You have no professions yet. Profession trainers stand in towns and capitals (World tab).')));
|
|
765
767
|
const comps = companionsOf(g);
|
|
@@ -785,13 +787,13 @@ export function professionsView(app) {
|
|
|
785
787
|
const prof = ds.profession(t.skill);
|
|
786
788
|
const sk = a.ch.skills[t.skill];
|
|
787
789
|
const newOnes = sk ? ds.recipes().filter((r) => r.skill === t.skill && r.source === 'trainer' && !a.ch.knownRecipes.includes(r.id) && sk.current >= r.orange && a.ch.level >= (r.reqLevel ?? 0)).length : 0;
|
|
788
|
-
rows.push({ text: `${t.name} ${dim(`<${prof?.name ?? t.skill} Trainer>`)}${newOnes ? green(` ${newOnes} to learn`) : ''}`, detail: () => [bold(t.name), `${prof?.name ?? t.skill} trainer`, '', dim('Enter: ranks and recipes')], act: () => a.push(profTrainerDialog(a, t)) });
|
|
790
|
+
rows.push({ text: `${t.name} ${dim(`<${prof?.name ?? t.skill} Trainer>`)}${newOnes ? green(` ${newOnes} to learn`) : ''}`, icon: prof?.icon, sub: dim(sk ? `your skill ${sk.current}/${sk.max}` : 'not learned'), detail: () => [bold(t.name), `${prof?.name ?? t.skill} trainer`, '', dim('Enter: ranks and recipes')], act: () => a.push(profTrainerDialog(a, t)) });
|
|
789
791
|
}
|
|
790
792
|
}
|
|
791
793
|
const books2 = [];
|
|
792
794
|
g.state.inventory.stacks.forEach((s) => {
|
|
793
795
|
const it = ds.item(s.item), teach = itemTeachIntent(it, ds);
|
|
794
|
-
if (teach?.kind === 'recipe' || teach?.kind === 'enchant') books2.push({ text: `${qualityPaint(it.quality)(it.name)}${s.count > 1 ? ` x${s.count}` : ''}`, detail: () => itemLines(a, it, ['', dim('Enter: pick who learns it (you or a companion with the craft)')]), act: () => teachFromItem(a, s) });
|
|
796
|
+
if (teach?.kind === 'recipe' || teach?.kind === 'enchant') books2.push({ text: `${qualityPaint(it.quality)(it.name)}${s.count > 1 ? ` x${s.count}` : ''}`, icon: it.icon, sub: dim(teach.kind), detail: () => itemLines(a, it, ['', dim('Enter: pick who learns it (you or a companion with the craft)')]), act: () => teachFromItem(a, s) });
|
|
795
797
|
});
|
|
796
798
|
if (books2.length) rows.push(H(''), H('Recipes in your bags'), ...books2);
|
|
797
799
|
return rows;
|