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
@@ -2,11 +2,10 @@
2
2
  // (items, spells, quests, NPCs, talents, recipes, zones, dungeons, objects, factions); Tab narrows to one kind.
3
3
  // A page lists what links to it (where an item drops, who sells it, which quest rewards it, who teaches a spell)
4
4
  // as rows: Enter follows a link, Esc goes back. Search and sources are phonecraft's own pure indexes.
5
- import { bold, dim, yellow, green, red, cyan, qualityPaint, wrapLines } from './text.mjs';
5
+ import { bold, dim, yellow, green, red, cyan, qualityPaint, wrapLines, coinText } from './text.mjs';
6
6
  import { itemLines, spellLines, creatureLines, rewardLines } from './tuiViews.mjs';
7
7
  import { wikiIndex, wikiTokens, wikiSearch, WIKI_KINDS, WIKI_KIND_LABEL, WIKI_KIND_ONE } from './wikiIndex.ts';
8
8
  import { wikiSourceIndexSync } from './wikiSources.ts';
9
- import { moneyText } from '../domain/economy/money.ts';
10
9
  import { className } from '../domain/core/ids.ts';
11
10
 
12
11
  const H = (text) => ({ text, header: true });
@@ -135,7 +134,7 @@ function pageLinks(app, kind, id) {
135
134
  section('Reputation', (q.rewRep ?? []).filter((r) => r.value).map((r) => link(app, 'faction', r.faction, `${ds.faction(r.faction)?.name ?? r.faction} ${r.value > 0 ? '+' : ''}${r.value}`)));
136
135
  const chain = [q.prevQuest && ['Comes after', q.prevQuest], q.nextQuest && ['Leads to', q.nextQuest]].filter(Boolean);
137
136
  for (const [label, e] of chain) section(label, [link(app, 'quest', e, ds.quest(e)?.title ?? String(e))]);
138
- rows.push(H(''), H(dim(`From ${q.starterName || '?'}, to ${q.enderName || '?'}${q.rewMoney ? `, ${moneyText(q.rewMoney)}` : ''}`)));
137
+ rows.push(H(''), H(dim(`From ${q.starterName || '?'}, to ${q.enderName || '?'}${q.rewMoney ? `, ${coinText(q.rewMoney)}` : ''}`)));
139
138
  }
