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.
Files changed (51) hide show
  1. package/README.md +43 -10
  2. package/bin/azsh.mjs +15 -1
  3. package/package.json +5 -2
  4. package/src/cli/hdArt.mjs +1 -1
  5. package/src/cli/iconArt.mjs +11 -1
  6. package/src/cli/modelArt.mjs +7 -4
  7. package/src/cli/oocPrep.mjs +32 -0
  8. package/src/cli/repl.mjs +16 -0
  9. package/src/cli/sixel.mjs +26 -14
  10. package/src/cli/text.mjs +16 -0
  11. package/src/cli/tui.mjs +33 -9
  12. package/src/cli/tuiAutopilot.mjs +17 -8
  13. package/src/cli/tuiBags.mjs +37 -4
  14. package/src/cli/tuiCheck.mjs +276 -15
  15. package/src/cli/tuiCity.mjs +31 -31
  16. package/src/cli/tuiCompanionKit.mjs +6 -6
  17. package/src/cli/tuiCompanions.mjs +189 -29
  18. package/src/cli/tuiCreate.mjs +7 -0
  19. package/src/cli/tuiDungeon.mjs +56 -20
  20. package/src/cli/tuiFight.mjs +63 -21
  21. package/src/cli/tuiFightPanes.mjs +15 -6
  22. package/src/cli/tuiFloor.mjs +20 -8
  23. package/src/cli/tuiGear.mjs +9 -10
  24. package/src/cli/tuiLoot.mjs +5 -6
  25. package/src/cli/tuiMap.mjs +214 -0
  26. package/src/cli/tuiPaperdoll.mjs +6 -5
  27. package/src/cli/tuiPartyStage.mjs +36 -7
  28. package/src/cli/tuiPet.mjs +16 -8
  29. package/src/cli/tuiProfessions.mjs +23 -23
  30. package/src/cli/tuiSaves.mjs +14 -0
  31. package/src/cli/tuiSearch.mjs +4 -5
  32. package/src/cli/tuiSettings.mjs +3 -4
  33. package/src/cli/tuiStage.mjs +72 -16
  34. package/src/cli/tuiTalents.mjs +3 -4
  35. package/src/cli/tuiViews.mjs +371 -84
  36. package/src/cli/tuiWiki.mjs +2 -3
  37. package/src/cli/tuiWorld.mjs +88 -5
  38. package/src/data/gear-art.bin.br +0 -0
  39. package/src/data/hd-art.json +36 -36
  40. package/src/data/model-art-worn.bin.br +0 -0
  41. package/src/data/model-art.bin.gz +0 -0
  42. package/src/data/tables/creatures.json.gz +0 -0
  43. package/src/data/world-map-sixel.bin.br +0 -0
  44. package/src/data/world-map.bin.gz +0 -0
  45. package/src/domain/character/character.ts +6 -0
  46. package/src/domain/pet/pet.ts +31 -0
  47. package/src/engine/combat/pickAction.ts +3494 -3487
  48. package/src/engine/combat/sessionChase.ts +12 -1
  49. package/src/engine/game/loadReconcile.ts +21 -1
  50. package/src/engine/game/pets.ts +33 -1
  51. package/src/engine/game/types.ts +4 -0
