azeroth-shell 0.2.0 → 0.4.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/CHANGELOG.md +53 -0
- package/README.md +43 -10
- package/bin/azsh.mjs +15 -1
- package/package.json +7 -3
- 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 +40 -13
- package/src/cli/tuiAutopilot.mjs +17 -8
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiBulk.mjs +83 -4
- package/src/cli/tuiCheck.mjs +328 -19
- package/src/cli/tuiCity.mjs +31 -31
- package/src/cli/tuiCompanionKit.mjs +26 -8
- package/src/cli/tuiCompanions.mjs +189 -29
- package/src/cli/tuiCreate.mjs +7 -0
- package/src/cli/tuiDungeon.mjs +113 -33
- 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 +374 -85
- 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/professions.ts +4 -0
- package/src/engine/game/squadQuestShare.ts +13 -1
- package/src/engine/game/types.ts +4 -0
package/src/cli/tuiFloor.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { dirname, join } from 'node:path';
|
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { bold, dim, yellow, green, red, cyan, fit } from './text.mjs';
|
|
23
23
|
import { startFight } from './tuiFight.mjs';
|
|
24
|
-
import { pullStop, restBetween } from './tuiDungeon.mjs';
|
|
24
|
+
import { pullStop, restBetween, readyUp } from './tuiDungeon.mjs';
|
|
25
25
|
import { prePull } from './squad.mjs';
|
|
26
26
|
import { decodeNavMesh, registerNavMeshForMap, navMeshForMap } from '../domain/nav/navmesh.ts';
|
|
27
27
|
import { bakeSlugForInstance } from '../world/bakeSlug.ts';
|
|
@@ -82,9 +82,13 @@ function stampTemplates(app, ents) {
|
|
|
82
82
|
/** The floor for the current run, built on first use and kept on the app while the run lasts. */
|
|
83
83
|
function floorFor(app, def) {
|
|
84
84
|
const run = app.g.state.instance;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
// One floor per entry: a hearth or a taxi out never cleared app.floor, so the next run of the dungeon walked the old one.
|
|
86
|
+
const enteredAt = app.g.state.instanceEnteredAtMs ?? null;
|
|
87
|
+
if (app.floor?.dungeonId === run.dungeonId && app.floor.enteredAt === enteredAt) return app.floor;
|
|
88
|
+
// Bodies of stops already cleared stay dead on the rebuilt floor, with any loose kills from before. The kills
|
|
89
|
+
// ride on the run, not the dungeon: a fresh run of the same dungeon stood the last run's dead back up, and an
|
|
90
|
+
// event's body (the Ring of Law's champion, one guid every run) was born dead and paid out unfought.
|
|
91
|
+
const cleared = new Set(run.floorClearedGuids ?? []);
|
|
88
92
|
for (const enc of def.encounters) if (isCleared(run, enc.id)) for (const gd of enc.spawnGuids ?? []) cleared.add(gd);
|
|
89
93
|
const bossEntries = new Set(def.encounters.filter((e) => e.kind === 'boss' && e.creatureEntry > 0).map((e) => e.creatureEntry));
|
|
90
94
|
const world = createContinuousWorld({
|
|
@@ -96,7 +100,7 @@ function floorFor(app, def) {
|
|
|
96
100
|
skipEntry: (entry) => !bossEntries.has(entry) && isTechnicalTriggerCreature(app.ds.creature(entry)),
|
|
97
101
|
});
|
|
98
102
|
stampTemplates(app, world.entities);
|
|
99
|
-
const f = { dungeonId: run.dungeonId, def, world, runMs: 0, patrolIndex: new Map(), target: null, walking: false, follow: true, speed: 1, timer: null, script: null, questScanMs: -Infinity, waiting: [], eventHoldUntil: 0,
|
|
103
|
+
const f = { dungeonId: run.dungeonId, enteredAt, def, world, runMs: 0, patrolIndex: new Map(), target: null, walking: false, follow: true, speed: 1, timer: null, script: null, questScanMs: -Infinity, waiting: [], eventHoldUntil: 0,
|
|
100
104
|
rng: mulberry32(((app.g.state.rngSeed ?? 1) ^ 0x9e3779b9) >>> 0) };
|
|
101
105
|
f.script = scriptContext(app, f);
|
|
102
106
|
app.floor = f;
|
|
@@ -158,8 +162,13 @@ function markEngaged(f, ents) {
|
|
|
158
162
|
|
|
159
163
|
function remember(app, f, guids) {
|
|
160
164
|
resolveKill(f.world, guids);
|
|
161
|
-
app
|
|
162
|
-
|
|
165
|
+
keepKills(app, f);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The floor's dead onto the run, so a step outside keeps them and a fresh run starts without them. */
|
|
169
|
+
function keepKills(app, f) {
|
|
170
|
+
const run = app.g.state.instance;
|
|
171
|
+
if (run?.dungeonId === f.dungeonId) app.g.state.instance = { ...run, floorClearedGuids: [...f.world.clearedGuids] };
|
|
163
172
|
}
|
|
164
173
|
|
|
165
174
|
/** One floor tick: patrols, the walk, the scripts, then what sees you. Returns true when a pull started. */
|
|
@@ -339,6 +348,7 @@ function fightBodies(app, f, ents) {
|
|
|
339
348
|
/** The event is lost: its summoned bodies go, claimed ones stand down, and it waits a minute before it arms again. */
|
|
340
349
|
function failEvent(app, f) {
|
|
341
350
|
f.script.resetGauntletOnWipe();
|
|
351
|
+
keepKills(app, f); // the event's fallen came off the floor's dead; a rebuilt floor must not bring them back dead
|
|
342
352
|
f.eventHoldUntil = f.runMs + EVENT_HOLD_MS;
|
|
343
353
|
app.say(red('The event fails. Rest (z) before you go again; it waits a minute.'));
|
|
344
354
|
}
|
|
@@ -398,6 +408,8 @@ function reopen(app) {
|
|
|
398
408
|
export function walkTo(app, f, enc) {
|
|
399
409
|
const dest = enc.origin;
|
|
400
410
|
if (!dest) { app.flashMsg(`${enc.name} has no place on the floor.`); return; }
|
|
411
|
+
// Buff and drink before setting off: whatever sees you on the way pulls with no time for it.
|
|
412
|
+
readyUp(app);
|
|
401
413
|
const r = setMoveOrder(f.world, dest, f.def.mapId);
|
|
402
414
|
f.target = enc.id;
|
|
403
415
|
f.walking = true;
|
|
@@ -407,7 +419,7 @@ export function walkTo(app, f, enc) {
|
|
|
407
419
|
|
|
408
420
|
/** P on a stop: an event starts where it stands, anything else is pulled from here. */
|
|
409
421
|
function pullFromHere(app, f, enc) {
|
|
410
|
-
if (!floorGauntletWaves(enc)) { pullStop(app, f.def, enc, { floor: true, onTick: () => stepInFight(app, f), onDone: (r) => settle(app, f, r) }); if (app.fight) app.fight.floor = f; return; }
|
|
422
|
+
if (!floorGauntletWaves(enc)) { pullStop(app, f.def, enc, { floor: true, ready: true, onTick: () => stepInFight(app, f), onDone: (r) => settle(app, f, r) }); if (app.fight) app.fight.floor = f; return; }
|
|
411
423
|
const run = app.g.state.instance;
|
|
412
424
|
const need = requiredFlags(enc).filter((fl) => !hasFlag(run, fl));
|
|
413
425
|
if (need.length) { app.flashMsg(`${enc.name} has not been set off yet (${need.join(', ')}).`); return; }
|
package/src/cli/tuiGear.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The Character tab's lower rows: what repairs cost and where, the ammo you shoot, and the food, water and elixirs the
|
|
2
2
|
// game uses for you out of combat. Per-slot durability and r (repair this one) ride on the slot rows themselves.
|
|
3
|
-
import { bold, dim, yellow, green, red, qualityPaint, itemText, secondsText } from './text.mjs';
|
|
4
|
-
import { moneyText } from '../domain/economy/money.ts';
|
|
3
|
+
import { bold, dim, yellow, green, red, qualityPaint, itemText, secondsText, coinText } from './text.mjs';
|
|
5
4
|
import { equippedEntry, equippedInstance } from '../domain/items/itemInstance.ts';
|
|
6
5
|
import { gearEnchantDisplayName } from '../domain/professions/gearEnchant.ts';
|
|
7
6
|
import { hasDurability, maxDurability, currentDurability, repairCostSlot } from '../domain/items/durability.ts';
|
|
@@ -49,23 +48,23 @@ export function repairOne(app, slot) {
|
|
|
49
48
|
const cost = repairCostSlot(app.ch, slot, (e) => app.ds.item(e));
|
|
50
49
|
if (!cost) { app.flashMsg('That piece needs no repair.'); return; }
|
|
51
50
|
const r = app.g.repairSlot(slot, q.vendorEntry ?? undefined);
|
|
52
|
-
app.result(r, r.ok ? `${q.vendorName} repairs it for ${
|
|
51
|
+
app.result(r, r.ok ? `${q.vendorName} repairs it for ${coinText(r.spent)}.` : '');
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
/** Enter on Repair all, or R on the
|
|
54
|
+
/** Enter on Repair all, or R on the Character tab: mend everything you wear at the repairer the quote prices against. */
|
|
56
55
|
export function repairAll(app) {
|
|
57
56
|
const q = app.g.repairQuote();
|
|
58
57
|
if (!q.cost) { app.flashMsg('Nothing needs repair.'); return; }
|
|
59
58
|
if (!q.vendorName) { app.flashMsg('Nobody here repairs.'); return; }
|
|
60
59
|
const r = app.g.repairAll(q.vendorEntry ?? undefined);
|
|
61
|
-
app.result(r, r.ok ? `Repaired for ${
|
|
60
|
+
app.result(r, r.ok ? `Repaired for ${coinText(r.spent)}.` : '');
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
export function slotRepairLines(app, slot) {
|
|
65
64
|
const cost = repairCostSlot(app.ch, slot, (e) => app.ds.item(e));
|
|
66
65
|
if (!cost) return [];
|
|
67
66
|
const q = app.g.repairQuote();
|
|
68
|
-
return [q.vendorName ? dim(`r: repair this piece for ${
|
|
67
|
+
return [q.vendorName ? dim(`r: repair this piece for ${coinText(cost)} at ${q.vendorName}`) : dim(`Repairing this piece costs about ${coinText(cost)}.`)];
|
|
69
68
|
}
|
|
70
69
|
|
|
71
70
|
const rangedWeapon = (app) => { const e = equippedEntry(app.ch.equipped.ranged); return e != null ? app.ds.item(e) : undefined; };
|
|
@@ -127,13 +126,13 @@ export function autoConsumablesDialog(app) {
|
|
|
127
126
|
};
|
|
128
127
|
}
|
|
129
128
|
|
|
130
|
-
/** The rows under the paperdoll on the
|
|
129
|
+
/** The rows under the paperdoll on the Character tab. */
|
|
131
130
|
export function gearExtraRows(app) {
|
|
132
131
|
const rows = [H(''), H('Upkeep')];
|
|
133
132
|
const q = app.g.repairQuote();
|
|
134
133
|
rows.push({
|
|
135
|
-
text: `Repair all ${q.cost ?
|
|
136
|
-
detail: () => [bold('Repair'), '', q.cost ? `Mending everything you wear costs ${
|
|
134
|
+
text: `Repair all ${q.cost ? coinText(q.cost) : dim('nothing to mend')}${q.cost ? dim(q.vendorName ? ` at ${q.vendorName}` : ' nobody here repairs') : ''}`,
|
|
135
|
+
detail: () => [bold('Repair'), '', q.cost ? `Mending everything you wear costs ${coinText(q.cost)}.` : 'Everything you wear is in good shape.', q.vendorName ? dim(`Priced at ${q.vendorName}.`) : dim('A smith or general goods vendor repairs.'), '', dim('Enter: repair all r on a slot: repair one piece')],
|
|
137
136
|
act: (a) => repairAll(a),
|
|
138
137
|
});
|
|
139
138
|
const weapon = rangedWeapon(app);
|
package/src/cli/tuiLoot.mjs
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
// keeps the haul in `state.pendingLoot`; Enter takes one row into the bags, a takes everything that fits, Esc
|
|
3
3
|
// closes it (outdoor leftovers are gone then, an opened bag item keeps its remainder). With auto-loot on (the
|
|
4
4
|
// default) fights never hold their loot and this window does not open.
|
|
5
|
-
import { bold, dim, yellow, green, red, qualityPaint } from './text.mjs';
|
|
5
|
+
import { bold, dim, yellow, green, red, qualityPaint, coinText } from './text.mjs';
|
|
6
6
|
import { itemLines } from './tuiViews.mjs';
|
|
7
|
-
import { moneyText } from '../domain/economy/money.ts';
|
|
8
7
|
import { formatItemName } from '../domain/items/itemDisplayName.ts';
|
|
9
8
|
import { farmNext } from './tuiFight.mjs';
|
|
10
9
|
import { noteLoot } from './tuiFightPanes.mjs';
|
|
@@ -32,7 +31,7 @@ export function takeAll(app) {
|
|
|
32
31
|
if (!app.result(r)) return;
|
|
33
32
|
noteLoot(app, r.taken, r.copper);
|
|
34
33
|
for (const t of r.taken) app.say(` ${yellow('You receive')} ${app.itemLabel(t.item)}${t.count > 1 ? ` x${t.count}` : ''}`);
|
|
35
|
-
if (r.copper) app.say(` ${yellow('You loot')} ${
|
|
34
|
+
if (r.copper) app.say(` ${yellow('You loot')} ${coinText(r.copper)}`);
|
|
36
35
|
if (r.remaining) app.say(red(` ${r.remaining} left in there: your bags are full.`));
|
|
37
36
|
if (r.emptied && app.top()?.id === 'loot') app.pop();
|
|
38
37
|
}
|
|
@@ -69,9 +68,9 @@ export function lootDialog(app) {
|
|
|
69
68
|
const rows = [{ text: `${KIND_LABEL[L.kind] ?? L.kind}: ${L.title}`, header: true }];
|
|
70
69
|
if (L.copper > 0) {
|
|
71
70
|
rows.push({
|
|
72
|
-
text:
|
|
73
|
-
detail: () => [bold('Money'),
|
|
74
|
-
act: (a2) => { const r = a2.g.takeLootMoney(); if (a2.result(r)) { noteLoot(a2, [], r.copper); a2.say(` ${yellow('You loot')} ${
|
|
71
|
+
text: coinText(L.copper),
|
|
72
|
+
detail: () => [bold('Money'), coinText(L.copper)],
|
|
73
|
+
act: (a2) => { const r = a2.g.takeLootMoney(); if (a2.result(r)) { noteLoot(a2, [], r.copper); a2.say(` ${yellow('You loot')} ${coinText(r.copper)}`); if (r.emptied) a2.pop(); } },
|
|
75
74
|
});
|
|
76
75
|
}
|
|
77
76
|
L.rows.forEach((r, i) => {
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// The world map on the Travel tab: phonecraft's combined Azeroth terrain map (the picture every zone's
|
|
2
|
+
// `mapPos` is measured on; scripts/bake-world-map.mjs) drawn in the detail pane with pins for where you
|
|
3
|
+
// stand, the zone under the cursor and the route a journey takes between them. On the road the pin walks its
|
|
4
|
+
// leg. Two packs, like the creature art: src/data/world-map.bin.gz as truecolour half-block cells (two map
|
|
5
|
+
// pixels a row), or src/data/world-map-sixel.bin.br as the terminal's own pixels where it draws sixel
|
|
6
|
+
// (sixel.mjs). No pack, no colour, or no room: no map, never an error.
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { gunzipSync, brotliDecompressSync } from 'node:zlib';
|
|
9
|
+
import { useColor, cyan, yellow, dim, vis } from './text.mjs';
|
|
10
|
+
import { sixelImage, sixelOn, cellSize } from './sixel.mjs';
|
|
11
|
+
|
|
12
|
+
const SMALL_URL = new URL('../data/world-map.bin.gz', import.meta.url);
|
|
13
|
+
const SIXEL_URL = new URL('../data/world-map-sixel.bin.br', import.meta.url);
|
|
14
|
+
|
|
15
|
+
// ---- the packs ----------------------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** A pack as { w, h, pal, idx } (a palette index per pixel, every pixel drawn), or null. */
|
|
18
|
+
function parse(buf) {
|
|
19
|
+
if (buf.toString('ascii', 0, 5) !== 'AZMAP' || buf[5] !== 1) return null;
|
|
20
|
+
const w = buf.readUInt16LE(6), h = buf.readUInt16LE(8), palLen = buf[10], palEnd = 11 + palLen * 3;
|
|
21
|
+
const idx = buf.subarray(palEnd, palEnd + w * h);
|
|
22
|
+
return idx.length === w * h ? { w, h, pal: buf.subarray(11, palEnd), idx } : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let small, big; // undefined: not read yet; null: not there
|
|
26
|
+
function smallMap() {
|
|
27
|
+
if (small === undefined) { try { small = parse(gunzipSync(readFileSync(SMALL_URL))); } catch { small = null; } }
|
|
28
|
+
return small;
|
|
29
|
+
}
|
|
30
|
+
function sixelMap() {
|
|
31
|
+
if (big === undefined) { try { big = parse(brotliDecompressSync(readFileSync(SIXEL_URL))); } catch { big = null; } }
|
|
32
|
+
return big;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Is there a map to draw? */
|
|
36
|
+
export const hasWorldMap = () => useColor && !!smallMap();
|
|
37
|
+
|
|
38
|
+
/** The map area-averaged to tw x th, as packed rgb. */
|
|
39
|
+
function resample(m, tw, th) {
|
|
40
|
+
const out = new Uint8Array(tw * th * 3), sx = m.w / tw, sy = m.h / th;
|
|
41
|
+
for (let ty = 0; ty < th; ty++) {
|
|
42
|
+
const y0 = Math.floor(ty * sy), y1 = Math.max(y0 + 1, Math.floor((ty + 1) * sy));
|
|
43
|
+
for (let tx = 0; tx < tw; tx++) {
|
|
44
|
+
const x0 = Math.floor(tx * sx), x1 = Math.max(x0 + 1, Math.floor((tx + 1) * sx));
|
|
45
|
+
let r = 0, g = 0, b = 0, n = 0;
|
|
46
|
+
for (let y = y0; y < y1 && y < m.h; y++) for (let x = x0; x < x1 && x < m.w; x++) {
|
|
47
|
+
const p = m.idx[y * m.w + x] * 3;
|
|
48
|
+
r += m.pal[p]; g += m.pal[p + 1]; b += m.pal[p + 2]; n++;
|
|
49
|
+
}
|
|
50
|
+
if (!n) continue;
|
|
51
|
+
const d = (ty * tw + tx) * 3;
|
|
52
|
+
out[d] = r / n; out[d + 1] = g / n; out[d + 2] = b / n;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---- what to pin -----------------------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The pins in map fractions (0..1 from the top-left, a zone's `mapPos`): `you`, walking its leg when a journey
|
|
62
|
+
* is under way; `dest`, the zone under the cursor (a journey's destination when none is given); `route`, the
|
|
63
|
+
* zones the trip passes after you, in order. `legs` is a travel preview's plan (or any list of { to }); a
|
|
64
|
+
* journey under way brings its own legs.
|
|
65
|
+
*/
|
|
66
|
+
function marks(app, dest, legs) {
|
|
67
|
+
const ds = app.ds, pos = (id) => ds.zone(id)?.mapPos ?? null;
|
|
68
|
+
const st = app.g.state.char.journey ? app.g.journeyStatus() : null;
|
|
69
|
+
let you = pos(app.ch.zoneId);
|
|
70
|
+
if (st) {
|
|
71
|
+
const i = st.progress.legIndex, leg = st.legs[i];
|
|
72
|
+
const a = leg ? pos(leg.from) : null, b = leg ? pos(leg.to) : null;
|
|
73
|
+
const t = leg?.seconds > 0 ? Math.min(1, Math.max(0, st.progress.legElapsedSeconds / leg.seconds)) : 1;
|
|
74
|
+
// Snapped to 1/250 of the map, so a tick that moves the pin under a pixel does not redraw (or re-encode) it.
|
|
75
|
+
you = a && b ? [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t].map((v) => Math.round(v * 250) / 250) : b ?? a ?? you;
|
|
76
|
+
legs = st.legs.slice(i);
|
|
77
|
+
dest ??= ds.zone(st.destination);
|
|
78
|
+
}
|
|
79
|
+
const route = [];
|
|
80
|
+
for (const l of legs ?? []) { const p = pos(l.to); if (p) route.push(p); }
|
|
81
|
+
return { you, dest: dest?.mapPos ?? null, route };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const at3 = (p) => (p ? `${p[0].toFixed(3)},${p[1].toFixed(3)}` : '-');
|
|
85
|
+
|
|
86
|
+
// ---- half-block cells -------------------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
const RESET = '\x1b[0m';
|
|
89
|
+
const YOU = { glyph: '●', rgb: '255;210;60' }, DEST = { glyph: '◆', rgb: '90;220;255' }, ROUTE = { glyph: '·', rgb: '255;240;190' };
|
|
90
|
+
|
|
91
|
+
/** The cells a straight line crosses between two cells (Bresenham), both ends included. */
|
|
92
|
+
function lineCells([x0, y0], [x1, y1]) {
|
|
93
|
+
const out = [], dx = Math.abs(x1 - x0), dy = -Math.abs(y1 - y0), sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
|
|
94
|
+
let err = dx + dy, x = x0, y = y0;
|
|
95
|
+
for (;;) {
|
|
96
|
+
out.push([x, y]);
|
|
97
|
+
if (x === x1 && y === y1) return out;
|
|
98
|
+
const e2 = 2 * err;
|
|
99
|
+
if (e2 >= dy) { err += dy; x += sx; }
|
|
100
|
+
if (e2 <= dx) { err += dx; y += sy; }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The map as half-block lines that fit maxCols x maxRows keeping its proportions, the pins as glyphs on
|
|
105
|
+
* their cells (the cell's own colour behind them). */
|
|
106
|
+
function halfBlockLines(m, mk, maxCols, maxRows) {
|
|
107
|
+
const scale = Math.min(maxCols / m.w, (maxRows * 2) / m.h);
|
|
108
|
+
const gw = Math.max(2, Math.round(m.w * scale)), gh = Math.max(2, Math.round(m.h * scale) + 1) & ~1, rows = gh / 2;
|
|
109
|
+
const s = resample(m, gw, gh);
|
|
110
|
+
const cell = (p) => [Math.min(gw - 1, Math.floor(p[0] * gw)), Math.min(rows - 1, Math.floor(p[1] * rows))];
|
|
111
|
+
const over = new Map();
|
|
112
|
+
const pts = [mk.you, ...mk.route].filter(Boolean).map(cell);
|
|
113
|
+
for (let i = 0; i + 1 < pts.length; i++) for (const c of lineCells(pts[i], pts[i + 1])) over.set(c.join(), ROUTE);
|
|
114
|
+
if (mk.dest) over.set(cell(mk.dest).join(), DEST);
|
|
115
|
+
if (mk.you) over.set(cell(mk.you).join(), YOU);
|
|
116
|
+
const lines = [];
|
|
117
|
+
for (let y = 0; y < rows; y++) {
|
|
118
|
+
let line = '';
|
|
119
|
+
for (let x = 0; x < gw; x++) {
|
|
120
|
+
const t = (y * 2 * gw + x) * 3, u = ((y * 2 + 1) * gw + x) * 3, o = over.get(`${x},${y}`);
|
|
121
|
+
if (o) line += `\x1b[38;2;${o.rgb}m\x1b[48;2;${(s[t] + s[u]) >> 1};${(s[t + 1] + s[u + 1]) >> 1};${(s[t + 2] + s[u + 2]) >> 1}m${o.glyph}${RESET}`;
|
|
122
|
+
else line += `\x1b[38;2;${s[t]};${s[t + 1]};${s[t + 2]}m\x1b[48;2;${s[u]};${s[u + 1]};${s[u + 2]}m▀${RESET}`;
|
|
123
|
+
}
|
|
124
|
+
lines.push(line);
|
|
125
|
+
}
|
|
126
|
+
return lines;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const cache = new Map();
|
|
130
|
+
const CACHE_MAX = 48;
|
|
131
|
+
function remember(key, lines) {
|
|
132
|
+
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value);
|
|
133
|
+
cache.set(key, lines);
|
|
134
|
+
return lines;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---- sixel pixels ------------------------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
const fitted = new Map(); // `${w}|${tw}x${th}` -> the map resampled to that size, kept for the next pin move
|
|
140
|
+
const FIT_MAX = 4;
|
|
141
|
+
|
|
142
|
+
/** The pins painted on the fitted map: a dashed route, a cyan diamond at the destination, a gold disc on you,
|
|
143
|
+
* each with a dark rim so it reads on any terrain. Sizes follow the picture's width. */
|
|
144
|
+
function paintPixels(base, tw, th, mk) {
|
|
145
|
+
const rgba = new Uint8Array(tw * th * 4);
|
|
146
|
+
for (let i = 0; i < tw * th; i++) { rgba[i * 4] = base[i * 3]; rgba[i * 4 + 1] = base[i * 3 + 1]; rgba[i * 4 + 2] = base[i * 3 + 2]; rgba[i * 4 + 3] = 255; }
|
|
147
|
+
const put = (x, y, c) => { if (x >= 0 && y >= 0 && x < tw && y < th) { const d = (y * tw + x) * 4; rgba[d] = c[0]; rgba[d + 1] = c[1]; rgba[d + 2] = c[2]; } };
|
|
148
|
+
const disc = (cx, cy, r, c) => { for (let y = Math.floor(cy - r); y <= cy + r; y++) for (let x = Math.floor(cx - r); x <= cx + r; x++) if ((x - cx) ** 2 + (y - cy) ** 2 <= r * r) put(x, y, c); };
|
|
149
|
+
const diamond = (cx, cy, r, c) => { for (let y = Math.floor(cy - r); y <= cy + r; y++) for (let x = Math.floor(cx - r); x <= cx + r; x++) if (Math.abs(x - cx) + Math.abs(y - cy) <= r) put(x, y, c); };
|
|
150
|
+
const DARK = [24, 20, 16], r = Math.max(3, tw / 90), px = (p) => [p[0] * tw, p[1] * th];
|
|
151
|
+
const pts = [mk.you, ...mk.route].filter(Boolean).map(px);
|
|
152
|
+
const dash = r * 2.2, gap = r * 1.6;
|
|
153
|
+
for (const [rad, c] of [[r / 2.6 + 1, DARK], [r / 2.6, [255, 240, 190]]]) {
|
|
154
|
+
let along = 0;
|
|
155
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
156
|
+
const [x0, y0] = pts[i], [x1, y1] = pts[i + 1], len = Math.hypot(x1 - x0, y1 - y0);
|
|
157
|
+
for (let d = 0; d <= len; d++) if ((along + d) % (dash + gap) < dash) disc(x0 + ((x1 - x0) * d) / len, y0 + ((y1 - y0) * d) / len, rad, c);
|
|
158
|
+
along += len;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (mk.dest) { const [x, y] = px(mk.dest); diamond(x, y, r * 1.5 + 1.5, DARK); diamond(x, y, r * 1.5, [90, 220, 255]); }
|
|
162
|
+
if (mk.you) { const [x, y] = px(mk.you); disc(x, y, r + 1.5, DARK); disc(x, y, r, [255, 210, 60]); disc(x, y, r / 3, DARK); }
|
|
163
|
+
return rgba;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The map as sixel placeholder lines (sixel.mjs) fitted to maxCols x maxRows, or null when sixel is off. */
|
|
167
|
+
function sixelLines(m, mk, key, maxCols, maxRows) {
|
|
168
|
+
if (!sixelOn()) return null;
|
|
169
|
+
const { w: cw, h: ch } = cellSize();
|
|
170
|
+
const scale = Math.min((maxCols * cw) / m.w, (maxRows * ch) / m.h);
|
|
171
|
+
const tw = Math.max(1, Math.round(m.w * scale)), th = Math.max(1, Math.round(m.h * scale));
|
|
172
|
+
return sixelImage(`map|${m.w}|${tw}x${th}|${key}`, () => {
|
|
173
|
+
const fitKey = `${m.w}|${tw}x${th}`;
|
|
174
|
+
let base = fitted.get(fitKey);
|
|
175
|
+
if (!base) {
|
|
176
|
+
if (fitted.size >= FIT_MAX) fitted.delete(fitted.keys().next().value);
|
|
177
|
+
fitted.set(fitKey, (base = resample(m, tw, th)));
|
|
178
|
+
}
|
|
179
|
+
return { w: tw, h: th, rgba: paintPixels(base, tw, th, mk) };
|
|
180
|
+
}, maxCols, maxRows);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---- the lines a pane draws ---------------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The world map for a detail pane, as lines no wider than `maxCols` and no more than `maxRows` of them (the
|
|
187
|
+
* last a legend): you pinned where you stand, `dest` (a zone) pinned if given, the `legs` of a planned trip
|
|
188
|
+
* (or the journey under way) drawn as a route. Null without colour, a pack, a position, or room.
|
|
189
|
+
*/
|
|
190
|
+
export function worldMapLines(app, { dest = null, legs = null, maxCols, maxRows } = {}) {
|
|
191
|
+
if (!useColor || !(maxCols >= 10) || !(maxRows >= 6)) return null;
|
|
192
|
+
const m = smallMap();
|
|
193
|
+
if (!m) return null;
|
|
194
|
+
const mk = marks(app, dest, legs);
|
|
195
|
+
if (!mk.you && !mk.dest) return null;
|
|
196
|
+
const key = [mk.you, mk.dest, ...mk.route].map(at3).join('|');
|
|
197
|
+
const rows = maxRows - 1;
|
|
198
|
+
let art = sixelLines(sixelMap() ?? m, mk, key, maxCols, rows);
|
|
199
|
+
if (!art) {
|
|
200
|
+
const ck = `${maxCols}x${rows}|${key}`;
|
|
201
|
+
art = cache.get(ck) ?? remember(ck, halfBlockLines(m, mk, maxCols, rows));
|
|
202
|
+
}
|
|
203
|
+
const legend = `${yellow(YOU.glyph)} you${mk.dest ? ` ${cyan(DEST.glyph)} destination` : ''}${mk.route.length ? dim(' ┄ route') : ''}`;
|
|
204
|
+
return [...art, legend];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** `lines` with the map under them, as large as the detail pane has room for; `lines` alone when there is none. */
|
|
208
|
+
export function withWorldMap(app, lines, opts = {}) {
|
|
209
|
+
const L = app.layout?.();
|
|
210
|
+
if (!L || !(L.detailW > 2)) return lines;
|
|
211
|
+
const w = L.detailW - 2, used = lines.reduce((n, l) => n + Math.max(1, Math.ceil(vis(l) / w)), 0);
|
|
212
|
+
const map = worldMapLines(app, { ...opts, maxCols: w, maxRows: L.mainH - used - 1 });
|
|
213
|
+
return map ? [...lines, '', ...map] : lines;
|
|
214
|
+
}
|
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
|
}
|