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.
Files changed (55) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +43 -10
  3. package/bin/azsh.mjs +15 -1
  4. package/package.json +7 -3
  5. package/src/cli/hdArt.mjs +1 -1
  6. package/src/cli/iconArt.mjs +11 -1
  7. package/src/cli/modelArt.mjs +7 -4
  8. package/src/cli/oocPrep.mjs +32 -0
  9. package/src/cli/repl.mjs +16 -0
  10. package/src/cli/sixel.mjs +26 -14
  11. package/src/cli/text.mjs +16 -0
  12. package/src/cli/tui.mjs +40 -13
  13. package/src/cli/tuiAutopilot.mjs +17 -8
  14. package/src/cli/tuiBags.mjs +37 -4
  15. package/src/cli/tuiBulk.mjs +83 -4
  16. package/src/cli/tuiCheck.mjs +328 -19
  17. package/src/cli/tuiCity.mjs +31 -31
  18. package/src/cli/tuiCompanionKit.mjs +26 -8
  19. package/src/cli/tuiCompanions.mjs +189 -29
  20. package/src/cli/tuiCreate.mjs +7 -0
  21. package/src/cli/tuiDungeon.mjs +113 -33
  22. package/src/cli/tuiFight.mjs +63 -21
  23. package/src/cli/tuiFightPanes.mjs +15 -6
  24. package/src/cli/tuiFloor.mjs +20 -8
  25. package/src/cli/tuiGear.mjs +9 -10
  26. package/src/cli/tuiLoot.mjs +5 -6
  27. package/src/cli/tuiMap.mjs +214 -0
  28. package/src/cli/tuiPaperdoll.mjs +6 -5
  29. package/src/cli/tuiPartyStage.mjs +36 -7
  30. package/src/cli/tuiPet.mjs +16 -8
  31. package/src/cli/tuiProfessions.mjs +23 -23
  32. package/src/cli/tuiSaves.mjs +14 -0
  33. package/src/cli/tuiSearch.mjs +4 -5
  34. package/src/cli/tuiSettings.mjs +3 -4
  35. package/src/cli/tuiStage.mjs +72 -16
  36. package/src/cli/tuiTalents.mjs +3 -4
  37. package/src/cli/tuiViews.mjs +374 -85
  38. package/src/cli/tuiWiki.mjs +2 -3
  39. package/src/cli/tuiWorld.mjs +88 -5
  40. package/src/data/gear-art.bin.br +0 -0
  41. package/src/data/hd-art.json +36 -36
  42. package/src/data/model-art-worn.bin.br +0 -0
  43. package/src/data/model-art.bin.gz +0 -0
  44. package/src/data/tables/creatures.json.gz +0 -0
  45. package/src/data/world-map-sixel.bin.br +0 -0
  46. package/src/data/world-map.bin.gz +0 -0
  47. package/src/domain/character/character.ts +6 -0
  48. package/src/domain/pet/pet.ts +31 -0
  49. package/src/engine/combat/pickAction.ts +3494 -3487
  50. package/src/engine/combat/sessionChase.ts +12 -1
  51. package/src/engine/game/loadReconcile.ts +21 -1
  52. package/src/engine/game/pets.ts +33 -1
  53. package/src/engine/game/professions.ts +4 -0
  54. package/src/engine/game/squadQuestShare.ts +13 -1
  55. package/src/engine/game/types.ts +4 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ What changed in each release of `azeroth-shell` on npm. Update with `npm install -g azeroth-shell@latest`.
