azeroth-shell 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +362 -85
- package/bin/azsh.mjs +35 -19
- package/package.json +12 -1
- package/src/autopilot/plan.ts +8 -11
- package/src/autopilot/view.ts +12 -11
- package/src/cli/combatLogFormat.ts +22 -0
- package/src/cli/gzLoader.mjs +47 -6
- package/src/cli/hdArt.mjs +81 -0
- package/src/cli/iconArt.mjs +145 -0
- package/src/cli/iconGrid.mjs +100 -0
- package/src/cli/modelArt.mjs +298 -0
- package/src/cli/oocPrep.mjs +32 -0
- package/src/cli/paperdoll.mjs +76 -0
- package/src/cli/perfCheck.mjs +56 -0
- package/src/cli/repl.mjs +48 -7
- package/src/cli/rotationText.mjs +6 -6
- package/src/cli/sixel.mjs +319 -0
- package/src/cli/term.mjs +106 -12
- package/src/cli/text.mjs +21 -3
- package/src/cli/tui.mjs +262 -102
- package/src/cli/tuiAutopilot.mjs +356 -0
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiBg.mjs +370 -41
- package/src/cli/tuiCheck.mjs +633 -37
- package/src/cli/tuiCity.mjs +146 -41
- package/src/cli/tuiCompanionKit.mjs +6 -6
- package/src/cli/tuiCompanions.mjs +211 -41
- package/src/cli/tuiCreate.mjs +230 -0
- package/src/cli/tuiDungeon.mjs +57 -21
- package/src/cli/tuiFight.mjs +467 -88
- package/src/cli/tuiFightPanes.mjs +42 -14
- package/src/cli/tuiFloor.mjs +21 -9
- package/src/cli/tuiGear.mjs +17 -14
- package/src/cli/tuiLoot.mjs +6 -6
- package/src/cli/tuiMap.mjs +214 -0
- package/src/cli/tuiPaperdoll.mjs +108 -0
- package/src/cli/tuiPartyStage.mjs +206 -0
- package/src/cli/tuiPet.mjs +16 -8
- package/src/cli/tuiProfessions.mjs +27 -25
- package/src/cli/tuiRotationCond.mjs +390 -0
- package/src/cli/tuiSaves.mjs +24 -1
- package/src/cli/tuiSearch.mjs +79 -27
- package/src/cli/tuiSettings.mjs +3 -4
- package/src/cli/tuiStage.mjs +360 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiTalents.mjs +3 -4
- package/src/cli/tuiViews.mjs +633 -172
- package/src/cli/tuiWiki.mjs +2 -3
- package/src/cli/tuiWorld.mjs +90 -7
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +114 -0
- package/src/data/icon-art.bin.gz +0 -0
- package/src/data/model-art-worn.bin.br +0 -0
- package/src/data/model-art.bin.gz +0 -0
- package/src/data/rotations/pve/paladin/retribution.json +2 -2
- package/src/data/tables/creatures.json.gz +0 -0
- package/src/data/world-map-sixel.bin.br +0 -0
- package/src/data/world-map.bin.gz +0 -0
- package/src/domain/bg/match.ts +85 -24
- package/src/domain/bg/strategies.ts +144 -29
- package/src/domain/bg/types.ts +2 -0
- package/src/domain/bg/wsg.ts +35 -20
- package/src/domain/character/character.ts +6 -0
- package/src/domain/pet/pet.ts +31 -0
- package/src/domain/quests/doQuest.ts +24 -6
- package/src/domain/rotation/build.ts +3031 -3031
- package/src/domain/rotation/classes/paladin.ts +16 -11
- package/src/domain/rotation/evaluate.ts +3 -0
- package/src/engine/combat/applySpell.ts +20 -9
- package/src/engine/combat/applySpellLate.ts +16 -12
- package/src/engine/combat/areaAura.ts +284 -283
- package/src/engine/combat/fighterFactory.ts +1 -1
- package/src/engine/combat/pickAction.ts +3494 -3449
- package/src/engine/combat/procsDamage.ts +31 -1
- package/src/engine/combat/sessionChase.ts +19 -2
- package/src/engine/combat/sessionMotionKit.ts +40 -2
- package/src/engine/game/loadReconcile.ts +21 -1
- package/src/engine/game/pets.ts +33 -1
- package/src/engine/game/types.ts +4 -0
- package/src/quests/quests.ts +60 -0
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
spellCritChance,
|
|
20
20
|
ccResistChance,
|
|
21
21
|
} from './formulas.ts';
|
|
22
|
-
import { fighterSpellCrit, fighterEffectiveArmor, applyBuffMod } from './fighterFactory.ts';
|
|
22
|
+
import { fighterSpellCrit, fighterEffectiveArmor, applyBuffMod, SPELL_DAMAGE_MOD_SCHOOL } from './fighterFactory.ts';
|
|
23
23
|
import {
|
|
24
24
|
powerOfType, maxPowerOfType, setPowerOfType, casterPowerTypeId, powerTypeName,
|
|
25
25
|
} from './powerPools.ts';
|
|
@@ -267,6 +267,35 @@ export function installProcsDamage(ctx: CombatContext, deps: installProcsDamageD
|
|
|
267
267
|
}
|
|
268
268
|
};
|
|
269
269
|
|
|
270
|
+
// A charged stat buff with DEAL spell flags (Chaos Fire 24389: "your next Fire spell") spends a
|
|
271
|
+
// charge when the bearer lands a damaging spell. Aura 13 has no proc handler, so the generic path
|
|
272
|
+
// drops the charge. A one-school damage mod only takes a spell of that school (24389 SchoolMask 0x04).
|
|
273
|
+
// Cite: UnitAuraProcHandler.cpp:46,:500-509; sql/archive/0.10/5019_mangos_spell_proc_event.sql.
|
|
274
|
+
const consumeChargedBuffsOnDealHit = (src: Fighter, fromSpell: SpellDef): void => {
|
|
275
|
+
const t = ctx.t;
|
|
276
|
+
const dealFlags = harmfulSpellDealFlags();
|
|
277
|
+
const mine = ctx.effectsOn(src.side);
|
|
278
|
+
for (let i = mine.length - 1; i >= 0; i--) {
|
|
279
|
+
const e = mine[i]!;
|
|
280
|
+
if (e.kind !== 'buff') continue;
|
|
281
|
+
if (e.charges == null || e.charges <= 0) continue;
|
|
282
|
+
if (e.expiresAt <= t) continue;
|
|
283
|
+
// An aura cannot spend its own charge. Cite: UnitAuraProcHandler.cpp:543.
|
|
284
|
+
if (e.spellId === fromSpell.id) continue;
|
|
285
|
+
const pf = (e.procFlags ?? 0) | 0;
|
|
286
|
+
if (pf === 0 || (pf & dealFlags) === 0) continue;
|
|
287
|
+
const modSchool = e.mod ? SPELL_DAMAGE_MOD_SCHOOL[e.mod as keyof typeof SPELL_DAMAGE_MOD_SCHOOL] : undefined;
|
|
288
|
+
if (modSchool && fromSpell.school !== modSchool) continue;
|
|
289
|
+
const { remaining, remove } = consumeProcCharge(e.charges);
|
|
290
|
+
if (remove) {
|
|
291
|
+
ctx.removeAurasDueToSpell?.(src, e.spellId);
|
|
292
|
+
} else {
|
|
293
|
+
e.charges = remaining;
|
|
294
|
+
ctx.touchEffect(e);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
270
299
|
// Proc support: match the flags and the per-minute rate, then fire the trigger spell's effects as a
|
|
271
300
|
// free, instant, no-cost side effect. The injected rng keeps it deterministic, and procs do not nest.
|
|
272
301
|
const triggerProcs = (
|
|
@@ -290,6 +319,7 @@ export function installProcsDamage(ctx: CombatContext, deps: installProcsDamageD
|
|
|
290
319
|
else syncCombustionChip(src);
|
|
291
320
|
}
|
|
292
321
|
}
|
|
322
|
+
if (flagsHint === 'spell' && fromSpell) consumeChargedBuffsOnDealHit(src, fromSpell);
|
|
293
323
|
const procs = src.procs || [];
|
|
294
324
|
if (!procs.length) return;
|
|
295
325
|
const procCtx = makeProcCtx(src, wasCrit, flagsHint, fromSpell);
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
isChaseInsideModel,
|
|
33
33
|
type ChaseClosenessState,
|
|
34
34
|
} from './chaseCloseness.ts';
|
|
35
|
-
import { rangedAutoMinYards, spellTargetTypeAllowed } from './predicates.ts';
|
|
35
|
+
import { focusImmunityBlocksSpell, rangedAutoMinYards, spellTargetTypeAllowed } from './predicates.ts';
|
|
36
36
|
import { CREATURE_FAMILY_ASSISTANCE_RADIUS } from '../../domain/creature/creatureAiConstants.ts';
|
|
37
37
|
import type { MotionOwner } from './motionClaim.ts';
|
|
38
38
|
import type { MotionGenerator } from './motionGenerator.ts';
|
|
@@ -47,6 +47,8 @@ import type { UnitNavPath, PathToOpts } from './sessionPathTo.ts';
|
|
|
47
47
|
const RANGED_MIN_CLEARANCE_YD = 2;
|
|
48
48
|
/** Stay this far inside an unpulled mob's detection so the step does not graze aggro. */
|
|
49
49
|
const UNPULLED_AGGRO_MARGIN_YD = 1.5;
|
|
50
|
+
/** Park this far inside a cast's rangeMax, past the walk order's 0.25 yd arrival slack. */
|
|
51
|
+
const CAST_REACH_SLACK_YD = 0.5;
|
|
50
52
|
// How far inside the bad arc a melee DPS must be before it starts a flank slide (~20 deg). A dead band, not
|
|
51
53
|
// a tolerance: the arc's own edge is what moves under it every tick.
|
|
52
54
|
const MELEE_SLOT_ENTRY_MARGIN_RAD = 0.35;
|
|
@@ -246,7 +248,18 @@ export function createChaseArm(deps: ChaseDeps): {
|
|
|
246
248
|
if (isEnemySide(f.side)) {
|
|
247
249
|
// The GRADE is the full contact ring while the PARK is half of it, which is the split the C++
|
|
248
250
|
// makes — and it retires the knife edge where one ULP of step error triggered a full-HP evade.
|
|
249
|
-
|
|
251
|
+
let reach = Math.max(pairContact, longestOffenseRangeYd(f) + EVADE_REACH_EPS_YD);
|
|
252
|
+
// With no ranged kit the park below stops at the short offense, so grade against it too. A
|
|
253
|
+
// Razormane Scout's Shoot 6660 infers instant_nuke: it parked at 24 yd, shot, and reset every 5 s.
|
|
254
|
+
// Scheduled spells only, so a kit row the mob never casts still lets it evade.
|
|
255
|
+
if (dist > reach) {
|
|
256
|
+
const castable = castableKitIds(f, ctx.spellOf);
|
|
257
|
+
for (const s of shortOffenseList(f)) {
|
|
258
|
+
if (castable && !castable.has(s.id | 0)) continue;
|
|
259
|
+
if (!spellTargetTypeAllowed(s, tgt.creatureType ?? 0) || focusImmunityBlocksSpell(s, tgt, 1)) continue;
|
|
260
|
+
reach = Math.max(reach, (s.rangeMax ?? 0) + EVADE_REACH_EPS_YD);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
250
263
|
const prev = unreachableFrom.get(f.side);
|
|
251
264
|
const victimMoved = !!prev && (
|
|
252
265
|
to.z != null && prev.victimZ != null
|
|
@@ -773,10 +786,14 @@ export function createChaseArm(deps: ChaseDeps): {
|
|
|
773
786
|
for (const s of shortOffenseList(f)) {
|
|
774
787
|
if (onCooldown(f, s) || !canAfford(f, s)) continue;
|
|
775
788
|
if (!spellTargetTypeAllowed(s, tgt.creatureType ?? 0)) continue;
|
|
789
|
+
if (focusImmunityBlocksSpell(s, tgt, 1)) continue;
|
|
776
790
|
const r = s.rangeMax ?? 0;
|
|
777
791
|
if (r > fireR) fireR = r;
|
|
778
792
|
}
|
|
779
793
|
if (fireR > 0) {
|
|
794
|
+
// Inside the reach, not on it: a walk order arrives within ARRIVE_YD (0.25) of its dest, and
|
|
795
|
+
// a shaman parked at 20.2 yd never got the 20 yd Flame Shock off.
|
|
796
|
+
fireR -= CAST_REACH_SLACK_YD;
|
|
780
797
|
stopAt = stopAt > 0 ? Math.min(stopAt, fireR) : fireR;
|
|
781
798
|
} else {
|
|
782
799
|
// Healer off-aggro may keep a stamped stand for heals; solo MH must not freeze.
|
|
@@ -10,6 +10,7 @@ import { effectiveSpeeds, type SpeedEffect } from '../../domain/nav/speed.ts';
|
|
|
10
10
|
import { MIN_AGGRO_DISTANCE } from '../../domain/creature/creatureAi.ts';
|
|
11
11
|
import { woundedSlowdownFactor } from '../../domain/creature/creatureAiConstants.ts';
|
|
12
12
|
import { roleOf } from './roles.ts';
|
|
13
|
+
import { focusImmunityBlocksSpell } from './predicates.ts';
|
|
13
14
|
import { triggeredIdsOf } from './triggeredCompanion.ts';
|
|
14
15
|
import type { CombatContext } from './context.ts';
|
|
15
16
|
import type { Fighter } from './types.ts';
|
|
@@ -132,8 +133,42 @@ export function createMotionKit(deps: MotionKitDeps): MotionKit {
|
|
|
132
133
|
return r;
|
|
133
134
|
};
|
|
134
135
|
|
|
135
|
-
|
|
136
|
-
|
|
136
|
+
// The focus when it can refuse a school, else null. Almost nothing can, so the kit walks below
|
|
137
|
+
// only run against an elemental or a bubble, and every other tick pays one mask read.
|
|
138
|
+
const schoolImmuneFoeOf = (f: Fighter): Fighter | null => {
|
|
139
|
+
const foe = ctx.foeOf?.(f);
|
|
140
|
+
if (!foe || foe === f) return null;
|
|
141
|
+
const masks = (foe.immunity?.schoolImmuneMask ?? 0) | (foe.immunity?.damageImmuneMask ?? 0)
|
|
142
|
+
| (foe.spec.schoolImmuneMask ?? 0);
|
|
143
|
+
return masks !== 0 ? foe : null;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// A focus that refuses the WHOLE offense kit (Vek'nilash to a frost mage) changes nothing: the
|
|
147
|
+
// caster holds where it stood, which is what a raid wants. Only a partial wall re-aims motion at
|
|
148
|
+
// what still lands. Cite: twinUppercut capability (casters at 30 yd take no Uppercut).
|
|
149
|
+
const immuneFoeWithAnOpening = (f: Fighter): Fighter | null => {
|
|
150
|
+
const foe = schoolImmuneFoeOf(f);
|
|
151
|
+
if (!foe) return null;
|
|
152
|
+
for (const s of rangedOffenseKit(f, (f.spec.weapon?.dmgMax ?? 0) > 0, rangedPlan(f)).list) {
|
|
153
|
+
if (!focusImmunityBlocksSpell(s, foe, 1)) return foe;
|
|
154
|
+
}
|
|
155
|
+
for (const s of shortOffenseList(f)) if (!focusImmunityBlocksSpell(s, foe, 1)) return foe;
|
|
156
|
+
return null;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Reach of what can actually land: a shaman parked at Lightning Bolt's 40 yd from a Rock Elemental
|
|
160
|
+
// never came inside Flame Shock's 20.
|
|
161
|
+
const longestOffenseRangeYd = (f: Fighter): number => {
|
|
162
|
+
const kit = rangedOffenseKit(f, (f.spec.weapon?.dmgMax ?? 0) > 0, rangedPlan(f));
|
|
163
|
+
const foe = immuneFoeWithAnOpening(f);
|
|
164
|
+
if (!foe) return kit.maxRangeYd;
|
|
165
|
+
let r = 0;
|
|
166
|
+
for (const s of kit.list) {
|
|
167
|
+
if (focusImmunityBlocksSpell(s, foe, 1)) continue;
|
|
168
|
+
if ((s.rangeMax ?? 0) > r) r = s.rangeMax ?? 0;
|
|
169
|
+
}
|
|
170
|
+
return r;
|
|
171
|
+
};
|
|
137
172
|
|
|
138
173
|
/** Judgement / Holy Shock — foe offense not in rangedOffenseKit (nuke/dot/shoot only). */
|
|
139
174
|
type ShortOffenseKit = { len: number; list: SpellDef[] };
|
|
@@ -159,9 +194,12 @@ export function createMotionKit(deps: MotionKitDeps): MotionKit {
|
|
|
159
194
|
const rangedAuto = rangedPlan(f);
|
|
160
195
|
// Only cooldown/cost are live here; which spells even COUNT as ranged offense is a bake
|
|
161
196
|
// constant, and re-deriving it walked the whole kit through roleOf per unit per tick.
|
|
197
|
+
// Immunity is live too: a Fireball the focus shrugs off held a fire mage at range forever.
|
|
198
|
+
const foe = immuneFoeWithAnOpening(f);
|
|
162
199
|
for (const s of rangedOffenseKit(f, hasRealMelee, rangedAuto).list) {
|
|
163
200
|
if (onCooldown(f, s)) continue;
|
|
164
201
|
if (!canAfford(f, s)) continue;
|
|
202
|
+
if (foe && focusImmunityBlocksSpell(s, foe, 1)) continue;
|
|
165
203
|
return true;
|
|
166
204
|
}
|
|
167
205
|
return false;
|
|
@@ -48,7 +48,7 @@ import { emptyInventory } from '../../domain/items/inventory.ts';
|
|
|
48
48
|
import { trimSoulShards } from '../../domain/bots/soulShardCap.ts';
|
|
49
49
|
import { companionLevel } from '../../domain/bots/squad.ts';
|
|
50
50
|
import { syncCompanionProfessionTools } from './companionTraining.ts';
|
|
51
|
-
import { petHasHappiness, reconcilePetPartyAuras } from '../../domain/pet/pet.ts';
|
|
51
|
+
import { petHasHappiness, reconcilePetPartyAuras, tpSpentOnKnown } from '../../domain/pet/pet.ts';
|
|
52
52
|
import { playerbotJunkItem } from '../../domain/bots/playerbotItemEligibility.ts';
|
|
53
53
|
import type { EquippedMap } from '../../domain/character/character.ts';
|
|
54
54
|
import { createAliveDeathFlow, isPlayerAlive } from '../../domain/run/death.ts';
|
|
@@ -324,6 +324,26 @@ export function applyLoadReconcile(ds: DataStore, state: GameState, opts?: impor
|
|
|
324
324
|
}
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
+
// A pet tamed before the bake dropped its rank-1 filler was charged training points for rank 1 of
|
|
328
|
+
// every family chain on top of its real kit, and may still queue those ranks to teach. Refund the
|
|
329
|
+
// difference once and keep the queue to the real kit. Cite: Pet.cpp:1955-1998 InitPetCreateSpells.
|
|
330
|
+
{
|
|
331
|
+
for (const p of [state.char.pet, state.char.storedPet, ...(state.char.stablePets ?? [])]) {
|
|
332
|
+
if (!p || p.innateKit) continue;
|
|
333
|
+
const cdef = ds.creature(p.creature);
|
|
334
|
+
if (!cdef || !petHasHappiness(cdef)) continue;
|
|
335
|
+
const kit = cdef.petDefaultSpells ?? [];
|
|
336
|
+
const filler = (cdef.petSpells ?? []).filter((id) => {
|
|
337
|
+
const s = ds.spell(id);
|
|
338
|
+
return !!s && !s.passive && (s.rank | 0) <= 1;
|
|
339
|
+
});
|
|
340
|
+
const refund = tpSpentOnKnown([...kit, ...filler], ds) - tpSpentOnKnown(kit, ds);
|
|
341
|
+
if (refund > 0 && p.trainingPoints != null) p.trainingPoints += refund;
|
|
342
|
+
if (p.teachSpells) p.teachSpells = p.teachSpells.filter((id) => kit.includes(id));
|
|
343
|
+
p.innateKit = [...kit];
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
327
347
|
// Action bars: normalize any loaded/legacy shape, and for a save that predates them
|
|
328
348
|
// (empty) seed bar 1 with the player's usable abilities so combat isn't blank.
|
|
329
349
|
state.actionBars = normalizeBars(state.actionBars);
|
package/src/engine/game/pets.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
newPet, petHasHappiness, petTaughtIds, petRepertoireHas,
|
|
6
|
-
feedPetHappinessGain, petDiet, isPetFood, tpForSpell, hasTpForSpell,
|
|
6
|
+
feedPetHappinessGain, petDiet, isPetFood, tpForSpell, hasTpForSpell, petUntrainCost, untrainPetState,
|
|
7
7
|
LOYALTY_REBELLIOUS, FEED_PET_DURATION_S,
|
|
8
8
|
autoFeedThreshold, AUTO_FEED_TARGET, HAPPINESS_MAX,
|
|
9
9
|
applyPetPartyAuras, stripPetPartyAuras, petPartyAuraSpellIds, reconcilePetPartyAuras,
|
|
@@ -156,6 +156,8 @@ export function createPetsModule(ctx: GameCtx, extras: PetsExtras): Pick<
|
|
|
156
156
|
| 'setPetAutoFeed'
|
|
157
157
|
| 'buyPetSpell'
|
|
158
158
|
| 'teachPetSpell'
|
|
159
|
+
| 'untrainPet'
|
|
160
|
+
| 'untrainPetCost'
|
|
159
161
|
| 'togglePetAutocast'
|
|
160
162
|
| 'setPetCommand'
|
|
161
163
|
| 'setPetReact'
|
|
@@ -319,6 +321,36 @@ export function createPetsModule(ctx: GameCtx, extras: PetsExtras): Pick<
|
|
|
319
321
|
return { ok: true };
|
|
320
322
|
},
|
|
321
323
|
|
|
324
|
+
untrainPetCost(nowMs) {
|
|
325
|
+
const pet = state.char.pet;
|
|
326
|
+
const cdef = pet ? ds.creature(pet.creature) : undefined;
|
|
327
|
+
if (!pet || !cdef || !petHasHappiness(cdef)) return null;
|
|
328
|
+
return petUntrainCost(pet, nowMs ?? Date.now());
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
untrainPet(trainerEntry, nowMs) {
|
|
332
|
+
const trainer = ds.trainer(trainerEntry);
|
|
333
|
+
if (!trainer || !(trainer.subname ?? '').toLowerCase().includes('pet trainer')) {
|
|
334
|
+
return { ok: false, reason: 'That is not a pet trainer' };
|
|
335
|
+
}
|
|
336
|
+
if (!canInteractWithCreature(ds, state.char.race, trainerEntry)) {
|
|
337
|
+
return { ok: false, reason: 'They refuse to deal with you.' };
|
|
338
|
+
}
|
|
339
|
+
const pet = state.char.pet;
|
|
340
|
+
if (!pet) return { ok: false, reason: 'You have no pet' };
|
|
341
|
+
if (pet.health <= 0) return { ok: false, reason: 'Your pet is dead' };
|
|
342
|
+
const cdef = ds.creature(pet.creature);
|
|
343
|
+
if (!cdef || !petHasHappiness(cdef)) return { ok: false, reason: 'Only a hunter pet can be untrained' };
|
|
344
|
+
if (!petTaughtIds(state.char, cdef).length) return { ok: false, reason: `${pet.name} has nothing to forget` };
|
|
345
|
+
const now = nowMs ?? Date.now();
|
|
346
|
+
const cost = petUntrainCost(pet, now);
|
|
347
|
+
if (state.char.gold < cost) return { ok: false, reason: 'Not enough money' };
|
|
348
|
+
state.char.gold -= cost;
|
|
349
|
+
state.char.pet = { ...untrainPetState(pet, cdef, state.char.level, ds), resetTalentsCost: cost, resetTalentsTime: now };
|
|
350
|
+
log(`${pet.name} forgets its training for ${moneyMarked(cost)} - ${state.char.pet.trainingPoints} training points to spend again.`);
|
|
351
|
+
return { ok: true, spent: cost };
|
|
352
|
+
},
|
|
353
|
+
|
|
322
354
|
togglePetAutocast(spellId) {
|
|
323
355
|
const pet = state.char.pet;
|
|
324
356
|
if (!pet) return { ok: false, reason: 'You have no pet' };
|
package/src/engine/game/types.ts
CHANGED
|
@@ -768,6 +768,10 @@ export interface Game {
|
|
|
768
768
|
// Beast Training (spell 5149): teach the CURRENT pet a rank from the repertoire, spending ITS training
|
|
769
769
|
// points (chain-aware). Cite: Pet.cpp GetTPForSpell/learnSpell.
|
|
770
770
|
teachPetSpell(spellId: number): ActionResult;
|
|
771
|
+
// Untrain the current hunter pet at a pet trainer: forget every taught ability, training points back
|
|
772
|
+
// to level × (loyalty − 1), for the escalating fee petUntrainCost reads. Cite: HandlePetUnlearnOpcode.
|
|
773
|
+
untrainPet(trainerEntry: number, nowMs?: number): ActionResult<{ spent: number }>;
|
|
774
|
+
untrainPetCost(nowMs?: number): number | null;
|
|
771
775
|
// Toggle autocast for a pet ability (right-click on the pet bar). Rank-agnostic by name chain; disabled
|
|
772
776
|
// abilities stay learned but leave the combat kit (buildPet).
|
|
773
777
|
togglePetAutocast(spellId: number): ActionResult<{ enabled: boolean }>;
|
package/src/quests/quests.ts
CHANGED
|
@@ -534,6 +534,66 @@ export function compareQuestDroppers(
|
|
|
534
534
|
return a.level - b.level;
|
|
535
535
|
}
|
|
536
536
|
|
|
537
|
+
/** A dropper counts as "as good" as the best one when its chance is at least this share of it. */
|
|
538
|
+
export const SIMILAR_DROPPER_RATIO = 0.75;
|
|
539
|
+
/** ...and its level is at most this far below the best one's (and never gray to the player). */
|
|
540
|
+
export const SIMILAR_DROPPER_LEVELS_BELOW = 3;
|
|
541
|
+
/** ...and at most this far above the player (a harder mob than the best is never the pool's gain). */
|
|
542
|
+
export const SIMILAR_DROPPER_LEVELS_OVER = 1;
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The entries worth pulling interchangeably with the top-ranked one: same elite class, a drop
|
|
546
|
+
* chance at least SIMILAR_DROPPER_RATIO of its chance, a level no more than SIMILAR_DROPPER_LEVELS_OVER
|
|
547
|
+
* above the player (or the best, when the whole set is above the player), no more than
|
|
548
|
+
* SIMILAR_DROPPER_LEVELS_BELOW under the best, and not gray unless the best is. Kill objectives
|
|
549
|
+
* carry chance 0, so every credited entry in the level band qualifies. `ranked` is ordered by
|
|
550
|
+
* compareQuestDroppers already, so ranked[0] is the best.
|
|
551
|
+
*/
|
|
552
|
+
export function similarQuestDroppers<T extends { level: number; chance: number; rank?: number }>(
|
|
553
|
+
ranked: readonly T[],
|
|
554
|
+
playerLevel: number,
|
|
555
|
+
): T[] {
|
|
556
|
+
const best = ranked[0];
|
|
557
|
+
if (!best) return [];
|
|
558
|
+
const bestElite = (best.rank ?? 0) > 0;
|
|
559
|
+
const haveLevel = playerLevel > 0;
|
|
560
|
+
const ceiling = Math.max(best.level, haveLevel ? playerLevel + SIMILAR_DROPPER_LEVELS_OVER : best.level);
|
|
561
|
+
const floor = best.level - SIMILAR_DROPPER_LEVELS_BELOW;
|
|
562
|
+
const bestGray = haveLevel && difficultyTier(best.level, playerLevel) === 'gray';
|
|
563
|
+
return ranked.filter((c) => {
|
|
564
|
+
if (((c.rank ?? 0) > 0) !== bestElite) return false;
|
|
565
|
+
if (c.chance < best.chance * SIMILAR_DROPPER_RATIO) return false;
|
|
566
|
+
if (c.level > ceiling || c.level < floor) return false;
|
|
567
|
+
return bestGray || !haveLevel || difficultyTier(c.level, playerLevel) !== 'gray';
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* One pull among the similar droppers, chosen by `salt` so the same state names the same mob and
|
|
573
|
+
* the next fight (state.fightCounter moved) can name another. Always picking the comparator's
|
|
574
|
+
* winner sent every Murloc quest at the Oracle and every collect quest at one template; a pool
|
|
575
|
+
* of near-equal mobs spreads the kills without touching the ranking the smokes pin.
|
|
576
|
+
*/
|
|
577
|
+
export function pickQuestDropper<T extends { level: number; chance: number; rank?: number }>(
|
|
578
|
+
ranked: readonly T[],
|
|
579
|
+
playerLevel: number,
|
|
580
|
+
salt: number,
|
|
581
|
+
): T | undefined {
|
|
582
|
+
const pool = similarQuestDroppers(ranked, playerLevel);
|
|
583
|
+
if (pool.length <= 1) return pool[0] ?? ranked[0];
|
|
584
|
+
// A short integer mix so consecutive salts do not walk the pool in order.
|
|
585
|
+
let h = (salt ^ 0x9e3779b9) >>> 0;
|
|
586
|
+
h = Math.imul(h ^ (h >>> 16), 0x85ebca6b) >>> 0;
|
|
587
|
+
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0;
|
|
588
|
+
h = (h ^ (h >>> 16)) >>> 0;
|
|
589
|
+
return pool[h % pool.length];
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** The salt pickQuestDropper takes for a save: the fight nonce under the save's seed. */
|
|
593
|
+
export function dropperPickSalt(state: { rngSeed: number; fightCounter: number }): number {
|
|
594
|
+
return ((state.rngSeed | 0) * 31 + (state.fightCounter | 0)) | 0;
|
|
595
|
+
}
|
|
596
|
+
|
|
537
597
|
// The best eligible loot chance for an item on this creature, 0 for none. An equal-chance group row
|
|
538
598
|
// scores as one so it beats a near-zero free row. Cite: domain/items/loot.ts.
|
|
539
599
|
export function lootChanceForItem(
|