azeroth-shell 0.2.0 → 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 +43 -10
- package/bin/azsh.mjs +15 -1
- package/package.json +5 -2
- package/src/cli/hdArt.mjs +1 -1
- package/src/cli/iconArt.mjs +11 -1
- package/src/cli/modelArt.mjs +7 -4
- package/src/cli/oocPrep.mjs +32 -0
- package/src/cli/repl.mjs +16 -0
- package/src/cli/sixel.mjs +26 -14
- package/src/cli/text.mjs +16 -0
- package/src/cli/tui.mjs +33 -9
- package/src/cli/tuiAutopilot.mjs +17 -8
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiCheck.mjs +276 -15
- package/src/cli/tuiCity.mjs +31 -31
- package/src/cli/tuiCompanionKit.mjs +6 -6
- package/src/cli/tuiCompanions.mjs +189 -29
- package/src/cli/tuiCreate.mjs +7 -0
- package/src/cli/tuiDungeon.mjs +56 -20
- package/src/cli/tuiFight.mjs +63 -21
- package/src/cli/tuiFightPanes.mjs +15 -6
- package/src/cli/tuiFloor.mjs +20 -8
- package/src/cli/tuiGear.mjs +9 -10
- package/src/cli/tuiLoot.mjs +5 -6
- package/src/cli/tuiMap.mjs +214 -0
- package/src/cli/tuiPaperdoll.mjs +6 -5
- package/src/cli/tuiPartyStage.mjs +36 -7
- package/src/cli/tuiPet.mjs +16 -8
- package/src/cli/tuiProfessions.mjs +23 -23
- package/src/cli/tuiSaves.mjs +14 -0
- package/src/cli/tuiSearch.mjs +4 -5
- package/src/cli/tuiSettings.mjs +3 -4
- package/src/cli/tuiStage.mjs +72 -16
- package/src/cli/tuiTalents.mjs +3 -4
- package/src/cli/tuiViews.mjs +371 -84
- package/src/cli/tuiWiki.mjs +2 -3
- package/src/cli/tuiWorld.mjs +88 -5
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +36 -36
- package/src/data/model-art-worn.bin.br +0 -0
- package/src/data/model-art.bin.gz +0 -0
- 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/character/character.ts +6 -0
- package/src/domain/pet/pet.ts +31 -0
- package/src/engine/combat/pickAction.ts +3494 -3487
- package/src/engine/combat/sessionChase.ts +12 -1
- 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/cli/tuiPaperdoll.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The Character tab as a sheet: you in your gear in the middle, the worn pieces' icons down both sides
|
|
2
2
|
// the way the client's paperdoll lays them out (head to wrists on the left, hands to trinkets on the right,
|
|
3
3
|
// the weapons and the bag bar underneath), each framed in its quality colour. Arrows move between the
|
|
4
4
|
// slots (Up and Down in order, Shift+arrows across the picture), the detail pane is the piece's tooltip, and Enter, x and r act on the slot as they do in the list.
|
|
@@ -35,7 +35,7 @@ function sheetSize(w, h, extras) {
|
|
|
35
35
|
return null;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/** Will the
|
|
38
|
+
/** Will the Character tab draw as the sheet right now? `extras` is how many rows follow the slots. */
|
|
39
39
|
export function paperdollPlan(app, extras) {
|
|
40
40
|
if (!iconsOn()) return null;
|
|
41
41
|
const L = app.layout();
|
|
@@ -44,9 +44,10 @@ export function paperdollPlan(app, extras) {
|
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* The sheet's lines for the gear view's `rows` in a `W` x `H` pane, with `body(cols, lines)` drawing the
|
|
47
|
-
* character (playerArt), plus where the arrows go; null when it does not fit.
|
|
47
|
+
* character (playerArt) and `title` heading it, plus where the arrows go; null when it does not fit. `hint`
|
|
48
|
+
* replaces the keys line (a companion's sheet has no repair or outfits).
|
|
48
49
|
*/
|
|
49
|
-
export function paperdollGrid(app, rows, W, H, body) {
|
|
50
|
+
export function paperdollGrid(app, rows, W, H, body, title, hint) {
|
|
50
51
|
const extraRows = rows.map((r, i) => ({ r, i })).filter(({ r }) => !r.cell && !r.header);
|
|
51
52
|
const plan = iconsOn() ? sheetSize(W, H, extraRows.length) : null;
|
|
52
53
|
if (!plan) return null;
|
|
@@ -58,7 +59,7 @@ export function paperdollGrid(app, rows, W, H, body) {
|
|
|
58
59
|
const top = 2, colH = SIDE * ch;
|
|
59
60
|
|
|
60
61
|
const ups = rows.filter((r) => r.cell?.up).length;
|
|
61
|
-
cv.text(1, 0, `${bold('Equipment')} ${ups ? green(`▲ ${plural(ups, 'upgrade')} in your bags u: put on all`) : dim(`Shift+arrows: move
|
|
62
|
+
cv.text(1, 0, `${title ?? bold('Equipment')} ${ups ? green(`▲ ${plural(ups, 'upgrade')} in your bags u: put on all`) : dim(hint ?? `s: stats Shift+arrows: move Enter: change${plan.extras ? '' : ' R: repair all o: outfits'}`)}`);
|
|
62
63
|
|
|
63
64
|
// Labels take a share of the width beside each column; the body gets the middle.
|
|
64
65
|
const lw = Math.max(0, Math.min(22, Math.floor((W - 2 * cw) * 0.28)));
|
|
@@ -10,7 +10,7 @@ import { dim, gray, fit } from './text.mjs';
|
|
|
10
10
|
import { SIZES, canvas, put, putLines, putRight, floorLine, narrowPlate, narrowState, auraRows, putMissiles, putPopsAbove, POP_MS, LUNGE_MS } from './tuiStage.mjs';
|
|
11
11
|
import { creatureArt, artWidth } from './modelArt.mjs';
|
|
12
12
|
import { paperdollArt } from './paperdoll.mjs';
|
|
13
|
-
import { playerArt } from './tuiViews.mjs';
|
|
13
|
+
import { playerArt, wornNow, wornArt } from './tuiViews.mjs';
|
|
14
14
|
import { iconsOn } from './iconArt.mjs';
|
|
15
15
|
import { allyIndexOfSide } from '../engine/core/events.ts';
|
|
16
16
|
import { combatDistanceYd, DEFAULT_OPEN_YD, MELEE_CONTACT_YD } from '../engine/combat/combatGeometry.ts';
|
|
@@ -19,6 +19,7 @@ const GAP = 1; // columns between two columns of the same side
|
|
|
19
19
|
const LANE = 6; // columns kept open between a party column and a foe column, for the glyphs that cross
|
|
20
20
|
const PLATE_MIN = 9; // a plate's narrowest: a 5-cell bar and "100%"
|
|
21
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
|
|
22
23
|
const SIZE_ORDER = ['xs', 's', 'm', 'l'];
|
|
23
24
|
// Who stands nearest the foes among units on the same spot (the melee pile opens on one): the healer last.
|
|
24
25
|
const ROLE_RANK = { healer: 0, damage: 2, tank: 3 };
|
|
@@ -31,12 +32,13 @@ function companionOf(app, v) {
|
|
|
31
32
|
return byIndex?.name === v.name ? byIndex : squad.find((c) => c.name === v.name) ?? byIndex;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
/** A unit's sprite in a box: you in your gear, a creature by its display id
|
|
35
|
+
/** A unit's sprite in a box: you or a companion in your form or gear, a creature by its display id. */
|
|
35
36
|
function spriteOf(app, u, cols, rows) {
|
|
36
|
-
if (u.isPlayer) return playerArt(app, cols, rows)?.lines ?? null;
|
|
37
|
+
if (u.isPlayer) return playerArt(app, cols, rows, u.v)?.lines ?? null;
|
|
37
38
|
if (u.v.creatureEntry != null) return creatureArt(app.ds.creature(u.v.creatureEntry)?.displayId, cols, rows);
|
|
38
39
|
const comp = u.friend ? companionOf(app, u.v) : null;
|
|
39
|
-
|
|
40
|
+
if (!comp) return null;
|
|
41
|
+
return wornArt(wornNow(app.ds, comp, u.v), cols, rows) ?? paperdollArt(comp, cols, rows);
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/** The box a unit gets when the big ones get `size`: pets a size under, a boss a size over. */
|
|
@@ -106,8 +108,10 @@ export function partyStage(app, ctl, W, H, { inline }) {
|
|
|
106
108
|
// a mob that runs onto the healer crosses to the lane while the healer stays put, and a mage walking into
|
|
107
109
|
// range slides in while its mob stands. Melee units park all round a mob, so this is read off distances, not
|
|
108
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.
|
|
109
113
|
const posOf = (u) => s.unitPosYd?.(u.key);
|
|
110
|
-
mem.openPos ??= new Map(); mem.openD ??= new Map();
|
|
114
|
+
mem.openPos ??= new Map(); mem.openD ??= new Map(); mem.lastC ??= new Map();
|
|
111
115
|
const nearest = (u) => {
|
|
112
116
|
let best = null;
|
|
113
117
|
for (const o of units) {
|
|
@@ -131,7 +135,12 @@ export function partyStage(app, ctl, W, H, { inline }) {
|
|
|
131
135
|
const c0 = 1 - frac(mem.openD.get(u.key));
|
|
132
136
|
return Math.max(0, Math.min(1, c0 + (1 - frac(n.d) - c0) * share));
|
|
133
137
|
};
|
|
134
|
-
for (const col of cols)
|
|
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
|
+
}
|
|
135
144
|
// The party's half and the pack's, the free width shared by head count, the lane between. In each half the
|
|
136
145
|
// columns stand by their closing, the nearest to the lane innermost; on the same spot the healer, the
|
|
137
146
|
// ranged, the melee, then the tank, and the foe you face before the rest. Then nobody overlaps: a column
|
|
@@ -145,6 +154,26 @@ export function partyStage(app, ctl, W, H, { inline }) {
|
|
|
145
154
|
const q = (col) => Math.round(col.c * 50);
|
|
146
155
|
friends.sort((a, b) => q(a) - q(b) || a.order - b.order);
|
|
147
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);
|
|
148
177
|
const place = (list, L, R) => {
|
|
149
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);
|
|
150
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);
|
|
@@ -164,7 +193,7 @@ export function partyStage(app, ctl, W, H, { inline }) {
|
|
|
164
193
|
putLines(c, col.ax, col.top, col.art);
|
|
165
194
|
}
|
|
166
195
|
putLines(c, col.x, zone, narrowPlate(app, v, { ...col, dead, w: col.cw }));
|
|
167
|
-
const state = narrowState(v, now, col.cw);
|
|
196
|
+
const state = narrowState(v, now, col.cw, mem.casts?.get(col.key));
|
|
168
197
|
if (state) put(c, col.x, stateRow, state);
|
|
169
198
|
if (showAuras && !col.small && !dead) putLines(c, col.x, auraTop, auraRows(app, mem, v, col.key, col.cw, icons));
|
|
170
199
|
putPopsAbove(c, col, (mem.pops.get(col.key) ?? []).filter((p) => now - p.t < POP_MS), now, zone);
|
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));
|
|
@@ -359,7 +359,7 @@ function enchantCheck(app, cr, e, env) {
|
|
|
359
359
|
if (e.reagents.every((rg) => countItem(cr.inv, rg.item) >= rg.count)) return { ok: true, cost: 0, buys: [] };
|
|
360
360
|
const b = vendorBuyInReady(app.ds, app.ch, e.reagents, cr.inv);
|
|
361
361
|
if (b.ok) return { ok: true, cost: b.cost, buys: b.buys };
|
|
362
|
-
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 };
|
|
363
363
|
}
|
|
364
364
|
|
|
365
365
|
/** Whose worn piece can take this enchant: you and the party, one option per matching slot. */
|
|
@@ -406,7 +406,7 @@ function enchantBench(app, crafterId) {
|
|
|
406
406
|
const cr = current(), ds = a.ds;
|
|
407
407
|
const sk = cr.char.skills.enchanting;
|
|
408
408
|
const env = craftEnv(a);
|
|
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'}`)} ${
|
|
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'))];
|
|
410
410
|
const known = new Set(cr.char.knownEnchants ?? []);
|
|
411
411
|
const formulas = ds.enchants().filter((e) => known.has(e.id)).sort((x, y) => y.orange - x.orange || x.name.localeCompare(y.name));
|
|
412
412
|
const W = Math.max(20, Math.min(34, a.layout().listW - 31));
|
|
@@ -415,7 +415,7 @@ function enchantBench(app, crafterId) {
|
|
|
415
415
|
for (const e of formulas) {
|
|
416
416
|
const chk = enchantCheck(a, cr, e, env);
|
|
417
417
|
rows.push({
|
|
418
|
-
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)}`,
|
|
419
419
|
detail: () => [bold(e.name), dim(`goes on: ${e.slots.join(', ')} ${rodNameForTier(e.minRodTier, ds)}`), '', ...reagentLines(a, e.reagents, [], cr.inv), '',
|
|
420
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')}`, '',
|
|
421
421
|
chk.ok ? green('Enter: pick whose gear to enchant') : red(chk.reason)],
|
|
@@ -489,7 +489,7 @@ function partyBook(app) {
|
|
|
489
489
|
groups.set(r.skill, g);
|
|
490
490
|
}
|
|
491
491
|
const ready = [...byRecipe.values()].filter((o) => o.some((x) => x.chk.ok)).length;
|
|
492
|
-
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.'))];
|
|
493
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.')));
|
|
494
494
|
const W = Math.max(20, Math.min(34, a.layout().listW - 34));
|
|
495
495
|
for (const [skill, list] of [...groups].sort((x, y) => x[0].localeCompare(y[0]))) {
|
|
@@ -527,9 +527,9 @@ function specRows(app, trainer) {
|
|
|
527
527
|
const name = sp?.name ?? it.kind.replace(/^(un)?learn_/, '');
|
|
528
528
|
const cost = it.costCopper ?? 0;
|
|
529
529
|
rows.push({
|
|
530
|
-
text: `${unlearn ? red(`Unlearn ${name}`) : green(`Become a specialist: ${name}`)}${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 ${
|
|
532
|
-
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)),
|
|
533
533
|
});
|
|
534
534
|
}
|
|
535
535
|
rows.push(H(''));
|
|
@@ -569,21 +569,21 @@ export function profTrainerDialog(app, trainer) {
|
|
|
569
569
|
const before = a.ch.gold;
|
|
570
570
|
if (learn(a, t).ok) { n++; spent += before - a.ch.gold; }
|
|
571
571
|
}
|
|
572
|
-
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.'));
|
|
573
573
|
} },
|
|
574
574
|
},
|
|
575
575
|
rows(a) {
|
|
576
576
|
const ch = a.ch, sk = ch.skills[trainer.skill];
|
|
577
577
|
const mod = vendorPriceMultiplierFor(ds, ch, trainer.entry);
|
|
578
|
-
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('')];
|
|
579
579
|
const nr = prof ? nextTrainerRank(ch, prof) : undefined;
|
|
580
580
|
if (nr) {
|
|
581
581
|
const cost = trainerSpellCost(nr.cost, mod);
|
|
582
582
|
const need = nextRankReqSkill(nr, sk);
|
|
583
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' : '';
|
|
584
584
|
rows.push(H('Rank'), {
|
|
585
|
-
text: `${why ? dim(`${sk ? 'Train' : 'Learn'} ${nr.name} ${prof.name}`) : green(`${sk ? 'Train' : 'Learn'} ${nr.name} ${prof.name}`)} ${
|
|
586
|
-
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.') : ''],
|
|
587
587
|
act: () => a.result(a.g.learnProfession(trainer.entry), `You are now a${/^[AEIOU]/.test(nr.name) ? 'n' : ''} ${nr.name} ${prof.name}.`),
|
|
588
588
|
}, H(''));
|
|
589
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(''));
|
|
@@ -598,14 +598,14 @@ export function profTrainerDialog(app, trainer) {
|
|
|
598
598
|
const cost = trainerSpellCost(t.def.cost ?? 0, mod);
|
|
599
599
|
const prod = t.kind === 'recipe' ? ds.item(t.def.product.item) : null;
|
|
600
600
|
rows.push({
|
|
601
|
-
text: `${fit(ch.gold >= cost ? t.def.name : dim(t.def.name), W)} ${
|
|
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 ${
|
|
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')}`)],
|
|
603
603
|
act: () => a.result(learn(a, t), `You learn ${t.def.name}.`),
|
|
604
604
|
});
|
|
605
605
|
}
|
|
606
606
|
if (later.length) {
|
|
607
607
|
rows.push(H(''), H('Coming up'));
|
|
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}` : ''} ${
|
|
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))}`)));
|
|
609
609
|
}
|
|
610
610
|
return rows;
|
|
611
611
|
},
|
|
@@ -761,7 +761,7 @@ export function professionsView(app) {
|
|
|
761
761
|
};
|
|
762
762
|
const you = crafterOf(a, null);
|
|
763
763
|
const mine = profRows(you);
|
|
764
|
-
const rows = [H(`Your professions ${
|
|
764
|
+
const rows = [H(`Your professions ${coinText(a.ch.gold)}`)];
|
|
765
765
|
if (mine.length) rows.push(...mine);
|
|
766
766
|
else rows.push(H(dim(' You have no professions yet. Profession trainers stand in towns and capitals (World tab).')));
|
|
767
767
|
const comps = companionsOf(g);
|
package/src/cli/tuiSaves.mjs
CHANGED
|
@@ -22,10 +22,24 @@ export async function savePicker(app, { onPlay, onNew }) {
|
|
|
22
22
|
// Each hero stands in their gear under their line: the saves are read once here for the character they hold.
|
|
23
23
|
const chars = new Map();
|
|
24
24
|
for (const m of saves) { try { chars.set(m.slot, (await repo.load(m.slot))?.state?.char ?? null); } catch { /* unreadable: no sprite */ } }
|
|
25
|
+
// A hero's first drawing unpacks their body's art and can take a moment, so paging past them draws only
|
|
26
|
+
// the text: the art waits until the cursor rests on them. One drawn once comes back at once.
|
|
27
|
+
const drawn = new Set();
|
|
28
|
+
let settle = null;
|
|
29
|
+
const restingOn = (m) => {
|
|
30
|
+
if (drawn.has(m.slot)) return true;
|
|
31
|
+
clearTimeout(settle);
|
|
32
|
+
settle = setTimeout(() => {
|
|
33
|
+
if (app.top() !== view || options[view.cursor]?.value.meta?.slot !== m.slot) return;
|
|
34
|
+
drawn.add(m.slot); app.render();
|
|
35
|
+
}, 250);
|
|
36
|
+
return false;
|
|
37
|
+
};
|
|
25
38
|
const options = saves.map((m) => ({
|
|
26
39
|
label: `${bold(m.name)} ${dim(`L${m.level} ${className(m.cls)}`)}${busy(m) ? ` ${yellow('open in another terminal')}` : ''}`,
|
|
27
40
|
detail: () => {
|
|
28
41
|
const text = [bold(m.name), `Level ${m.level} ${className(m.cls)}`, dim(`last played ${when(m.updatedAt)}`), '', dim('Enter: play m: back up, restore, export, delete')];
|
|
42
|
+
if (!restingOn(m)) return text;
|
|
29
43
|
const ch = chars.get(m.slot), L = app.layout();
|
|
30
44
|
const art = ch ? paperdollArt(ch, L.detailW - 2, L.mainH - text.length - 1) : null;
|
|
31
45
|
return art ? [...text, '', ...art] : text;
|
package/src/cli/tuiSearch.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// zones it grows in; a zone, a quest, an object. Enter acts on the place: open it when you are already there
|
|
6
6
|
// (the vendor, the fight, the portal, the node), travel there when you are not. The matching is the wiki's
|
|
7
7
|
// (substring, then character subsequence), over the wiki's name index plus a gather-node index of our own.
|
|
8
|
-
import { bold, dim, yellow, green, red, cyan, qualityPaint, secondsText, plural } from './text.mjs';
|
|
8
|
+
import { bold, dim, yellow, green, red, cyan, qualityPaint, secondsText, plural, coinText } from './text.mjs';
|
|
9
9
|
import { wikiIndex, wikiTokens, rowMatches } from './wikiIndex.ts';
|
|
10
10
|
import { wikiSourceIndexSync } from './wikiSources.ts';
|
|
11
11
|
import { wikiPage } from './tuiWiki.mjs';
|
|
@@ -13,7 +13,6 @@ import { itemLines, creatureLines, questLines, travelPreviewLines, travelTo, ven
|
|
|
13
13
|
import { profTrainerDialog } from './tuiProfessions.mjs';
|
|
14
14
|
import { entryDialog } from './tuiDungeon.mjs';
|
|
15
15
|
import { startFight } from './tuiFight.mjs';
|
|
16
|
-
import { moneyText } from '../domain/economy/money.ts';
|
|
17
16
|
import { vendorPriceMultiplierFor } from '../engine/game/vendorOffers.ts';
|
|
18
17
|
import { className } from '../domain/core/ids.ts';
|
|
19
18
|
|
|
@@ -162,7 +161,7 @@ function goTo(app, zoneId, here) {
|
|
|
162
161
|
if (!dest) { app.flashMsg('No road leads there.'); return; }
|
|
163
162
|
const pv = app.g.previewTravel(dest.id);
|
|
164
163
|
if (!app.result(pv)) return;
|
|
165
|
-
app.confirm(`Travel to ${dest.name}? ${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${
|
|
164
|
+
app.confirm(`Travel to ${dest.name}? ${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${coinText(pv.totalCopper)} in fares` : ''}`, () => travelTo(app, dest));
|
|
166
165
|
}
|
|
167
166
|
|
|
168
167
|
/** Enter on a place with several zones: travel to the one you pick, the current zone first. */
|
|
@@ -183,7 +182,7 @@ function travelHint(app, zones) {
|
|
|
183
182
|
const dest = app.ds.zone(zones[0]);
|
|
184
183
|
const pv = dest ? app.g.previewTravel(dest.id) : null;
|
|
185
184
|
if (pv && !pv.ok) return red(pv.reason);
|
|
186
|
-
return dim(`Enter: travel to ${zoneName(app, zones[0])}${pv ? ` (${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${
|
|
185
|
+
return dim(`Enter: travel to ${zoneName(app, zones[0])}${pv ? ` (${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${coinText(pv.totalCopper)}` : ''})` : ''}${zones.length > 1 ? ', or pick another zone' : ''}`);
|
|
187
186
|
}
|
|
188
187
|
|
|
189
188
|
// ---- rows per kind ------------------------------------------------------------------------------------------
|
|
@@ -328,7 +327,7 @@ function itemRows(app, entry, only) {
|
|
|
328
327
|
if (s.kind === 'vendor') {
|
|
329
328
|
const v = ds.vendor(s.ref);
|
|
330
329
|
if (!v) continue;
|
|
331
|
-
const price = it.buyPrice ? dim(` ${
|
|
330
|
+
const price = it.buyPrice ? dim(` ${coinText(Math.floor(it.buyPrice * vendorPriceMultiplierFor(ds, app.ch, v.entry)))}`) : '';
|
|
332
331
|
rows.push(npcRow(app, s.ref, { indent: `${indent}${dim('sold by')} `, note: price }));
|
|
333
332
|
} else if (s.kind === 'drop' || s.kind === 'skin' || s.kind === 'pickpocket') {
|
|
334
333
|
if (!ds.creature(s.ref)) continue;
|
package/src/cli/tuiSettings.mjs
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
// disenchanting, the junk rules (what gets sold or destroyed on its own) and which gathering nodes the autopilot
|
|
3
3
|
// takes. Enter flips or cycles a row. "Every character" settings live in ~/.azsh/prefs.json through the
|
|
4
4
|
// createGame hooks; "this character" settings live in the save.
|
|
5
|
-
import { bold, dim, yellow, green, red, cyan, qualityPaint } from './text.mjs';
|
|
5
|
+
import { bold, dim, yellow, green, red, cyan, qualityPaint, coinText } from './text.mjs';
|
|
6
6
|
import { autoLootOn } from './tuiLoot.mjs';
|
|
7
|
-
import { moneyText } from '../domain/economy/money.ts';
|
|
8
7
|
import { JUNK_PROTECT_CATEGORIES } from '../domain/items/junkCategories.ts';
|
|
9
8
|
import { autopilotSettingsDialog } from './tuiAutopilotSettings.mjs';
|
|
10
9
|
import { paceLabel, nextPace } from './tuiWorld.mjs';
|
|
@@ -81,7 +80,7 @@ export function settingsDialog(app) {
|
|
|
81
80
|
const eh = g.equipmentHelperConfig();
|
|
82
81
|
rows.push(H(''), H(bold('Equipment helper')));
|
|
83
82
|
rows.push({ text: `Helper ${onOff(eh.enabled)}`, detail: () => [bold('Equipment helper'), '', 'Scores what you pick up against what you wear and marks upgrades with ▲.'], act: (a2) => g.setEquipmentHelperConfig({ ...eh, enabled: !eh.enabled }) });
|
|
84
|
-
rows.push({ text: `Upgrades ${eh.mode === 'auto' ? green('put on as they drop') : cyan('suggest only')}`, detail: () => [bold('Upgrades'), '', 'Suggest: ▲ on the
|
|
83
|
+
rows.push({ text: `Upgrades ${eh.mode === 'auto' ? green('put on as they drop') : cyan('suggest only')}`, detail: () => [bold('Upgrades'), '', 'Suggest: ▲ on the Character and Bags tabs, u puts them on.', 'Put on: strict upgrades are worn the moment they land in your bags.'], act: () => g.setEquipmentHelperConfig({ ...eh, mode: eh.mode === 'auto' ? 'suggest' : 'auto' }) });
|
|
85
84
|
|
|
86
85
|
const deMine = g.characterAutoDisenchantConfig(), deAll = g.globalAutoDisenchantConfig();
|
|
87
86
|
rows.push(H(''), H(bold('Disenchant on its own')));
|
|
@@ -110,7 +109,7 @@ export function settingsDialog(app) {
|
|
|
110
109
|
const n = jf[key].length;
|
|
111
110
|
rows.push({ text: `${label.padEnd(26)}${n ? `${n} item${n === 1 ? '' : 's'}` : dim('none')}`, detail: () => [bold(label), '', ...(n ? jf[key].map((e) => a.itemLabel(e)) : [dim('Empty. m on a bag item adds to it.')]), '', dim('Enter: edit')], act: (a2) => a2.push(junkListDialog(a2, key, label)) });
|
|
112
111
|
}
|
|
113
|
-
rows.push({ text: green('Clear out junk now'), detail: () => [bold('Clear out junk now'), '', 'Sells (when a vendor is open or the rule allows) and destroys what the rules say, once.'], act: (a2) => { const r = g.runAutoJunk(); if (r.sold || r.destroyed) a2.say(green(`Junk: ${r.sold} sold${r.earned ? ` for ${
|
|
112
|
+
rows.push({ text: green('Clear out junk now'), detail: () => [bold('Clear out junk now'), '', 'Sells (when a vendor is open or the rule allows) and destroys what the rules say, once.'], act: (a2) => { const r = g.runAutoJunk(); if (r.sold || r.destroyed) a2.say(green(`Junk: ${r.sold} sold${r.earned ? ` for ${coinText(r.earned)}` : ''}, ${r.destroyed} destroyed.`)); else a2.flashMsg('Nothing in your bags is junk by these rules.'); } });
|
|
114
113
|
|
|
115
114
|
const gf = g.gatherFilterConfig();
|
|
116
115
|
rows.push(H(''), H(`${bold('Gathering')} ${dim('which nodes the autopilot mines, picks and skins, by skill colour')}`));
|
package/src/cli/tuiStage.mjs
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
// left and the foe down the right; the dungeon room and the pack are meant to be more actor lists through
|
|
4
4
|
// the same canvas. Sizes come from the design page: a sprite's box says its rank, and the terminal's
|
|
5
5
|
// width says which boxes it can afford.
|
|
6
|
-
import { bold, dim, cyan, yellow, green, red, gray, inverse, fit, vis, cut, bar, brightBlue, orange, useColor, strip, itemText } from './text.mjs';
|
|
7
|
-
import { moneyText } from '../domain/economy/money.ts';
|
|
6
|
+
import { bold, dim, cyan, yellow, green, red, gray, inverse, fit, vis, cut, bar, brightBlue, orange, useColor, strip, itemText, coinText } from './text.mjs';
|
|
8
7
|
import { className } from '../domain/core/ids.ts';
|
|
9
8
|
import { iconArt, iconsOn } from './iconArt.mjs';
|
|
10
9
|
import { sixelOn } from './sixel.mjs';
|
|
@@ -15,7 +14,7 @@ export const POWER_PAINT = { mana: brightBlue, rage: red, energy: yellow, focus:
|
|
|
15
14
|
export const POP_MS = 1500; // a hit or heal stays on screen this long (fight time)
|
|
16
15
|
export const POP_RISE_MS = 400; // a pop climbs one row every this long
|
|
17
16
|
export const LUNGE_MS = 250; // an auto-attack swing moves the sprite two columns toward its target this long
|
|
18
|
-
export const SHORT_BUFF_MS = 60_000; // a frame shows only buffs about to run out; the long ones are on the Character tab
|
|
17
|
+
export const SHORT_BUFF_MS = 60_000; // a frame shows only buffs about to run out; the long ones are under s on the Character tab
|
|
19
18
|
export const ROLE_GLYPH = { tank: cyan('T'), healer: green('H'), damage: red('D') };
|
|
20
19
|
|
|
21
20
|
/** Sprite boxes, columns x rows: a pack add or a pet, dungeon trash, a normal foe, you or an elite, a boss. */
|
|
@@ -135,19 +134,19 @@ export function narrowPlate(app, v, { isPlayer, focused, friend, dead, role, ran
|
|
|
135
134
|
}
|
|
136
135
|
|
|
137
136
|
/** The state row of a narrow plate, `w` wide: a cast bar that fills under the spell's name (a channel drains
|
|
138
|
-
* it), else crowd control, stealth, or "dead"; empty when idle. */
|
|
139
|
-
export function narrowState(v, now, w) {
|
|
137
|
+
* it), else crowd control, the cast just done (castSlot), stealth, or "dead"; empty when idle. */
|
|
138
|
+
export function narrowState(v, now, w, recent) {
|
|
140
139
|
if (v.hp <= 0) return gray('dead');
|
|
141
140
|
if (v.casting) {
|
|
142
|
-
const
|
|
143
|
-
const left = Math.max(0, v.casting.endsAt - now);
|
|
144
|
-
const prog = Math.max(0, Math.min(1, v.casting.channeled ? left / total : 1 - left / total));
|
|
141
|
+
const { prog, left } = castProgress(v.casting, now);
|
|
145
142
|
const secs = w >= 14 ? ` ${(left / 1000).toFixed(1)}s` : '';
|
|
146
143
|
const text = fit(cut(v.casting.name, w - secs.length), w - secs.length);
|
|
147
144
|
const filled = Math.round(prog * vis(text));
|
|
148
145
|
return `${inverse(yellow(text.slice(0, filled)))}${yellow(text.slice(filled))}${dim(secs)}`;
|
|
149
146
|
}
|
|
150
147
|
if (v.controlled) return red(cut(v.controlled.control, w));
|
|
148
|
+
const last = freshCast(recent, now);
|
|
149
|
+
if (last) return cyan(bold(cut(last.name, w)));
|
|
151
150
|
if (v.stealthed) return dim('stealthed');
|
|
152
151
|
return '';
|
|
153
152
|
}
|
|
@@ -176,17 +175,74 @@ export function auraRows(app, mem, v, key, plateW, icons) {
|
|
|
176
175
|
return rows;
|
|
177
176
|
}
|
|
178
177
|
|
|
179
|
-
|
|
180
|
-
export
|
|
178
|
+
export const CAST_NAME_MS = 2000; // a cast just done keeps its name in the cast slot this long (fight time)
|
|
179
|
+
export const CAST_TRAIL_MS = 8000; // and its icon in the trail beside it this long, greyed for the second half
|
|
180
|
+
|
|
181
|
+
/** The cast bar's progress and seconds left, 0..1. */
|
|
182
|
+
function castProgress(casting, now) {
|
|
183
|
+
const total = Math.max(1, casting.endsAt - casting.startedAt);
|
|
184
|
+
const left = Math.max(0, casting.endsAt - now);
|
|
185
|
+
return { prog: Math.max(0, Math.min(1, casting.channeled ? left / total : 1 - left / total)), left };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The last cast while its icon is still in the trail, with `fresh` set while it keeps its name lit. `recent`
|
|
189
|
+
* is the unit's casts, newest first ({ t, spellId, name, instant }, noteCasts in tuiFight). */
|
|
190
|
+
function lastCast(recent, now) {
|
|
191
|
+
const r = recent?.[0];
|
|
192
|
+
return r && now - r.t < CAST_TRAIL_MS ? { ...r, fresh: now - r.t < CAST_NAME_MS } : null;
|
|
193
|
+
}
|
|
194
|
+
const freshCast = (recent, now) => { const r = lastCast(recent, now); return r?.fresh ? r : null; };
|
|
195
|
+
|
|
196
|
+
/** The cast slot's text: a cast bar, else crowd control, else the cast just done (an instant says so, the
|
|
197
|
+
* casts before it trail after it), else stealth, else the last cast gone dim; empty when idle. */
|
|
198
|
+
export function castSlot(v, now, recent) {
|
|
181
199
|
if (v.casting) {
|
|
182
|
-
const
|
|
183
|
-
const left = Math.max(0, v.casting.endsAt - now);
|
|
184
|
-
const prog = v.casting.channeled ? left / total : 1 - left / total;
|
|
200
|
+
const { prog, left } = castProgress(v.casting, now);
|
|
185
201
|
return `${fit(yellow(v.casting.name), 14)} ${bar(prog, 1, 10, yellow)} ${dim(`${(left / 1000).toFixed(1)}s`)}`;
|
|
186
202
|
}
|
|
187
203
|
if (v.controlled) return red(v.controlled.control);
|
|
188
|
-
|
|
189
|
-
return '';
|
|
204
|
+
const last = lastCast(recent, now);
|
|
205
|
+
if (!last || (!last.fresh && v.stealthed)) return v.stealthed ? dim('stealthed') : '';
|
|
206
|
+
const before = recent.slice(1).filter((r) => now - r.t < CAST_TRAIL_MS).map((r) => r.name);
|
|
207
|
+
const trail = before.length ? dim(` ‹ ${before.join(' ‹ ')}`) : '';
|
|
208
|
+
return last.fresh ? `${cyan(bold(last.name))}${last.instant ? dim(' instant') : ''}${trail}` : `${dim(last.name)}${trail}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The cast slot two rows tall and `w` wide, where icons draw: the spell's icon, its name beside it and under
|
|
213
|
+
* the name the cast bar (a cast in progress) or "instant" (one just gone off, for CAST_NAME_MS; after that the
|
|
214
|
+
* name stays dim). The icons of the casts before it trail off to the right, newest first, grey once they are
|
|
215
|
+
* half way out, like an aura strip of what you pressed. Crowd control and stealth take the lead as castSlot has them.
|
|
216
|
+
*/
|
|
217
|
+
export function castRows(app, v, recent, now, w) {
|
|
218
|
+
const spell = (id) => app.ds.spell(id);
|
|
219
|
+
let lead = null, top = '', under = '';
|
|
220
|
+
const last = lastCast(recent, now);
|
|
221
|
+
if (v.casting) {
|
|
222
|
+
const { prog, left } = castProgress(v.casting, now);
|
|
223
|
+
lead = { spellId: v.casting.spellId, since: v.casting.startedAt };
|
|
224
|
+
top = yellow(v.casting.name);
|
|
225
|
+
under = `${bar(prog, 1, 10, yellow)} ${dim(`${(left / 1000).toFixed(1)}s`)}`;
|
|
226
|
+
} else if (v.controlled) top = red(v.controlled.control);
|
|
227
|
+
else if (last && (last.fresh || !v.stealthed)) {
|
|
228
|
+
lead = { spellId: last.spellId, since: last.t, gray: now - last.t > CAST_TRAIL_MS / 2 };
|
|
229
|
+
top = !last.fresh ? dim(last.name) : now - last.t > CAST_NAME_MS / 2 ? cyan(last.name) : cyan(bold(last.name));
|
|
230
|
+
under = last.fresh && last.instant ? dim('instant') : '';
|
|
231
|
+
} else if (v.stealthed) top = dim('stealthed');
|
|
232
|
+
const nameW = Math.min(16, Math.max(8, w - 5));
|
|
233
|
+
const art = lead ? iconArt(spell(lead.spellId)?.icon, 4, lead.gray ? 'gray' : '') : null;
|
|
234
|
+
const rows = art ? [`${art[0]} ${fit(cut(top, nameW), nameW)}`, `${art[1]} ${fit(cut(under, nameW), nameW)}`] : [fit(cut(top, nameW + 5), nameW + 5), fit(cut(under, nameW + 5), nameW + 5)];
|
|
235
|
+
// The trail: what went off before the lead (a channel is in `recent` from its first tick, so it is the lead,
|
|
236
|
+
// not its own trail).
|
|
237
|
+
const room = Math.floor((w - vis(rows[0]) - 1) / 5);
|
|
238
|
+
const trail = (recent ?? []).filter((r) => now - r.t < CAST_TRAIL_MS && !(lead && r.spellId === lead.spellId && r.t >= lead.since)).slice(0, Math.max(0, room));
|
|
239
|
+
for (const r of trail) {
|
|
240
|
+
const old = now - r.t > CAST_TRAIL_MS / 2;
|
|
241
|
+
const ic = iconArt(spell(r.spellId)?.icon, 4, old ? 'gray' : '');
|
|
242
|
+
rows[0] += ` ${ic ? ic[0] : dim(fit(r.name, 4))}`;
|
|
243
|
+
rows[1] += ` ${ic ? ic[1] : dim(fit(r.name.slice(4), 4))}`;
|
|
244
|
+
}
|
|
245
|
+
return rows.every((l) => !strip(l).trim()) ? ['', ''] : rows;
|
|
190
246
|
}
|
|
191
247
|
|
|
192
248
|
/** Short buffs green then debuffs red, in the order they first showed up, so a refreshed one keeps its place. */
|
|
@@ -256,7 +312,7 @@ const coinIcon = (copper) => (copper < 100 ? 'inv_misc_coin_05' : copper < 10_00
|
|
|
256
312
|
/** What the foe dropped, items then the coin: each with its icon name and its line in the quality colour. */
|
|
257
313
|
export function lootToasts(app, hold) {
|
|
258
314
|
const out = hold.loot.map((d) => { const it = app.ds.item(d.item); return { icon: it?.icon, text: `${itemText(it, String(d.item))}${d.count > 1 ? dim(` x${d.count}`) : ''}` }; });
|
|
259
|
-
if (hold.copper) out.push({ icon: coinIcon(hold.copper), coin: true, text:
|
|
315
|
+
if (hold.copper) out.push({ icon: coinIcon(hold.copper), coin: true, text: coinText(hold.copper) });
|
|
260
316
|
return out;
|
|
261
317
|
}
|
|
262
318
|
|