4
+
5
+ ## Unreleased
6
+
7
+ ## 0.4.0 — 2026-11-03
8
+
9
+ - Bags: `X` destroys several stacks at once, and `Q` destroys every quest item left over from quests you handed in.
10
+ - Dungeons: a 5-man you leave is held for two hours (it was 30 minutes). On the World tab, Enter on a run you
11
+ left half done takes you straight back in. The tab also lists the zone's dungeons while you are inside one,
12
+ and a cleared run says so. Picking another dungeon from inside one leaves the current run, which is held.
13
+ - Companions get their own copy of a quest item that comes from an object. If their copy never finished, they
14
+ drop it when you hand the quest in, so it no longer fills their log. Their quest list offers to drop old ones.
15
+ - Art for what a fight turns into or brings in: boss morphs, totems, wards and adds were invisible before.
16
+ - The title line reads "Azeroth Shell".
17
+
18
+ ## 0.3.0 — 2026-11-03
19
+
20
+ - HD gear art at 256 px with a palette per body. Shapeshift forms and costumes draw sharp under sixel.
21
+ - The Travel tab has a world map, in half-block and sixel.
22
+ - Tame and quest-item uses on World rows; dungeon entry locks and extra entrances; Hearthstone and ring/trinket
23
+ picks in Bags. The Character tab gets a stats page, and buffs are cast before a pull.
24
+ - Hunter pets untrain at the trainer's prices and keep their innate abilities.
25
+ - A foe still targeting you keeps the fight going.
26
+ - `azsh --art` downloads the full-size art up front.
27
+
28
+ ## 0.2.0 — 2026-11-02
29
+
30
+ - Art: creatures, icons and your geared character are drawn as half-block sprites, or as real pixels in a
31
+ terminal that supports sixel.
32
+ - A fight stage, the Gear tab as a character sheet, talent trees, and a one-page character creation screen.
33
+ - Party fights on the stage, each body at its real distance. Casters no longer stall on immune foes.
34
+ - The autopilot on screen: pre-flight card, run strip, run panel, Run/Quests/Bags panes, end card, and the
35
+ rotation condition editor.
36
+ - Auction house browsing by category; world search covers the zone you stand in.
37
+ - Warsong Gulch: strip map, your own seat, side calls, and matches that actually end.
38
+ - Faster start: the engine is cached under `~/.azsh/cache`, with a loading line while it imports.
39
+
40
+ ## 0.1.2 — 2026-11-01
41
+
42
+ - A character open in another terminal cannot be opened a second time.
43
+ - The Travel tab stays open after you depart.
44
+ - A screen refresh setting: game loops repaint at most once per refresh.
45
+
46
+ ## 0.1.1 — 2026-11-01
47
+
48
+ - Vendor window: `d` destroys a stack from your bags or a companion's.
49
+ - Terminal: Ctrl-L repaints, a full repaint on screen change, and a notice when the window is too small.
50
+
51
+ ## 0.1.0 — 2026-11-01
52
+
53
+ - First release on npm.
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.4.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,7 +30,10 @@
30
30
  "src/",
31
31
  "!src/data/*-hd.bin.br",
32
32
  "!src/data/gear-art-hd/",
33
- "docs/"
33
+ "!src/data/*.only.bin.br",
34
+ "!src/data/gear-art-hd.only/",
35
+ "docs/",
36
+ "CHANGELOG.md"
34
37
  ],
35
38
  "engines": {
36
39
  "node": ">=22.18"
@@ -41,9 +44,10 @@
41
44
  "check": "node --import ./src/cli/register.mjs src/cli/bootCheck.mjs",
42
45
  "data:pack": "node scripts/pack-data.mjs",
43
46
  "data:unpack": "node scripts/pack-data.mjs --unpack",
44
- "art:bake": "node scripts/bake-model-art.mjs",
47
+ "art:bake": "node scripts/bake-model-art.mjs && node scripts/bake-model-art.mjs --worn",
45
48
  "art:gear": "python scripts/bake-gear-art.py",
46
49
  "art:icons": "node scripts/bake-icon-art.mjs",
50
+ "art:map": "node scripts/bake-world-map.mjs && node scripts/bake-world-map.mjs --sixel",
47
51
  "art:hd": "node scripts/bake-model-art.mjs --hd && node scripts/bake-icon-art.mjs --hd && python scripts/bake-gear-art.py --hd",
48
52
  "art:publish": "node scripts/publish-hd-art.mjs",
49
53
  "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) {