package/README.md CHANGED
@@ -13,7 +13,7 @@ front end. No graphics, no browser, no build step.
13
13
  - [Install](#install)
14
14
  - [Keys](#keys)
15
15
  - [Playing](#playing): [character](#new-character) · [world and autopilot](#world-and-the-autopilot) ·
16
- [fights](#fights) · [rotation](#rotation) · [gear](#gear-and-bags) · [vendors, trainers, talents](#vendors-trainers-talents) ·
16
+ [fights](#fights) · [rotation](#rotation) · [gear](#character-and-bags) · [vendors, trainers, talents](#vendors-trainers-talents) ·
17
17
  [professions](#professions) · [companions](#companions) · [dungeons](#dungeons) · [city services](#city-services)
18
18
  - [Art and terminals](#art-and-terminals)
19
19
  - [Line mode and saves](#line-mode-and-saves)
@@ -23,11 +23,19 @@ front end. No graphics, no browser, no build step.
23
23
  ## Install
24
24
 
25
25
  ```sh
26
- npx azeroth-shell # run it straight from npm
26
+ npx azeroth-shell # run it straight from npm
27
27
  npm install -g azeroth-shell # or install it once, then:
28
- azeroth-shell # pick a character or roll one (azsh for short)
28
+ azeroth-shell # or just azsh
29
+ azeroth-shell --art # optional: fetch the full-size art (179 MB) now instead of when asked
29
30
  ```
30
31
 
32
+ The package is the whole game. The one extra download is the full-size art, which only terminals
33
+ that draw sixel images use. It never comes down on its own: on such a terminal azsh asks first,
34
+ showing the size and the folder, or you fetch it with `--art` (that also works through
35
+ `npx azeroth-shell --art`). It comes from this repo's `art-1` GitHub release, and every file is
36
+ checked against the sha256 in `src/data/hd-art.json` before it is used. You don't need to know
37
+ beforehand whether your terminal draws sixel, because azsh checks that itself.
38
+
31
39
  From a checkout: `npm install -g .` or `node bin/azsh.mjs`.
32
40
 
33
41
  Needs Node 22.18 or newer (it strips TypeScript types natively). No dependencies. The tree is
@@ -42,7 +50,7 @@ every screen redraws in place.
42
50
 
43
51
  | Key | Does |
44
52
  | --- | --- |
45
- | `1`-`9` `0`, `Tab`, `Left` `Right` | World, Quests, Bags, Gear, Spells, Rotation, Talents, Travel, Professions, Companions; Character is the last tab (`Tab`/`Left`) |
53
+ | `1`-`9` `0`, `Tab`, `Left` `Right` | World, Quests, Bags, Character, Spells, Rotation, Talents, Travel, Professions, Companions; Settings is the last tab (`Tab`/`Left`) |
46
54
  | `Up` `Down` `j` `k` | move the cursor; the right pane describes the row |
47
55
  | `Enter` | act on the row: fight, open a quest, talk to a vendor or trainer, equip, cast, travel |
48
56
  | `Esc` | close the window on top; on a tab, back to World |
@@ -156,21 +164,24 @@ conditions hold fires.
156
164
  | `D` | target dummy: run the rotation against a dummy |
157
165
  | `A` | autopilot settings |
158
166
 
159
- ### Gear and Bags
167
+ ### Character and Bags
160
168
 
161
- **Gear** shows every slot with what you wear. `Enter` on a slot lists everything in your bags (and
169
+ **Character** shows every slot with what you wear, and your health, power, experience, money,
170
+ attributes and combat ratings under the item tooltip; `s` opens the rest (weapon skills,
171
+ professions, auras, home, pet, honor, reputation). `Enter` on a slot lists everything in your bags (and
162
172
  in the bank, in a bank town) that fits it, with the stat, armor and damage difference in the detail
163
173
  pane; `Enter` there puts it on. The engine's equipment helper scores your bags against what you
164
174
  wear: upgrades are listed at the top of the tab and marked `▲` in Bags. From 160 columns the tab is
165
175
  a character sheet: you in your gear in the middle, the worn pieces' icons down both sides, Shift+arrows
166
176
  walking the slots.
167
177
 
168
- ![The Gear tab as a character sheet: a level 60 shaman in raid gear, the slots' icons on both sides, the cursor walking them](media/gear.gif)
178
+ ![The Character tab as a sheet: a level 60 shaman in raid gear, the slots' icons on both sides, the cursor walking them](media/gear.gif)
169
179
 
170
180
  | Key | Does |
171
181
  | --- | --- |
172
182
  | `Enter` on a slot | list what fits, then `Enter` to equip |
173
- | `u` (Gear or Bags) | put on every upgrade at once |
183
+ | `s` | your stats, skills, auras, pet, honor and reputation |
184
+ | `u` (Character or Bags) | put on every upgrade at once |
174
185
  | `x` | take a piece off |
175
186
  | `b` (Bags) | open the bank, in a bank town |
176
187
  | `g` (Bags) | open Send goods (see [Professions](#professions)) |
@@ -294,21 +305,41 @@ terminal's own retro effect (Windows Terminal: `"experimental.retroTerminalEffec
294
305
  profile; cool-retro-term elsewhere).
295
306
 
296
307
  At 90 columns or wider the creature you face is drawn beside the fight frames and under its detail
297
- on the World tab. Your own character, in the gear you wear, stands on the Character tab, on the
308
+ on the World tab. Your own character, in the gear you wear, stands on the Character tab and its stats page, on the
298
309
  character picker, at creation and (from 130 columns) on the other side of the fight, and a
299
310
  companion's page shows them the same way. It is truecolour half-block art baked from the game's
300
311
  models; a terminal without 24-bit colour shows it in the nearest colours it has.
301
312
 
302
313
  A terminal that draws sixel images (Windows Terminal 1.22+, WezTerm, foot, iTerm2; VS Code with
303
- `terminal.integrated.enableImages`) gets every creature, every character in their gear and every
314
+ `terminal.integrated.enableImages`) can draw every creature, every character in their gear and every
304
315
  icon in real pixels instead. azsh asks the terminal at start.
305
316
 
317
+ The Travel tab has the world map under whatever you point at: both continents, a gold pin where you
318
+ stand, a cyan one on the zone under the cursor and the planned route dotted between them, legs by
319
+ sea and air included; on the road the pin walks its leg. It is the same terrain map the browser game
320
+ pans around, as half-block cells or, on a sixel terminal, as pixels. Both of its packs are small enough
321
+ to ship, so the map needs no download. The pane is short on a short terminal, and so is the map:
322
+ give the window forty rows or more and it grows into them.
323
+
324
+ The full-size art for that is not in the npm package: it is about 179 MB, too much to ship to every
325
+ terminal that cannot draw it. When azsh finds a sixel terminal without it, it offers to download it
326
+ from the project's GitHub release into `~/.azsh/art/`. Each file is checked against a sha256 the
327
+ package carries. Answer "Not now" and it asks again next run; "Never ask" stops the prompt. `azsh --art` fetches it
328
+ from the shell without starting the game. Until the
329
+ art is there, the half-block art stays.
330
+
331
+ | Command | Does |
332
+ | --- | --- |
333
+ | `:art` | say whether this terminal draws sixel and whether the full-size art is here |
334
+ | `:art download` | fetch the full-size art now (progress on the hint row, the screen redraws when done) |
335
+
306
336
  ![The same hero on the create screen: half-block art on the left, sixel on the right](media/art-compare.png)
307
337
 
308
338
  | Variable | Does |
309
339
  | --- | --- |
310
340
  | `AZSH_SIXEL=0` | keep the half-block art |
311
341
  | `AZSH_SIXEL=1` | turn sixel on for a terminal that does not say |
342
+ | `AZSH_ART_DIR` | read the full-size art from (and download it to) this folder instead |
312
343
  | `AZSH_HOME` | move the save folder (default `~/.azsh`) |
313
344
 
314
345
  ## Line mode and saves
@@ -368,6 +399,8 @@ npm run data:pack # gzip the bake after a data refresh; data:unpack reverses
368
399
  npm run art:bake # rebuild src/data/model-art.bin.gz from phonecraft's creature renders (needs ffmpeg)
369
400
  npm run art:gear # rebuild src/data/gear-art.bin.gz from phonecraft's paperdoll bake (needs Python with Pillow)
370
401
  npm run art:icons # rebuild src/data/icon-art.bin.gz from phonecraft's spell and item icons (needs ffmpeg)
402
+ npm run art:map # rebuild the Travel tab's world map, src/data/world-map.bin.gz and world-map-sixel.bin.br,
403
+ # from phonecraft's combined Azeroth terrain map (needs ffmpeg)
371
404
  npm run art:hd # rebuild the full-size art sixel draws: model-art-hd.bin.br, icon-art-hd.bin.br and
372
405
  # gear-art-hd/<body>.bin.br (needs ffmpeg, and Python with Pillow for the gear)
373
406
  ```
package/bin/azsh.mjs CHANGED
@@ -6,8 +6,22 @@ if (major < 22 || (major === 22 && minor < 18)) {
6
6
  console.error(`azsh needs Node 22.18 or newer (found ${process.versions.node}).`);
7
7
  process.exit(1);
8
8
  }
9
- await import('../src/cli/register.mjs');
10
9
  const argv = process.argv.slice(2);
10
+ if (argv.includes('--art')) {
11
+ // Fetch the full-size art up front instead of waiting for the in-game offer; says what and from where first.
12
+ const { hdManifest, missingHdBytes, downloadHdArt, downloadDir } = await import('../src/cli/hdArt.mjs');
13
+ const m = hdManifest(), bytes = missingHdBytes();
14
+ if (!m) { console.error('This build has no art manifest.'); process.exit(1); }
15
+ if (!bytes) { console.log(`The full-size art is all here (${downloadDir()}).`); process.exit(0); }
16
+ console.log(`Fetching ${Math.round(bytes / 1e6)} MB of full-size art (${m.files.length} files, sha256-checked)\n from ${m.base}\n into ${downloadDir()}`);
17
+ const res = await downloadHdArt((done, total) => {
18
+ if (process.stdout.isTTY) process.stdout.write(`\r ${Math.round(done / 1e6)} of ${Math.round(total / 1e6)} MB`);
19
+ });
20
+ if (process.stdout.isTTY) process.stdout.write('\n');
21
+ console.log(res.ok ? 'Done. A sixel terminal now draws creatures, characters and icons in real pixels.' : `Stopped: ${res.reason}`);
22
+ process.exit(res.ok ? 0 : 1);
23
+ }
24
+ await import('../src/cli/register.mjs');
11
25
  const wantRepl = argv.includes('--repl') || (!argv.includes('--tui') && (!process.stdin.isTTY || !process.stdout.isTTY));
12
26
  if (wantRepl) {
13
27
  const { main } = await import('../src/cli/repl.mjs');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azeroth-shell",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Azeroth shell. A WoW Classic 1.12 text RPG for the terminal, built on CMaNGOS data.",
6
6
  "keywords": [
@@ -30,6 +30,8 @@
30
30
  "src/",
31
31
  "!src/data/*-hd.bin.br",
32
32
  "!src/data/gear-art-hd/",
33
+ "!src/data/*.only.bin.br",
34
+ "!src/data/gear-art-hd.only/",
33
35
  "docs/"
34
36
  ],
35
37
  "engines": {
@@ -41,9 +43,10 @@
41
43
  "check": "node --import ./src/cli/register.mjs src/cli/bootCheck.mjs",
42
44
  "data:pack": "node scripts/pack-data.mjs",
43
45
  "data:unpack": "node scripts/pack-data.mjs --unpack",
44
- "art:bake": "node scripts/bake-model-art.mjs",
46
+ "art:bake": "node scripts/bake-model-art.mjs && node scripts/bake-model-art.mjs --worn",
45
47
  "art:gear": "python scripts/bake-gear-art.py",
46
48
  "art:icons": "node scripts/bake-icon-art.mjs",
49
+ "art:map": "node scripts/bake-world-map.mjs && node scripts/bake-world-map.mjs --sixel",
47
50
  "art:hd": "node scripts/bake-model-art.mjs --hd && node scripts/bake-icon-art.mjs --hd && python scripts/bake-gear-art.py --hd",
48
51
  "art:publish": "node scripts/publish-hd-art.mjs",
49
52
  "build:exe": "node scripts/build-exe.mjs",
package/src/cli/hdArt.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // The full-size art sixel draws (sixel.mjs, modelArt.mjs): creature sprites, icons and one gear file per body,
2
- // about 72 MB, too much to ship to every terminal that cannot draw it. The package carries only the manifest
2
+ // about 179 MB, too much to ship to every terminal that cannot draw it. The package carries only the manifest
3
3
  // (src/data/hd-art.json: the release tag, and each file's name, size and sha256); the files are assets of that
4
4
  // GitHub release, fetched on request into ~/.azsh/art/<tag>/ and checked against the manifest.
5
5
  //
@@ -8,7 +8,7 @@
8
8
  import { readFileSync } from 'node:fs';
9
9
  import { gunzipSync } from 'node:zlib';
10
10
  import { useColor, vis, wrapLines } from './text.mjs';
11
- import { sixelIconCells } from './sixel.mjs';
11
+ import { sixelIconCells, sixelOn } from './sixel.mjs';
12
12
 
13
13
  const PACK_URL = new URL('../data/icon-art.bin.gz', import.meta.url);
14
14
  const SIZES = [8, 6, 4];
@@ -94,6 +94,16 @@ export function iconArt(name, cols = 4, tone = '') {
94
94
  return iconCells(name, cols, tone)?.map((row) => row.join('')) ?? null;
95
95
  }
96
96
 
97
+ /**
98
+ * A one-line icon two cells wide and a space, to lead a line of text in a list (what someone wears, a
99
+ * quest's rewards). Only where the terminal draws sixel: as half-block cells it would be a smudge of colour,
100
+ * so there it is ''. A name with no art keeps the column with blanks.
101
+ */
102
+ export function miniIcon(name) {
103
+ if (!sixelOn()) return '';
104
+ return `${iconArt(name, 2)?.[0] ?? ' '} `;
105
+ }
106
+
97
107
  /** A blank the size of an icon, for rows in a list that mixes icons and none. */
98
108
  export const iconBlank = (cols = 4) => Array.from({ length: cols / 2 }, () => ' '.repeat(cols));
99
109
 
@@ -175,18 +175,21 @@ const gearHd = new Map(); // slug -> { buf, pal, canvas, skins, overlays } or nu
175
175
  export function forgetHdArt() { forgetSixelPacks(); gearHd.clear(); cache.clear(); }
176
176
 
177
177
  /** A body's hd file (scripts/bake-gear-art.py --hd): its palette, then the body section the small pack has
178
- * for it. Read the first time the body is drawn; about 11 MB each once unpacked. */
178
+ * for it. Version 1 (a release's 128 px bodies) holds the canvas and each x y w h in a byte, version 2 (256 px,
179
+ * past a byte) in a u16. Read the first time the body is drawn. */
179
180
  function gearBodyHd(slug) {
180
181
  if (gearHd.has(slug)) return gearHd.get(slug);
181
182
  let body = null;
182
183
  try {
183
184
  const buf = brotliDecompressSync(readFileSync(hdArtPath(`gear-art-hd/${slug}.bin.br`)));
184
- if (buf.toString('ascii', 0, 7) === 'AZGEARB' && buf[7] === 1) {
185
+ const version = buf[7];
186
+ if (buf.toString('ascii', 0, 7) === 'AZGEARB' && (version === 1 || version === 2)) {
185
187
  let off = 8;
188
+ const n = version === 2 ? 2 : 1, num = (o) => (n === 2 ? buf.readUInt16LE(o) : buf[o]);
186
189
  const palLen = buf[off++];
187
190
  const pal = buf.subarray(off, off + palLen * 3); off += palLen * 3;
188
191
  off += 1 + buf[off]; // the slug again
189
- const canvas = buf[off++];
192
+ const canvas = num(off); off += n;
190
193
  body = { buf, pal, canvas, skins: new Map(), overlays: new Map() };
191
194
  const skinCount = buf[off++];
192
195
  for (let i = 0; i < skinCount; i++) { body.skins.set(buf[off], off + 1); off += 1 + canvas * canvas; }
@@ -194,7 +197,7 @@ function gearBodyHd(slug) {
194
197
  for (let i = 0; i < ovCount; i++) {
195
198
  const keyLen = buf[off++];
196
199
  const key = buf.toString('ascii', off, off + keyLen); off += keyLen;
197
- const x = buf[off], y = buf[off + 1], w = buf[off + 2], h = buf[off + 3]; off += 4;
200
+ const x = num(off), y = num(off + n), w = num(off + 2 * n), h = num(off + 3 * n); off += 4 * n;
198
201
  body.overlays.set(key, { off, x, y, w, h });
199
202
  off += w * h;
200
203
  }
@@ -0,0 +1,32 @@
1
+ // Out-of-combat prep before a pull: the rotation's buffs phase (armours, Intellect, imbues, conjures, the pet)
2
+ // cast standing still, not on the fight's first ticks. phonecraft autopilotController.ts prepOoc (:889) without
3
+ // its fillMana: the mana the buffs cost stays spent, and the autopilot drinks it back before it pulls.
4
+ import { pickOocAction, useOocPrepItem } from '../autopilot/combatRotation.ts';
5
+ import { maxPower } from '../domain/character/character.ts';
6
+ import { powerType } from '../domain/character/characterStats.ts';
7
+
8
+ const MAX_PREP_ACTIONS = 8; // each pick is a guarded rule cast once; the cap keeps a refused rule from looping
9
+
10
+ /** Cast what the buffs phase wants up. Returns the names cast and the mana left as a fraction (1 for non-mana). */
11
+ export function prepOoc(g, ds) {
12
+ const cast = [];
13
+ for (let i = 0; i < MAX_PREP_ACTIONS; i++) {
14
+ const action = pickOocAction(g.rotation(), g.state, ds);
15
+ if (!action) break;
16
+ if (action.kind === 'spell') {
17
+ if (!g.castOutOfCombat(action.spellId).ok) break;
18
+ cast.push(ds.spell(action.spellId)?.name ?? `spell ${action.spellId}`);
19
+ } else if (action.kind === 'item') {
20
+ if (!useOocPrepItem(g, action)) break;
21
+ cast.push(ds.item(action.itemEntry)?.name ?? `item ${action.itemEntry}`);
22
+ } else break;
23
+ }
24
+ const ch = g.state.char;
25
+ const max = powerType(ch) === 'mana' ? maxPower(ch, ds) : 0;
26
+ return { cast: [...new Set(cast)], manaPct: max > 0 ? ch.power / max : 1 };
27
+ }
28
+
29
+ /** The log line for a prep that cast something, or null. */
30
+ export function prepText(prep) {
31
+ return prep.cast.length ? `Buffed up: ${prep.cast.join(', ')}` : null;
32
+ }
package/src/cli/repl.mjs CHANGED
@@ -17,6 +17,7 @@ import { planStep, noteQuestDefeat } from '../autopilot/plan.ts';
17
17
 
18
18
  // Deaths on one quest before world questing gives up on it and moves to the next.
19
19
  const QUEST_SKIP_DEFEATS = 2;
20
+ import { prepOoc, prepText } from './oocPrep.mjs';
20
21
  import { pullOpts, squadLogNames, squadModeOf, setSquadMode, SQUAD_MODE_LABEL, SQUAD_MODE_WORDS, COMPANION_HINT_LINES } from './squad.mjs';
21
22
 
22
23
  const useColor = stdout.isTTY && !process.env.NO_COLOR;
@@ -264,6 +265,8 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
264
265
 
265
266
  /** g.fight with the squad when the squad mode says so; the log names the companions. */
266
267
  const quickFight = (entry) => {
268
+ const buffed = prepText(prepOoc(g, ds));
269
+ if (buffed) out(dim(` ${buffed}`));
267
270
  const opts = pullOpts(g, ds, buffPace, entry, (l) => out(dim(` ${l}`)));
268
271
  const r = g.fight(entry, opts);
269
272
  return { r, logNames: opts ? squadLogNames(g, entry, names()) : names() };
@@ -603,6 +606,7 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
603
606
  let done = false;
604
607
  let lastKind = null;
605
608
  let lastQuest = null; // the quest the latest step worked on, blamed when the planner gives up
609
+ let restedForBuffs = false; // the pull's buffs already sent the run to rest once
606
610
  let stopReason = null; // why the run ended: the planner's stop step, or 'steps' when the step budget ran out
607
611
  const skipped = [];
608
612
  const inLog = (entry) => g.state.questlog.quests.some((qs) => qs.entry === entry && qs.status !== 'turnedIn');
@@ -653,6 +657,18 @@ export function createCommands({ g, ds, out, persist, slot, arrive, skipJourney
653
657
  switch (step.kind) {
654
658
  case 'fight': {
655
659
  const c = ds.creature(step.creatureEntry);
660
+ // Buff before the pull, then drink back what the buffs cost: casting them on the fight's first ticks
661
+ // sent a mage in at three quarters of its bar. Once per pull, so a buff that falls off while it
662
+ // drinks is recast without another rest.
663
+ const prep = prepOoc(g, ds);
664
+ const buffed = prepText(prep);
665
+ if (buffed && !restedForBuffs && cfg.restMode === 'eat-then-wait' && prep.manaPct < cfg.recoverToPct) {
666
+ restedForBuffs = true; prog.recovering = true; lastKind = 'buff';
667
+ line = `${buffed} ${dim('rest before the pull')}`;
668
+ break;
669
+ }
670
+ restedForBuffs = false;
671
+ if (buffed) out(`${dim(String(i).padStart(3) + '.')} ${buffed}`);
656
672
  if (liveFight) {
657
673
  const started = liveFight(step, (r) => settle(r, step));
658
674
  if (!started.ok) { sets.blockedFights.add(step.creatureEntry); line = red(`fight ${c?.name}: ${started.reason}`); }
package/src/cli/sixel.mjs CHANGED
@@ -9,8 +9,9 @@
9
9
  //
10
10
  // The pixels come from model-art-hd.bin.br (scripts/bake-model-art.mjs --hd): the same sprites as the
11
11
  // half-block pack at the renders' own size, up to 128 px wide; icons from icon-art-hd.bin.br. Neither ships in
12
- // the package: hdArt.mjs finds them (downloaded, or a checkout's bake). No pack, no sprite, or no sixel: null,
13
- // and the caller draws the half-block art. AZSH_SIXEL=0 turns it off, AZSH_SIXEL=1 on without asking.
12
+ // the package: hdArt.mjs finds them (downloaded, or a checkout's bake). model-art-worn.bin.br does ship: the
13
+ // forms and costumes a player wears, read for any sprite the hd pack lacks. No pack, no sprite, or no sixel:
14
+ // null, and the caller draws the half-block art. AZSH_SIXEL=0 turns it off, AZSH_SIXEL=1 on without asking.
14
15
  import { readFileSync } from 'node:fs';
15
16
  import { brotliDecompressSync } from 'node:zlib';
16
17
  import { useColor } from './text.mjs';
@@ -36,35 +37,46 @@ export function noteDeviceAttributes(params) {
36
37
  export function noteCellSize(h, w) {
37
38
  if (h > 0 && w > 0) { cellH = h; cellW = w; }
38
39
  }
40
+ /** The cell in pixels, { w, h }: what a caller fitting its own picture to a pane sizes by. */
41
+ export const cellSize = () => ({ w: cellW, h: cellH });
39
42
 
40
43
  // ---- the source images ------------------------------------------------------------------------------
41
44
 
42
- let index = null; // displayId -> byte offset of the sprite in `pack` (the half-block pack's layout, AZART v1)
43
- let pack = null;
45
+ const WORN_URL = new URL('../data/model-art-worn.bin.br', import.meta.url);
46
+ let index = null; // displayId -> { pack, off }: the sprite's bytes (the half-block pack's layout, AZART v1)
44
47
 
45
- function load() {
46
- if (index) return index;
47
- index = new Map();
48
+ /** Index one pack's sprites; an id already indexed keeps the pack it came from. */
49
+ function indexPack(file) {
48
50
  try {
49
- pack = brotliDecompressSync(readFileSync(hdArtPath('model-art-hd.bin.br')));
50
- if (pack.toString('ascii', 0, 5) !== 'AZART' || pack[5] !== 1) return index;
51
+ const pack = brotliDecompressSync(readFileSync(file));
52
+ if (pack.toString('ascii', 0, 5) !== 'AZART' || pack[5] !== 1) return;
51
53
  const count = pack.readUInt32LE(6);
52
54
  let off = 10;
53
55
  for (let i = 0; i < count && off + 7 <= pack.length; i++) {
54
- index.set(pack.readUInt32LE(off), off);
56
+ const id = pack.readUInt32LE(off);
57
+ if (!index.has(id)) index.set(id, { pack, off });
55
58
  off += 7 + pack[off + 6] * 3 + pack[off + 4] * pack[off + 5];
56
59
  }
57
- } catch { pack = null; }
60
+ } catch { /* no pack: its sprites draw half-block */ }
61
+ }
62
+
63
+ function load() {
64
+ if (index) return index;
65
+ index = new Map();
66
+ const hd = hdArtPath('model-art-hd.bin.br');
67
+ if (hd) indexPack(hd);
68
+ indexPack(WORN_URL);
58
69
  return index;
59
70
  }
60
71
 
61
72
  /** Forget the packs (and what was missing) so the next draw reads them again: after a download. */
62
- export function forgetSixelPacks() { index = pack = iconIndex = iconPack = null; images.clear(); byKey.clear(); }
73
+ export function forgetSixelPacks() { index = iconIndex = iconPack = null; images.clear(); byKey.clear(); }
63
74
 
64
75
  /** The sprite for a display id as { w, h, rgba } (already cropped to its opaque box), or null. */
65
76
  function sourceOf(displayId) {
66
- const off = load().get(displayId);
67
- if (off == null) return null;
77
+ const at = load().get(displayId);
78
+ if (!at) return null;
79
+ const { pack, off } = at;
68
80
  const w = pack[off + 4], h = pack[off + 5], palLen = pack[off + 6], pal = off + 7, idx = pal + palLen * 3;
69
81
  const rgba = new Uint8Array(w * h * 4);
70
82
  for (let i = 0; i < w * h; i++) {
package/src/cli/text.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // Text helpers shared by the line REPL and the windowed TUI: quest text codes, wrapping, fuzzy
2
2
  // picking, ANSI colour and visible-width maths. Nothing here touches game state.
3
3
  import { className, raceName } from '../domain/core/ids.ts';
4
+ import { coinParts, moneyText } from '../domain/economy/money.ts';
4
5
 
5
6
  export const useColor = !process.env.NO_COLOR;
6
7
  // A reset inside the painted text (a coloured item name in a green sentence) re-opens this paint,
@@ -25,6 +26,21 @@ export function qualityPaint(quality) { return QUALITY_PAINT[quality ?? 1] ?? wh
25
26
  /** An item record's name in its quality colour; `fallback` when the record is missing. */
26
27
  export function itemText(it, fallback = 'item') { return qualityPaint(it?.quality)(it?.name ?? fallback); }
27
28
 
29
+ // The client's coin colours. A count is followed by its coin, as the money frame draws it: 1● 20● 5●.
30
+ const COIN_RGB = { g: [255, 210, 0], s: [205, 205, 215], c: [205, 120, 55] };
31
+ const COIN = Object.fromEntries(Object.entries(COIN_RGB).map(([k, [r, g, b]]) => [k, `\x1b[38;2;${r};${g};${b}m●\x1b[0m`]));
32
+ /** One coin, 'g', 's' or 'c', for a count written by hand; the letter without colour. */
33
+ export const coin = (k) => (useColor ? COIN[k] : k);
34
+ /** Copper as counts and coins (gold, silver, copper in their colours), an empty copper left off after a
35
+ * bigger coin as the client does; `zero` stands in for nothing when given ('free'). Without colour the
36
+ * letters: 1g 20s 5c. The counts take whatever paint is round them. */
37
+ export function coinText(copper, zero) {
38
+ if (zero != null && !(copper > 0)) return zero;
39
+ if (!useColor) return moneyText(copper);
40
+ const parts = coinParts(copper);
41
+ return parts.filter((p, i) => p.n || i === 0).map((p) => `${p.n}${COIN[p.k]}`).join(' ');
42
+ }
43
+
28
44
  const GENDER_TOKEN = /\$[Gg]\s*([^:]*):([^;]*);/g;
29
45
  /** $N name, $C class, $R race, $B break, $G male:female; (SharedDefines.h:26). Same codes as phonecraft questText. */
30
46
  export function questText(raw, ch) {
package/src/cli/tui.mjs CHANGED
@@ -24,7 +24,7 @@ import { honorDialog, weeklyHonorPass } from './tuiHonor.mjs';
24
24
  import { devCommand } from './tuiDev.mjs';
25
25
  import { savePicker } from './tuiSaves.mjs';
26
26
  import { wikiDialog } from './tuiWiki.mjs';
27
- import { bold, dim, cyan, yellow, green, red, inverse, fit, vis, cut, bar, pct, brightYellow, magenta, brightBlue, strip, qualityPaint } from './text.mjs';
27
+ import { bold, dim, cyan, yellow, green, red, inverse, fit, vis, cut, bar, pct, brightYellow, magenta, brightBlue, strip, qualityPaint, coinText } from './text.mjs';
28
28
  import { iconsOn, iconArt, iconBlank, wrapIconLines } from './iconArt.mjs';
29
29
  import { createGame } from '../engine/game.ts';
30
30
  import { toSave } from '../persistence/saveShape.ts';
@@ -33,7 +33,7 @@ import { characterCreation } from './tuiCreate.mjs';
33
33
  import { missingHdBytes, downloadHdArt, downloadDir } from './hdArt.mjs';
34
34
  import { forgetHdArt } from './modelArt.mjs';
35
35
  import { sixelOn } from './sixel.mjs';
36
- import { moneyText, coinsText } from '../domain/economy/money.ts';
36
+ import { coinsText } from '../domain/economy/money.ts';
37
37
  import { maxHealth, maxPower } from '../domain/character/character.ts';
38
38
  import { powerType } from '../domain/character/characterStats.ts';
39
39
  import { addItem } from '../domain/items/inventory.ts';
@@ -55,6 +55,7 @@ const HELP_LINES = [
55
55
  ' a in the search (or S on World) widens it to the whole world. Enter goes or opens.',
56
56
  ' z sit and rest 30 s ? this help',
57
57
  ' Ctrl-C / :quit save and leave Ctrl-L repaint a garbled screen',
58
+ ' c (World tab) / :logout save and go back to the character list',
58
59
  '',
59
60
  'In the world',
60
61
  ' Enter on a creature starts a live fight; f fights it instantly. Enter on a quest, vendor, trainer or',
@@ -75,22 +76,23 @@ const HELP_LINES = [
75
76
  ' W walks the floor instead: pick a place, the party walks the nav mesh, patrols walk, and what sees you pulls.',
76
77
  '',
77
78
  'Gear and bags',
78
- ' Gear tab: Enter on a slot lists everything in your bags (and the bank, in a bank town) that fits it.',
79
+ ' Character tab: your gear around you. Enter on a slot lists everything in your bags (and the bank, in a bank town) that fits it.',
80
+ ' Your stats sit under the tooltip; s opens the rest: weapon skills, professions, auras, home, pet, honor, reputation.',
79
81
  ' ▲ marks an upgrade the equipment helper found; u puts on every upgrade at once, x takes a piece off.',
80
82
  ' r repairs one piece, R everything; o opens the Outfitter (gear sets). :outfit <name> wears a set from anywhere.',
81
- ' In a big enough window the Gear tab is a character sheet (the Talents tab the three trees): Up and Down walk the icons in order, Shift+arrows move across them.',
83
+ ' In a big enough window the Character tab is a paperdoll (the Talents tab the three trees): Up and Down walk the icons in order, Shift+arrows move across them.',
82
84
  ' Bags tab: Enter uses, equips, opens, unwraps or learns; m lists every verb for the stack. s sells to the open',
83
85
  ' vendor, j sells junk, b opens the bank, d destroys, S sorts, / splits a stack, [ ] move it, A uses the whole stack.',
84
86
  ' D lists every green, blue or epic the group can disenchant; L lists every book someone can learn. G runs it.',
85
87
  ' :dev item|quest|recipe|prof skips the rules (testing). New character offers a random world bot.',
86
- ' :settings sets auto-loot, the upgrade helper, auto-disenchant, the junk rules and which nodes get gathered.',
88
+ ' Settings tab (or :settings) sets auto-loot, the upgrade helper, auto-disenchant, the junk rules and which nodes get gathered.',
87
89
  ' Spells tab: b edits your action bars, m your macros. :macro <name> runs one; a macro on bar 1 fires by its key in a fight.',
88
90
  ' Loot: with :autoloot off a corpse opens the loot window. Enter takes a row, a takes all, Esc leaves the rest.',
89
91
  '',
90
92
  'In a fight',
91
93
  ' 1-9 press an ability a toggle auto-play space pause + - speed l log Esc run away',
92
94
  ' t gives a companion an order: attack a target, cast one ability, or hold ground (the fight waits).',
93
- ' c cancels one of your buffs (also from the Character tab, out of a fight).',
95
+ ' c cancels one of your buffs (also from s on the Character tab, out of a fight).',
94
96
  ' r (in a fight you started with Enter) fights the same creature again after each win, until r again or Esc.',
95
97
  ' The ability bar runs along the bottom of the stage with its cooldowns; a takes the keys off the rotation.',
96
98
  '',
@@ -239,6 +241,22 @@ export class App {
239
241
  this.onQuit?.();
240
242
  }
241
243
 
244
+ /** Save, let go of the character and go back to the character picker. Not in the middle of a fight or a run. */
245
+ async logout() {
246
+ if (!this.g) return;
247
+ if (this.fight || this.auto) { this.flashMsg('Finish the fight or stop the run first.'); this.render(); return; }
248
+ for (const t of this.timers) this.term.clearInterval(t);
249
+ this.timers = [];
250
+ await this.persist();
251
+ if (this.slot != null) this.repo.unlock?.(this.slot);
252
+ this.say(dim(`Saved ${this.g.state.char.name}.`));
253
+ this.g = null; this.slot = null; this.cmds = null; this.vendor = null;
254
+ this.tabs = []; this.tab = 0; this.stack = [];
255
+ this.meter = null; this.lootTally = null; this.lastActivity = null;
256
+ await this.toPicker?.();
257
+ this.render();
258
+ }
259
+
242
260
  // ---- small helpers the views lean on -------------------------------------------------------------
243
261
 
244
262
  get ch() { return this.g.state.char; }
@@ -291,7 +309,7 @@ export class App {
291
309
  pick(title, options, onPick, { hint, keepOpen } = {}) {
292
310
  const view = {
293
311
  id: 'pick', title, hint: hint ?? 'Enter: choose Esc: back',
294
- rows: () => options.map((o) => ({ text: o.label, data: o.value, detail: o.detail ? () => (typeof o.detail === 'function' ? o.detail() : o.detail) : undefined, act: () => { if (!keepOpen) this.pop(); onPick(o.value, o); } })),
312
+ rows: () => options.map((o) => ({ text: o.label, icon: o.icon, sub: o.sub, data: o.value, detail: o.detail ? () => (typeof o.detail === 'function' ? o.detail() : o.detail) : undefined, act: () => { if (!keepOpen) this.pop(); onPick(o.value, o); } })),
295
313
  noDetail: !options.some((o) => o.detail),
296
314
  };
297
315
  this.push(view);
@@ -502,6 +520,7 @@ export class App {
502
520
  const [, cmd = '', args = ''] = line.match(/^(\S+)\s*(.*)$/) ?? [];
503
521
  const c = cmd.toLowerCase();
504
522
  if (c === 'quit' || c === 'exit' || c === 'q') { await this.quit(); return; }
523
+ if (c === 'logout' || c === 'characters') { await this.logout(); return; }
505
524
  if (c === 'auto') { const m = args.match(/^(\d+)?\s*(?:farm\s+(.+))?$/i); startAutopilot(this, m?.[1] ? Number(m[1]) : Infinity, m?.[2]); return; }
506
525
  if (c === 'art') {
507
526
  const missing = missingHdBytes();
@@ -610,7 +629,7 @@ export class App {
610
629
  const dead = this.isDead() ? red(bold(' DEAD')) : ch.mountedItem != null ? dim(' mounted') : '';
611
630
  const title = ` ${bold(ch.name)} ${dim(`L${ch.level} ${raceName(ch.race)} ${className(ch.cls)}`)}${dead}`;
612
631
  const road = journeyHeader(this);
613
- const right = `${road ? yellow(road) : cyan(z?.name ?? ch.zoneId)} ${yellow(moneyText(ch.gold) || '0c')} `;
632
+ const right = `${road ? yellow(road) : cyan(z?.name ?? ch.zoneId)} ${coinText(ch.gold)} `;
614
633
  const l0 = fit(title, L.W - vis(right)) + right;
615
634
  // In a fight the bars follow the live session, so the header is the player's frame and no panel needs another.
616
635
  const live = this.fight?.session?.playerView?.() ?? null;
@@ -690,7 +709,11 @@ export class App {
690
709
  if (showDetail) {
691
710
  const d = cursorRow?.detail?.(this, cursorRow) ?? view.detail?.(this, cursorRow) ?? [];
692
711
  detailLines = wrapIconLines(d, L.detailW - 2);
693
- if (detailLines.length > L.mainH) detailLines = detailLines.slice(0, L.mainH - 1).concat(dim('…'));
712
+ // A view's foot (the Character tab's stats) sits at the bottom of the pane when the tooltip keeps room above it.
713
+ const foot = view.detailFoot ? wrapIconLines(view.detailFoot(this, L.detailW - 2), L.detailW - 2) : [];
714
+ const room = foot.length && L.mainH - foot.length >= 16 ? L.mainH - foot.length - 1 : L.mainH;
715
+ if (detailLines.length > room) detailLines = detailLines.slice(0, room - 1).concat(dim('…'));
716
+ if (room < L.mainH) detailLines = [...detailLines, ...Array(L.mainH - foot.length - detailLines.length).fill(''), ...foot];
694
717
  }
695
718
  for (let i = 0; i < L.mainH; i++) {
696
719
  const left = fit(listLines[i] ?? '', listW);
@@ -766,6 +789,7 @@ export async function runTui({ term = new RealTerm(), argv = [] } = {}) {
766
789
  app.render();
767
790
  };
768
791
  const reopenPicker = () => savePicker(app, { onPlay: (save) => { void begin(save); }, onNew: createFlow });
792
+ app.toPicker = reopenPicker;
769
793
  const createFlow = () => characterCreation(app, (state) => {
770
794
  app.stack = [];
771
795
  app.startGame(state, `${state.char.name.toLowerCase()}-${Date.now().toString(36)}`, Date.now(), true);
@@ -2,12 +2,12 @@
2
2
  // you browse, the run panel between fights (now, next, quests, this run's numbers, what stops it), the Run
3
3
  // pane at the bottom (one row per planner step) and the end card. The run itself is the controller in
4
4
  // tuiFight.mjs (startAutopilot); the planner is repl.mjs createAutopilot. Everything here only reads.
5
- import { bold, dim, cyan, yellow, green, red, magenta, inverse, fit, vis, cut, bar, strip, pct, secondsText, wrapLines } from './text.mjs';
5
+ import { bold, dim, cyan, yellow, green, red, magenta, inverse, fit, vis, cut, bar, strip, pct, secondsText, wrapLines, coinText } from './text.mjs';
6
6
  import { idlePlayerFrame, startAutopilot, fightArt, flankArt } from './tuiFight.mjs';
7
7
  import { objectiveLines, questDialog } from './tuiViews.mjs';
8
8
  import { autopilotSettingsDialog } from './tuiAutopilotSettings.mjs';
9
9
  import { squadModeOf } from './squad.mjs';
10
- import { moneyText } from '../domain/economy/money.ts';
10
+ import { iconsOn, iconArt, iconBlank } from './iconArt.mjs';
11
11
  import { maxHealth, maxPower } from '../domain/character/character.ts';
12
12
 
13
13
  const H = (text) => ({ text, header: true, plain: true });
@@ -15,7 +15,7 @@ const H = (text) => ({ text, header: true, plain: true });
15
15
  const wrapRows = (lead, text, W) => wrapLines(text, Math.max(20, W - 13)).map((l, i) => H(`${i ? ' '.repeat(11) : lead}${l}`));
16
16
  const num = (n) => Math.round(n).toLocaleString('en-US');
17
17
  const clock = (ms) => { const s = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
18
- const signedMoney = (copper) => (copper < 0 ? `-${moneyText(-copper)}` : `+${moneyText(copper) || '0c'}`);
18
+ const signedMoney = (copper) => (copper < 0 ? `-${coinText(-copper)}` : `+${coinText(copper)}`);
19
19
 
20
20
  /** "questing in Redridge Mountains", "farming Redridge Mystic in …", "doing A Baying of Gnolls". */
21
21
  export function runWhat(app, ctl) {
@@ -149,14 +149,23 @@ export function bagsPaneLines(app, L) {
149
149
  const items = rows.map((r, i) => i).filter((i) => !rows[i].header);
150
150
  if (!items.length) return [` ${bold('Bags')}`, dim(' Nothing in your bags.')];
151
151
  if (!items.includes(st.cursor)) st.cursor = items.find((i) => i >= st.cursor) ?? items[items.length - 1];
152
- const room = L.logH - 1;
152
+ // With icons a row is two lines like the Bags tab's list: the icon down the left, the name beside the top
153
+ // and the row's kind and price beside the bottom.
154
+ const icons = iconsOn() && L.W >= 40;
155
+ const room = Math.floor((L.logH - 1) / (icons ? 2 : 1));
153
156
  const at = items.indexOf(st.cursor);
154
157
  const start = Math.max(0, Math.min(at - Math.floor(room / 2), items.length - room));
155
158
  const lines = [` ${bold('Bags')} ${dim('Up Down: pick Enter: use, equip, open the run plays on')}`];
156
159
  for (const i of items.slice(start, start + room)) {
157
- const r = rows[i];
158
- const text = fit(i === st.cursor ? `▶ ${r.text}` : ` ${r.text}`, L.W - 1);
159
- lines.push(i === st.cursor ? inverse(text) : ` ${text}`);
160
+ const r = rows[i], sel = i === st.cursor;
161
+ if (icons) {
162
+ const art = iconArt(r.icon, 4) ?? iconBlank(4);
163
+ const t1 = fit(` ${r.text}`, L.W - 6), t2 = fit(` ${r.sub ?? ''}`, L.W - 6);
164
+ lines.push(`${sel ? '▶' : ' '}${art[0]}${sel ? inverse(t1) : t1}`, ` ${art[1]}${sel ? inverse(t2) : t2}`);
165
+ continue;
166
+ }
167
+ const text = fit(sel ? `▶ ${r.text}` : ` ${r.text}`, L.W - 1);
168
+ lines.push(sel ? inverse(text) : ` ${text}`);
160
169
  }
161
170
  return lines;
162
171
  }
@@ -229,7 +238,7 @@ function runLine(app, ctl) {
229
238
  // ---- the Run pane ----------------------------------------------------------------------------------
230
239
 
231
240
  const GLYPH = {
232
- fight: red('⚔'), wait: dim('~'), consume: dim('~'), cannibalize: dim('~'), turnin: green('✓'), accept: green('+'), travel: cyan('→'),
241
+ fight: red('⚔'), buff: cyan('↑'), wait: dim('~'), consume: dim('~'), cannibalize: dim('~'), turnin: green('✓'), accept: green('+'), travel: cyan('→'),
233
242
  gather: yellow('⚒'), skin: yellow('⚒'), buy: yellow('$'), equip: green('▲'), skip: yellow('✗'), dead: red('☠'), stop: yellow('■'),
234
243
  };
235
244