140
139
  } else if (kind === 'recipe') {
141
140
  const r = ds.recipe(id);
@@ -1,14 +1,20 @@
1
1
  // World-tab extras that are not a creature fight, a quest or a vendor: pick pocket for rogues (P on a creature)
2
2
  // real-time journeys, riding (instructors, mounts), and the zone's objects (chests, books, quest objects).
3
- import { bold, dim, yellow, red, green, cyan, bar, wrapLines } from './text.mjs';
3
+ import { bold, dim, yellow, red, green, cyan, bar, wrapLines, coinText } from './text.mjs';
4
4
  import { startFight } from './tuiFight.mjs';
5
5
  import { knownUsableSpells } from '../domain/character/characterSpells.ts';
6
6
  import { activeStealthAura } from '../domain/character/buildCombatant.ts';
7
+ import { inferRole } from '../engine/combat.ts';
8
+ import { countItem } from '../domain/items/inventory.ts';
9
+ import { requiresSourceUseCredit } from '../domain/quests/questlog.ts';
10
+ import { tamingRodByQuestId } from '../domain/quests/questTameRods.ts';
11
+ import { pendingCorpseUseItem } from '../domain/quests/questCorpseUse.ts';
12
+ import { pendingScriptItemUse } from '../domain/quests/questScriptItemUse.ts';
7
13
  import { pickpocketLootKey, canRestockPickpocket } from '../domain/professions/pickpocket.ts';
8
- import { moneyText } from '../domain/economy/money.ts';
9
14
  import { vendorPriceMultiplierFor, trainerSpellCost } from '../engine/game/vendorOffers.ts';
10
15
  import { questDialog, npcLines } from './tuiViews.mjs';
11
16
  import { mailDialog } from './tuiCity.mjs';
17
+ import { withWorldMap } from './tuiMap.mjs';
12
18
  import { autoLootOn, openLoot, takeAll } from './tuiLoot.mjs';
13
19
  import { zoneGameObjects, zoneGoListedForPlayer, makeGoPlayerVisibility, questReferencedGoEntries, questTargets } from '../world/world.ts';
14
20
  import { goQuestGiverOffer, goDialogMarkerFromOffer, questStatusFromLog } from '../domain/quests/questlog.ts';
@@ -54,6 +60,82 @@ export function pickPocketTag(app, creature) {
54
60
  return st === 'ready' ? dim(' P: pick pocket') : st === 'empty' ? dim(' pockets empty') : '';
55
61
  }
56
62
 
63
+ // ---- taming ----------------------------------------------------------------------------------------------
64
+ // The facade's tamePet is ungated, so the row gates it as phonecraft's worldView does: a hunter who has learned
65
+ // Tame Beast (the pet quest's reward) and has no pet out. While a Taming the Beast quest is open, its rod takes
66
+ // the place of the tame on the beast it names (useQuestItemOnTarget credits it). Ref worldView.ts:155.
67
+
68
+ const HUNTER = 3;
69
+
70
+ /** The Tame Beast rank you know, or undefined. */
71
+ export function tameSpell(app) {
72
+ if (app.ch.cls !== HUNTER) return undefined;
73
+ return knownUsableSpells(app.ch, app.g.state.spellbook, app.ds).find((s) => (s.role ?? inferRole(s)) === 'pet_tame');
74
+ }
75
+
76
+ /** A quest item to use on a creature: the open Taming the Beast quest's rod (only on its beast), or a use-to-credit source item (on a tameable beast). */
77
+ export function questUseItem(app) {
78
+ const inv = app.g.state.inventory;
79
+ for (const qs of app.g.state.questlog.quests) {
80
+ if (qs.status === 'turnedIn' || qs.status === 'complete') continue;
81
+ const rod = tamingRodByQuestId(qs.entry);
82
+ if (rod && countItem(inv, rod.item) >= 1) { const it = app.ds.item(rod.item); if (it) return { entry: it.entry, name: it.name, onlyCreature: rod.targetCreature }; }
83
+ const q = app.ds.quest(qs.entry);
84
+ if (!q || !requiresSourceUseCredit(q)) continue;
85
+ for (const t of q.sourceItemTargets ?? []) { if (countItem(inv, t.item) < 1) continue; const it = app.ds.item(t.item); if (it) return { entry: it.entry, name: it.name }; }
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ const useItemFits = (use, creature) => !!use && (use.onlyCreature != null ? creature.entry === use.onlyCreature : !!creature.tameable);
91
+
92
+ /** What t on this creature does: 'use' (a quest item), 'tame', or why not ('' when it is not for t at all). */
93
+ export function tameState(app, creature) {
94
+ if (useItemFits(questUseItem(app), creature)) return 'use';
95
+ if (app.ch.cls !== HUNTER || !creature.tameable) return '';
96
+ if (!tameSpell(app)) return 'unlearned';
97
+ return app.ch.pet ? 'pet' : 'tame';
98
+ }
99
+
100
+ export function tameTag(app, creature) {
101
+ const st = tameState(app, creature);
102
+ return st === 'use' ? dim(` t: use ${questUseItem(app).name}`) : st === 'tame' ? dim(' t: tame') : '';
103
+ }
104
+
105
+ /** t on a creature: use the quest item on it, or tame it. */
106
+ export function tame(app, creature) {
107
+ const st = tameState(app, creature);
108
+ if (st === 'use') {
109
+ const use = questUseItem(app);
110
+ app.result(app.g.useQuestItemOnTarget({ itemEntry: use.entry, targetCreatureEntry: creature.entry }), `You use ${use.name} on ${creature.name}.`);
111
+ return;
112
+ }
113
+ if (app.ch.cls !== HUNTER) { app.flashMsg('Only hunters tame beasts.'); return; }
114
+ if (!creature.tameable) { app.flashMsg(`${creature.name} cannot be tamed.`); return; }
115
+ if (st === 'unlearned') { app.flashMsg('Learn Tame Beast first: the hunter trainer sends you on the taming quests at level 10.'); return; }
116
+ if (st === 'pet') { app.flashMsg(`Dismiss or stable ${app.ch.pet.name} before taming another.`); return; }
117
+ app.result(app.g.tamePet(creature.entry), `You tame ${creature.name}.`);
118
+ }
119
+
120
+ // ---- right here: loot left behind, and quest items used on a corpse or a creature the list never offers -------
121
+ // Ref worldView.ts:551-582. A corpse item (Bramble, a molt) goes on your last kill; a script item (Lazy Peons'
122
+ // blackjack) goes on a civilian, who is never on the creature list.
123
+
124
+ export function hereRows(app) {
125
+ const g = app.g, ds = app.ds, inv = g.state.inventory, rows = [];
126
+ const left = g.pendingLoot();
127
+ if (left && app.top()?.id !== 'loot') rows.push({ text: `${yellow('Loot')} ${left.title ?? 'what you left'} ${dim('still lootable')}`, detail: () => [bold(left.title ?? 'Loot'), 'You closed it with things still in it.', '', dim('Enter: open it again')], act: () => openLoot(app) });
128
+ const active = g.state.questlog.quests.filter((qs) => qs.status === 'active').map((qs) => ({ qs, q: ds.quest(qs.entry) })).filter((x) => x.q);
129
+ const useRow = (item, target, q) => {
130
+ const it = ds.item(item), on = target ? ds.creature(target)?.name ?? `creature ${target}` : ds.creature(g.state.lastCorpse?.creatureEntry ?? 0)?.name ?? 'the corpse';
131
+ return { text: `${yellow('Use')} ${it?.name ?? `item ${item}`} on ${on} ${dim(q.title)}`, icon: it?.icon, detail: () => [bold(`Use ${it?.name ?? 'item'} on ${on}`), `For ${q.title}.`, '', dim('Enter: use it')], act: () => app.result(g.useQuestItemOnTarget({ itemEntry: item, ...(target ? { targetCreatureEntry: target } : {}) }), `You use ${it?.name ?? 'it'} on ${on}.`) };
132
+ };
133
+ const corpse = active.map(({ q }) => ({ q, item: pendingCorpseUseItem(ds, q, inv, g.state.lastCorpse) })).find((x) => x.item);
134
+ if (corpse) rows.push(useRow(corpse.item, 0, corpse.q));
135
+ for (const { q, qs } of active) { const u = pendingScriptItemUse(ds, q, qs, inv, app.ch.zoneId); if (u) { rows.push(useRow(u.item, u.targetCreatureEntry, q)); break; } }
136
+ return rows.length ? [{ text: '', header: true }, { text: 'Here', header: true }, ...rows] : [];
137
+ }
138
+
57
139
  // ---- journeys ------------------------------------------------------------------------------------------
58
140
  // With a real-time travel pace (Settings) a trip takes wall-clock time: the game loop calls journeyTick, the
59
141
  // header shows where you are headed, and the World and Travel tabs carry this card. Instant pace never gets here.
@@ -101,7 +183,8 @@ export function journeyRows(app) {
101
183
  { text: `${bar(st.progress.totalElapsedSeconds, total, 30, cyan)} ${mmss(st.progress.remainingSeconds)} left${app.travelPaused ? yellow(' paused') : ''}`, header: true },
102
184
  {
103
185
  text: `${leg ? `${leg.kind} ${app.ds.zone(leg.from)?.name ?? leg.from} → ${app.ds.zone(leg.to)?.name ?? leg.to}` : ''} ${dim(`${st.timeScale}x Enter: journey options`)}`,
104
- detail: () => [bold(`To ${dest}`), '', ...st.legs.map((l, i) => `${i < st.progress.legIndex ? green('✓') : i === st.progress.legIndex ? cyan('▶') : dim('·')} ${l.kind} ${app.ds.zone(l.from)?.name ?? l.from} → ${app.ds.zone(l.to)?.name ?? l.to} ${dim(mmss(l.seconds))}`), '', dim('Enter: speed, pause, arrive now, turn back')],
186
+ // The legs, then the world map with the traveller walking the current one (tuiMap.mjs).
187
+ detail: () => withWorldMap(app, [bold(`To ${dest}`), '', ...st.legs.map((l, i) => `${i < st.progress.legIndex ? green('✓') : i === st.progress.legIndex ? cyan('▶') : dim('·')} ${l.kind} ${app.ds.zone(l.from)?.name ?? l.from} → ${app.ds.zone(l.to)?.name ?? l.to} ${dim(mmss(l.seconds))}`), '', dim('Enter: speed, pause, arrive now, turn back')]),
105
188
  act: (a) => journeyMenu(a),
106
189
  },
107
190
  { text: '', header: true },
@@ -146,11 +229,11 @@ function ridingDialog(app, entry) {
146
229
  seen.add(sp.rank);
147
230
  const cost = trainerSpellCost(sp.cost, mod);
148
231
  const why = have >= sp.rank ? 'known' : sp.step > 1 && have < 75 ? 'needs Apprentice Riding' : app.ch.level < sp.reqLevel ? `level ${sp.reqLevel}` : app.ch.gold < cost ? 'not enough money' : '';
149
- opts.push({ label: `${why ? dim(`Riding ${sp.rank}`) : green(`Riding ${sp.rank}`)} ${dim(`level ${sp.reqLevel}, ${moneyText(cost)}`)}${why ? dim(` ${why}`) : ''}`, value: sp.rank });
232
+ opts.push({ label: `${why ? dim(`Riding ${sp.rank}`) : green(`Riding ${sp.rank}`)} ${dim(`level ${sp.reqLevel}, ${coinText(cost)}`)}${why ? dim(` ${why}`) : ''}`, value: sp.rank });
150
233
  }
151
234
  app.pick(`${app.ds.creature(entry)?.name ?? 'Riding instructor'}`, opts, (rank) => {
152
235
  const r = app.g.trainRiding(entry, rank);
153
- app.result(r, r.ok ? `You learn Riding (${r.rank}) for ${moneyText(r.spent)}.` : '');
236
+ app.result(r, r.ok ? `You learn Riding (${r.rank}) for ${coinText(r.spent)}.` : '');
154
237
  });
155
238
  }
156
239
 
Binary file
@@ -1,6 +1,6 @@
1
1
  {
2
- "tag": "art-1",
3
- "base": "https://github.com/LienSimen/azsh/releases/download/art-1/",
2
+ "tag": "art-2",
3
+ "base": "https://github.com/LienSimen/azsh/releases/download/art-2/",
4
4
  "files": [
5
5
  {
6
6
  "name": "model-art-hd.bin.br",
@@ -11,104 +11,104 @@
11
11
  {
12
12
  "name": "icon-art-hd.bin.br",
13
13
  "path": "icon-art-hd.bin.br",
14
- "size": 5183379,
15
- "sha256": "e92bb1e7fe58c1d5a13628cdd12beccf7254c9929dee216b773103452abb6bab"
14
+ "size": 5183918,
15
+ "sha256": "fc66716b4403d443f7a22da6bf956464220923c5faa80245070bd4ca1e556501"
16
16
  },
17
17
  {
18
18
  "name": "gear-art-hd-dwarf-female.bin.br",
19
19
  "path": "gear-art-hd/dwarf-female.bin.br",
20
- "size": 3065094,
21
- "sha256": "1c04ccce7f4c1ad24e2e437ca55a2081ad6eb2c589c2da8f05df36127d9729ca"
20
+ "size": 10001585,
21
+ "sha256": "a96907c4ecf9dfb1d9cf9d5614fb06a1e09998e70bbfd17e55c6e79a6ad8b4ad"
22
22
  },
23
23
  {
24
24
  "name": "gear-art-hd-dwarf-male.bin.br",
25
25
  "path": "gear-art-hd/dwarf-male.bin.br",
26
- "size": 4447262,
27
- "sha256": "7e1ff7f3a2f968034f14d53013d4e46c399314c9d3b9a2a242714a4d56b5a179"
26
+ "size": 14404864,
27
+ "sha256": "e096f75a13261756312d2472130d43e09f90cfeaa2327a4b4f8d9c96d6b52c1c"
28
28
  },
29
29
  {
30
30
  "name": "gear-art-hd-gnome-female.bin.br",
31
31
  "path": "gear-art-hd/gnome-female.bin.br",
32
- "size": 3285698,
33
- "sha256": "ddac7efdc36cc003ebb34b4811bfe49c86a6bad6c35fb7bb312fc2ad31054f11"
32
+ "size": 11048883,
33
+ "sha256": "cdbb81d645ecd01c48c25d06982e372e6f697ad29862c72dac9fcfb59fd4d16c"
34
34
  },
35
35
  {
36
36
  "name": "gear-art-hd-gnome-male.bin.br",
37
37
  "path": "gear-art-hd/gnome-male.bin.br",
38
- "size": 3748025,
39
- "sha256": "3ba3e446a654ab2e2a394f60ea7e7d38d66991d1252e318d4e5060bbdf5a4357"
38
+ "size": 11875475,
39
+ "sha256": "c4440b1475ecb73d290140619eb99196d32f3417e27d2e04384b5378cf49730d"
40
40
  },
41
41
  {
42
42
  "name": "gear-art-hd-human-female.bin.br",
43
43
  "path": "gear-art-hd/human-female.bin.br",
44
- "size": 2183524,
45
- "sha256": "036193451db30ed95cca8120158d5dc494c2210420f53835ba9bad3bcdd2887f"
44
+ "size": 7089810,
45
+ "sha256": "5c4d7d3d39a445bdbf7ff43d7e412a0a8679a841f2376eb1dc7aa54f16995bd2"
46
46
  },
47
47
  {
48
48
  "name": "gear-art-hd-human-male.bin.br",
49
49
  "path": "gear-art-hd/human-male.bin.br",
50
- "size": 2869717,
51
- "sha256": "97fbd6604dec28c8bb68e6e301b1acc2f628a52d831a7c6f3cac3f7eab6d6883"
50
+ "size": 9311191,
51
+ "sha256": "207abf704dd8efea247efc2a51a466715b89c55381ce5efb4915377a53dd45ee"
52
52
  },
53
53
  {
54
54
  "name": "gear-art-hd-nightelf-female.bin.br",
55
55
  "path": "gear-art-hd/nightelf-female.bin.br",
56
- "size": 2000472,
57
- "sha256": "321f9303ac0e48e71d09e1169a7f4f95e4c747012399da070b42d77e8a33920a"
56
+ "size": 6381920,
57
+ "sha256": "0b7bf823059d380bfb7f72a18863a17e44023aba87b219d2416a7e4b6583e055"
58
58
  },
59
59
  {
60
60
  "name": "gear-art-hd-nightelf-male.bin.br",
61
61
  "path": "gear-art-hd/nightelf-male.bin.br",
62
- "size": 2799028,
63
- "sha256": "e9bbcedefb814729f746ae24bc80031627babf6c9bf89fe67ae6fc0783e3f420"
62
+ "size": 9122533,
63
+ "sha256": "3711a86d14fa788ab5ecdf49dcd9eae30409a7a30bd59ed1849672ed52f01794"
64
64
  },
65
65
  {
66
66
  "name": "gear-art-hd-orc-female.bin.br",
67
67
  "path": "gear-art-hd/orc-female.bin.br",
68
- "size": 2639880,
69
- "sha256": "65675608a4ef58ab0d19b79f712ce94c994ddadfa72371ec0503e83f1a6dc89f"
68
+ "size": 8417526,
69
+ "sha256": "eb3ba4150f9146725ed530798e56d47128d67be79ae597db267c087fa9786688"
70
70
  },
71
71
  {
72
72
  "name": "gear-art-hd-orc-male.bin.br",
73
73
  "path": "gear-art-hd/orc-male.bin.br",
74
- "size": 4227013,
75
- "sha256": "09bf8130c09719ed0117b507f7baac3fa252bcfe189d2518cb4c4ad24042eff1"
74
+ "size": 13171006,
75
+ "sha256": "2c41e7fea6ab2db7e5b1c64990dc9d963240610d7bfe52e5ec496da9cd392f5b"
76
76
  },
77
77
  {
78
78
  "name": "gear-art-hd-tauren-female.bin.br",
79
79
  "path": "gear-art-hd/tauren-female.bin.br",
80
- "size": 2699924,
81
- "sha256": "94cd59aad17351f3d44c338943f61143355f092e5a7a7961055fd46980a8c334"
80
+ "size": 8849291,
81
+ "sha256": "fe7f37cd80abfcbab36e54cfd50311833457f6b98a1cea34e87ae031965bc35a"
82
82
  },
83
83
  {
84
84
  "name": "gear-art-hd-tauren-male.bin.br",
85
85
  "path": "gear-art-hd/tauren-male.bin.br",
86
- "size": 3810060,
87
- "sha256": "b0bd1bd0bdbc6d99846d9969d0136c7ca71fc70150527759cc3b424f1e5cbb33"
86
+ "size": 12346906,
87
+ "sha256": "b718b1abdf972a602636a5b3ccf15ce9eb6f9714b8d29e8c78682fa4e0962c05"
88
88
  },
89
89
  {
90
90
  "name": "gear-art-hd-troll-female.bin.br",
91
91
  "path": "gear-art-hd/troll-female.bin.br",
92
- "size": 2053657,
93
- "sha256": "af3a015cd97f3d37ee6401959a919254c768ea7305243251d1eaf9ffb2bcb8df"
92
+ "size": 6684064,
93
+ "sha256": "f1bef8c28ad0d267a14118b830309f952cc40acd95bb79954e3a720f5e2916ef"
94
94
  },
95
95
  {
96
96
  "name": "gear-art-hd-troll-male.bin.br",
97
97
  "path": "gear-art-hd/troll-male.bin.br",
98
- "size": 2820001,
99
- "sha256": "bef8de643988c386df9f2c125721692cfd8cda98670d6305f726c0e7f2413c3b"
98
+ "size": 8909278,
99
+ "sha256": "f58f08e62a6f23295b887a3b97657eb4f3e097e47e17aa0f3e94111500b69f26"
100
100
  },
101
101
  {
102
102
  "name": "gear-art-hd-undead-female.bin.br",
103
103
  "path": "gear-art-hd/undead-female.bin.br",
104
- "size": 2278891,
105
- "sha256": "dc2dab19f7eda1af4e69a242509edc58c62a3ba0188ec28302dfa32a37d441e5"
104
+ "size": 7500090,
105
+ "sha256": "d99136b4fa6edef4fdc676dbb5e988e4bf3b65a125ce1de8b84b889ae17d06a9"
106
106
  },
107
107
  {
108
108
  "name": "gear-art-hd-undead-male.bin.br",
109
109
  "path": "gear-art-hd/undead-male.bin.br",
110
- "size": 2812557,
111
- "sha256": "3a7d57acd0c824893cb346d7de782983a757b963791957311a6679b383c89545"
110
+ "size": 9072949,
111
+ "sha256": "d421f1719d60abcbe466775bc724710d5adc0049e89dbbc29fd0c64151351f2b"
112
112
  }
113
113
  ]
114
114
  }
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -195,6 +195,9 @@ export interface PetState {
195
195
  loyaltyPoints?: number;
196
196
  /** Unspent training points for pet-trainer abilities. */
197
197
  trainingPoints?: number;
198
+ // Pet untrain fee ladder: the last fee paid (copper) and when (epoch ms). Pet::m_resetTalentsCost/Time.
199
+ resetTalentsCost?: number;
200
+ resetTalentsTime?: number;
198
201
  /** Owner XP remaining before the next loyalty level-up is allowed (SetRequiredXpForNextLoyaltyLevel). */
199
202
  loyaltyXpRequired?: number;
200
203
  // Ability ids the player right-click-disabled on the pet bar, rank-agnostic by name chain.
@@ -206,6 +209,9 @@ export interface PetState {
206
209
  // Hunter pets: innate ability ids the OWNER can still learn from this pet, each cast rolling 10%
207
210
  // to enter the Beast Training repertoire. Mirrors Pet::m_teachspells.
208
211
  teachSpells?: number[];
212
+ // Hunter pets: the innate kit its tame charged training points for (InitPetCreateSpells). Absent on
213
+ // a pet tamed while the bake still handed every beast rank 1 of each family chain.
214
+ innateKit?: number[];
209
215
  // Pet bar command: 'follow' (default) or 'stay'. 'attack' is a one-shot order and is never stored. Cite:
210
216
  // Unit.h:819-824 CommandStates; PetActionBarFrame.lua PET_FOLLOW/PET_WAIT.
211
217
  command?: 'follow' | 'stay';
@@ -667,6 +667,36 @@ export function grantPetLevelTrainingPoints(
667
667
  return { ...pet, trainingPoints: (pet.trainingPoints ?? 0) + gain };
668
668
  }
669
669
 
670
+ const PET_UNTRAIN_DAY_MS = 24 * 60 * 60 * 1000;
671
+
672
+ // Next pet-untrain fee in copper, Pet::resetTalentsCost: 10s, then 50s, then 1g, then +1g to a 10g
673
+ // cap; a day since the last untrain drops it back to 10s. Cite: Pet.cpp resetTalentsCost.
674
+ export function petUntrainCost(pet: PetState, nowMs: number): number {
675
+ const prev = pet.resetTalentsCost ?? 0;
676
+ const days = Math.floor((nowMs - (pet.resetTalentsTime ?? 0)) / PET_UNTRAIN_DAY_MS);
677
+ if (prev < 1000 || days > 0) return 1000;
678
+ if (prev < 5000) return 5000;
679
+ if (prev < 10000) return 10000;
680
+ return Math.min(100000, prev + 10000);
681
+ }
682
+
683
+ // HandlePetUnlearnOpcode: every taught ability goes and TP resets to level × (loyalty − 1). The innate
684
+ // kit is the creature's here (petKnownSpells reads petDefaultSpells), so it stays and keeps its tame
685
+ // charge, where CMaNGOS unlearns it too. Cite: PetHandler.cpp HandlePetUnlearnOpcode; Pet.cpp SetTP.
686
+ export function untrainPetState(pet: PetState, cdef: CreatureDef, petLevel: number, ds: DataStore): PetState {
687
+ const innate = pet.innateKit ?? cdef.petDefaultSpells ?? [];
688
+ const loyalty = pet.loyalty ?? LOYALTY_REBELLIOUS;
689
+ const { autocastOff, ...rest } = pet;
690
+ const keptOff = (autocastOff ?? []).filter((id) => innate.some((k) => ds.spell(k)?.name === ds.spell(id)?.name));
691
+ return {
692
+ ...rest,
693
+ ...(keptOff.length ? { autocastOff: keptOff } : {}),
694
+ loyalty,
695
+ taughtSpells: [],
696
+ trainingPoints: petLevel * (loyalty - 1) - tpSpentOnKnown(innate, ds),
697
+ };
698
+ }
699
+
670
700
  // Abilities the active pet can cast: the innate kit plus taught ranks, intersected with the
671
701
  // creature's petSpells, highest LEVEL-ELIGIBLE rank per name. Generic-line passives are excluded.
672
702
  export function petKnownSpells(char: Character, cdef: CreatureDef, ds: DataStore): SpellDef[] {
@@ -863,6 +893,7 @@ export function newPet(creature: number, ds: DataStore, char: Character): PetSta
863
893
  // Training repertoire are taught per-pet for TP (game.teachPetSpell).
864
894
  taughtSpells: [],
865
895
  teachSpells,
896
+ innateKit: [...innate],
866
897
  };
867
898
  }
868
899