Release Healer Man 0.1.36
This commit is contained in:
1 parent
40d9f346d8
commit
03a4e3f51c
22 files changed
+1389
-153
No files matched your search
@@ -1,3 +1,5 @@
|
||||
import type { PartyNavigationGraph } from "./partyNavigation";
|
||||
|
||||
export type CombatSpatialPosition = readonly [x: number, y: number, z: number];
|
||||
|
||||
export interface CombatSpatialActorSnapshot {
|
||||
@@ -8,6 +10,8 @@ export interface CombatSpatialActorSnapshot {
|
||||
}
|
||||
|
||||
export interface CombatSpatialProvider {
|
||||
/** The already-loaded, reviewed dungeon surface shared by actor navigation. */
|
||||
readonly navigationGraph?: () => PartyNavigationGraph | null;
|
||||
readonly actor: (actorId: string) => CombatSpatialActorSnapshot | null;
|
||||
readonly hasLineOfSight: (
|
||||
source: CombatSpatialPosition,
|
||||
@@ -45,6 +49,10 @@ export function combatHasLineOfSight(
|
||||
return provider?.hasLineOfSight(source, target) ?? true;
|
||||
}
|
||||
|
||||
export function combatNavigationGraph(): PartyNavigationGraph | null {
|
||||
return provider?.navigationGraph?.() ?? null;
|
||||
}
|
||||
|
||||
export function projectCombatGroundPoint(
|
||||
requested: CombatSpatialPosition,
|
||||
maximumDistance: number,
|
||||
|
||||
+81
-13
@@ -372,6 +372,7 @@ export interface MobCombatState {
|
||||
readonly lastDamageAt: number;
|
||||
readonly engaged: boolean;
|
||||
readonly combatPhase: "idle" | "chasing" | "attacking" | "returning";
|
||||
readonly navigationEvadeUntil?: number;
|
||||
readonly homePosition: CombatPosition | null;
|
||||
readonly nextAttackAt: number;
|
||||
readonly abilityReadyAt: Readonly<Record<string, number>>;
|
||||
@@ -723,6 +724,7 @@ export interface CombatState {
|
||||
cancelCast: (message?: string) => boolean;
|
||||
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
|
||||
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
|
||||
resetMobForNavigation: (id: string, atHome: boolean, now?: number) => boolean;
|
||||
tick: (deltaSeconds: number, now?: number) => void;
|
||||
resetEncounter: (options?: {
|
||||
readonly preserveDefeated?: boolean;
|
||||
@@ -2418,6 +2420,7 @@ function engageMobGroup(
|
||||
let changed = false;
|
||||
for (const [id, mob] of Object.entries(mobs)) {
|
||||
if (mob.dead || mob.despawned || mobGroupId(id) !== groupId) continue;
|
||||
if (mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue;
|
||||
const threatByActor = groupWasEngaged
|
||||
? mob.threatByActor
|
||||
: seedActorThreat(mob.threatByActor, sourceActorId);
|
||||
@@ -2459,7 +2462,7 @@ function nearestAggroActorId(
|
||||
let nearestDistanceSquared = aggroRange * aggroRange;
|
||||
if (state.health > 0) {
|
||||
const playerDistanceSquared = distanceSquared(mobPosition, state.playerPosition);
|
||||
if (playerDistanceSquared <= nearestDistanceSquared) {
|
||||
if (playerDistanceSquared <= nearestDistanceSquared && combatHasLineOfSight(mobPosition, state.playerPosition)) {
|
||||
nearestId = PLAYER_AGGRO_ID;
|
||||
nearestDistanceSquared = playerDistanceSquared;
|
||||
}
|
||||
@@ -2469,7 +2472,7 @@ function nearestAggroActorId(
|
||||
const position = getPartyRuntimePosition(member.id);
|
||||
if (!position) continue;
|
||||
const memberDistanceSquared = distanceSquared(mobPosition, position);
|
||||
if (memberDistanceSquared < nearestDistanceSquared) {
|
||||
if (memberDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) {
|
||||
nearestId = member.id;
|
||||
nearestDistanceSquared = memberDistanceSquared;
|
||||
}
|
||||
@@ -2479,7 +2482,7 @@ function nearestAggroActorId(
|
||||
const position = getPartyRuntimePosition(actor.id);
|
||||
if (!position) continue;
|
||||
const actorDistanceSquared = distanceSquared(mobPosition, position);
|
||||
if (actorDistanceSquared < nearestDistanceSquared) {
|
||||
if (actorDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, position)) {
|
||||
nearestId = actor.id;
|
||||
nearestDistanceSquared = actorDistanceSquared;
|
||||
}
|
||||
@@ -2487,7 +2490,7 @@ function nearestAggroActorId(
|
||||
for (const summon of state.summons) {
|
||||
if (summon.health <= 0) continue;
|
||||
const summonDistanceSquared = distanceSquared(mobPosition, summon.position);
|
||||
if (summonDistanceSquared < nearestDistanceSquared) {
|
||||
if (summonDistanceSquared < nearestDistanceSquared && combatHasLineOfSight(mobPosition, summon.position)) {
|
||||
nearestId = summon.id;
|
||||
nearestDistanceSquared = summonDistanceSquared;
|
||||
}
|
||||
@@ -2533,7 +2536,9 @@ function damageMobInWork(
|
||||
combatResult?: CombatResult,
|
||||
): DamageResult {
|
||||
const mob = work.mobs[id];
|
||||
if (!mob || mob.dead || amount <= 0) return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
||||
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
|
||||
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
||||
}
|
||||
const mitigatedBeforeAuras = combatResult?.finalDamage ?? damageAfterMobResistance(
|
||||
amount,
|
||||
mob,
|
||||
@@ -2678,7 +2683,7 @@ function resolveAndDamageMobInWork(
|
||||
rules: CombatResultRules = {},
|
||||
): DamageResult {
|
||||
const mob = work.mobs[id];
|
||||
if (!mob || mob.dead || amount <= 0) {
|
||||
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
|
||||
return { amount: 0, killed: false, xpAwarded: 0, levelsGained: 0 };
|
||||
}
|
||||
const sourceAuras = aurasForEntity(work.auras, source.actorId);
|
||||
@@ -5656,7 +5661,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|
||||
tauntMob: (id, source, durationMs, now = combatNow()) => {
|
||||
const target = get().mobs[id];
|
||||
if (!target || target.dead || durationMs <= 0) return false;
|
||||
if (!target || target.dead || durationMs <= 0 || (target.navigationEvadeUntil ?? 0) > now) return false;
|
||||
const previous = target.tauntDiminishingReturns;
|
||||
const applications = target.boss && previous && previous.resetAt > now ? previous.applications : 0;
|
||||
const multiplier = target.boss ? [1, 0.65, 0.4225, 0][Math.min(3, applications)] : 1;
|
||||
@@ -5676,6 +5681,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
));
|
||||
for (const [mobId, mob] of Object.entries(mobs)) {
|
||||
if (mob.dead || mobGroupId(mobId) !== groupId) continue;
|
||||
if ((mob.navigationEvadeUntil ?? 0) > now) continue;
|
||||
const seeded = groupWasEngaged
|
||||
? mob.threatByActor
|
||||
: seedActorThreat(mob.threatByActor, source.actorId);
|
||||
@@ -6441,7 +6447,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
let engagedGroups = 0;
|
||||
for (const id of Object.keys(mobs)) {
|
||||
const mob = mobs[id];
|
||||
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0) continue;
|
||||
if (mob.dead || mob.despawned || mob.engaged || mob.aggroRange <= 0
|
||||
|| mob.combatPhase === "returning" || (mob.navigationEvadeUntil ?? 0) > now) continue;
|
||||
const position = getMobPosition(id);
|
||||
if (!position) continue;
|
||||
const actorId = nearestAggroActorId(runtimeState, position, mob.aggroRange);
|
||||
@@ -6452,6 +6459,38 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return engagedGroups;
|
||||
},
|
||||
|
||||
resetMobForNavigation: (id, atHome, now = combatNow()) => {
|
||||
const state = get();
|
||||
const mob = state.mobs[id];
|
||||
if (!mob || mob.dead || mob.despawned || !Number.isFinite(now)) return false;
|
||||
set({
|
||||
mobs: { ...state.mobs, [id]: {
|
||||
...mob,
|
||||
engaged: false,
|
||||
combatPhase: atHome ? "idle" : "returning",
|
||||
navigationEvadeUntil: Math.max(mob.navigationEvadeUntil ?? 0, now + 10_000),
|
||||
health: mob.maxHealth,
|
||||
statuses: [],
|
||||
controlDiminishingReturns: {},
|
||||
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
|
||||
auras: [],
|
||||
abilityReadyAt: {},
|
||||
abilityCastCounts: {},
|
||||
activeCast: null,
|
||||
schoolLockedUntil: {},
|
||||
nextAttackAt: 0,
|
||||
nextAttackIndex: 0,
|
||||
threatByActor: {},
|
||||
targetActorId: null,
|
||||
forcedTarget: null,
|
||||
} },
|
||||
effects: state.effects.filter((effect) => effect.targetId !== id && effect.sourceActorId !== id),
|
||||
auras: state.auras.filter((aura) => aura.targetId !== id && aura.sourceId !== id),
|
||||
areaEffects: state.areaEffects.filter((area) => area.sourceActorId !== id),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
advanceMobCombat: (now = combatNow(), playerPosition) => {
|
||||
const state = get();
|
||||
const runtimeState = playerPosition
|
||||
@@ -6527,6 +6566,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const dy = position[1] - targetPosition[1];
|
||||
const dz = position[2] - targetPosition[2];
|
||||
const distanceSquared = dx * dx + dy * dy + dz * dz;
|
||||
const targetVisible = combatHasLineOfSight(position, targetPosition);
|
||||
const abilityReadyAt = Object.keys(mob.abilityReadyAt).length
|
||||
? { ...mob.abilityReadyAt }
|
||||
: { ...initialEnemyAbilityReadyAt(mob, now) };
|
||||
@@ -6537,6 +6577,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
let enemyActiveCast = cannotAttack || (silenced && mob.activeCast?.school !== "physical")
|
||||
? null
|
||||
: mob.activeCast;
|
||||
if (enemyActiveCast) {
|
||||
const pendingAttack = mob.attacks.find((attack) => attack.id === enemyActiveCast!.attackId);
|
||||
const pendingPosition = enemyActiveCast.targetActorId
|
||||
? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null;
|
||||
if (pendingAttack && enemyAbilityNeedsHostileRange(pendingAttack)
|
||||
&& (!pendingPosition || !combatHasLineOfSight(position, pendingPosition)
|
||||
|| Math.hypot(position[0] - pendingPosition[0], position[1] - pendingPosition[1],
|
||||
position[2] - pendingPosition[2]) > pendingAttack.range)) {
|
||||
emitEnemyAttackPresentation(mob, pendingAttack, "cast-cancel", now, position,
|
||||
enemyActiveCast.targetActorId, pendingPosition);
|
||||
abilityReadyAt[pendingAttack.id] = now + 250;
|
||||
enemyActiveCast = null;
|
||||
}
|
||||
}
|
||||
let completingEnemyCast = false;
|
||||
let selectedAttackIndex = -1;
|
||||
let selectedAttack: EnemyAttackDefinition | null = null;
|
||||
@@ -6551,7 +6605,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const attack = mob.attacks[index];
|
||||
const attackKind = enemyAttackKind(attack);
|
||||
const inAbilityRange = !enemyAbilityNeedsHostileRange(attack)
|
||||
|| distanceSquared <= attack.range * attack.range;
|
||||
|| (targetVisible && distanceSquared <= attack.range * attack.range);
|
||||
if (
|
||||
inAbilityRange
|
||||
&& !(silenced && attackKind === "spell")
|
||||
@@ -6567,10 +6621,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
}
|
||||
const inRange = mob.attacks.some((attack) => (
|
||||
!enemyAbilityNeedsHostileRange(attack)
|
||||
|| distanceSquared <= attack.range * attack.range
|
||||
));
|
||||
// A self buff/heal must not pin an otherwise blocked attacker in place.
|
||||
const hostileAttacks = mob.attacks.filter(enemyAbilityNeedsHostileRange);
|
||||
const inRange = targetVisible && (hostileAttacks.length
|
||||
? hostileAttacks.some((attack) => distanceSquared <= attack.range * attack.range)
|
||||
: distanceSquared <= mob.attackRange * mob.attackRange);
|
||||
if (selectedAttack && completingEnemyCast && enemyAbilityNeedsHostileRange(selectedAttack)) {
|
||||
const castTargetPosition = enemyActiveCast?.targetActorId
|
||||
? actorPosition(runtimeState, enemyActiveCast.targetActorId) : null;
|
||||
if (!castTargetPosition || !combatHasLineOfSight(position, castTargetPosition)
|
||||
|| Math.hypot(position[0] - castTargetPosition[0], position[1] - castTargetPosition[1],
|
||||
position[2] - castTargetPosition[2]) > selectedAttack.range) {
|
||||
emitEnemyAttackPresentation(mob, selectedAttack, "cast-cancel", now, position,
|
||||
enemyActiveCast?.targetActorId ?? resolution.targetActorId, castTargetPosition);
|
||||
abilityReadyAt[selectedAttack.id] = now + 250;
|
||||
enemyActiveCast = null;
|
||||
selectedAttack = null;
|
||||
}
|
||||
}
|
||||
const phase: MobCombatState["combatPhase"] = inRange ? "attacking" : rooted ? "idle" : "chasing";
|
||||
let nextAttackAt = Math.min(...Object.values(abilityReadyAt), Number.POSITIVE_INFINITY);
|
||||
let attackRevision = mob.attackRevision;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { registerCombatSpatialProvider, resetCombatSpatialProvider } from "./combatSpatial";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
|
||||
import { DEFAULT_ENEMY_ATTACK, type EnemyAttackDefinition } from "./enemyAttacks";
|
||||
import { createMobPursuit } from "./mobPursuit";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
|
||||
let visible = true;
|
||||
const melee = { ...DEFAULT_ENEMY_ATTACK, id: "swing", range: 4, initialCooldownMs: [0, 0] as const };
|
||||
const spell: EnemyAttackDefinition = { ...melee, id: "bolt", range: 30, school: "shadow", animation: "cast", castTimeMs: 1_000 };
|
||||
function register(id = "enemy", attacks: readonly EnemyAttackDefinition[] = [melee]) {
|
||||
setMobPosition(id, [2, 0, 0]);
|
||||
useCombatStore.getState().registerMob(id, { name: id, attacks, maxHealth: 1_000, xpReward: 0 });
|
||||
useCombatStore.getState().damageMob(id, 1, 1_000);
|
||||
}
|
||||
|
||||
describe("dungeon wall combat authorization", () => {
|
||||
beforeEach(() => {
|
||||
resetCombatSpatialProvider(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 20 });
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
usePartyStore.getState().initializeParty("walls", 20, useCombatStore.getState().health);
|
||||
visible = true;
|
||||
registerCombatSpatialProvider({ actor: () => null, hasLineOfSight: () => visible, projectGroundPoint: (point) => point });
|
||||
});
|
||||
afterEach(resetCombatSpatialProvider);
|
||||
|
||||
it("does not swing or consume an attack cooldown through a wall despite melee distance", () => {
|
||||
register(); visible = false;
|
||||
const health = useCombatStore.getState().health;
|
||||
const events: CombatPresentationEvent[] = [];
|
||||
const unsubscribe = subscribeCombatPresentation((message) => { if (message.kind === "event") events.push(message.event); });
|
||||
expect(useCombatStore.getState().advanceMobCombat(2_000)).toBe(0);
|
||||
expect(useCombatStore.getState().mobs.enemy).toMatchObject({ combatPhase: "chasing", attackRevision: 0 });
|
||||
expect(useCombatStore.getState().health).toBe(health);
|
||||
expect(events.filter((event) => event.sourceActorId === "enemy")).toHaveLength(0);
|
||||
visible = true;
|
||||
expect(useCombatStore.getState().advanceMobCombat(2_100)).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().mobs.enemy.combatPhase).toBe("attacking");
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("does not start an obstructed cast and cancels a pending cast when sight is lost", () => {
|
||||
register("enemy", [spell]); visible = false;
|
||||
useCombatStore.getState().advanceMobCombat(1_000);
|
||||
expect(useCombatStore.getState().mobs.enemy.activeCast).toBeNull();
|
||||
visible = true;
|
||||
useCombatStore.getState().advanceMobCombat(1_100);
|
||||
expect(useCombatStore.getState().mobs.enemy.activeCast).not.toBeNull();
|
||||
const health = useCombatStore.getState().health;
|
||||
visible = false;
|
||||
expect(useCombatStore.getState().advanceMobCombat(1_200)).toBe(0);
|
||||
expect(useCombatStore.getState().mobs.enemy).toMatchObject({ activeCast: null, combatPhase: "chasing", attackRevision: 0 });
|
||||
expect(useCombatStore.getState().health).toBe(health);
|
||||
});
|
||||
|
||||
it("does not let a self buff keep a wall-blocked melee mob in attack mode", () => {
|
||||
register("enemy", [melee, { ...melee, id: "buff", target: "self", effect: { kind: "aura", aura: "enrage", durationMs: 10_000 } }]);
|
||||
visible = false;
|
||||
useCombatStore.getState().advanceMobCombat(2_000);
|
||||
expect(useCombatStore.getState().mobs.enemy.combatPhase).toBe("chasing");
|
||||
});
|
||||
|
||||
it("does not proximity-pull a visible-distance mob through a wall", () => {
|
||||
setMobPosition("idle", [2, 0, 0]);
|
||||
useCombatStore.getState().registerMob("idle", { name: "idle", attacks: [melee] });
|
||||
visible = false;
|
||||
expect(useCombatStore.getState().engageNearbyMobs(1_000)).toBe(0);
|
||||
visible = true;
|
||||
expect(useCombatStore.getState().engageNearbyMobs(1_001)).toBe(1);
|
||||
});
|
||||
|
||||
it("resets only the stuck living mob, clears its threat/cast, restores health, and delays repulls", () => {
|
||||
register("enemy", [spell]); register("other");
|
||||
useCombatStore.getState().advanceMobCombat(1_000);
|
||||
const other = useCombatStore.getState().mobs.other;
|
||||
expect(useCombatStore.getState().resetMobForNavigation("enemy", true, 2_000)).toBe(true);
|
||||
expect(useCombatStore.getState().mobs.enemy).toMatchObject({ health: 1_000, engaged: false,
|
||||
combatPhase: "idle", activeCast: null, targetActorId: null, threatByActor: {}, navigationEvadeUntil: 12_000,
|
||||
dead: false, experienceGranted: false });
|
||||
expect(useCombatStore.getState().mobs.other).toBe(other);
|
||||
expect(useCombatStore.getState().engageNearbyMobs(11_999)).toBe(0);
|
||||
expect(useCombatStore.getState().engageNearbyMobs(12_000)).toBe(1);
|
||||
});
|
||||
|
||||
it("does not recover or resurrect a dead mob", () => {
|
||||
register();
|
||||
useCombatStore.getState().damageMob("enemy", 10_000, 2_000);
|
||||
expect(useCombatStore.getState().resetMobForNavigation("enemy", true, 3_000)).toBe(false);
|
||||
expect(useCombatStore.getState().mobs.enemy.dead).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects damage against a navigation-reset mob during its evade cooldown", () => {
|
||||
register();
|
||||
useCombatStore.getState().resetMobForNavigation("enemy", true, 2_000);
|
||||
expect(useCombatStore.getState().damageMob("enemy", 500, 3_000).amount).toBe(0);
|
||||
expect(useCombatStore.getState().tauntMob("enemy", { actorId: "__player__", role: "healer" }, 3_000, 3_000)).toBe(false);
|
||||
expect(useCombatStore.getState().mobs.enemy.health).toBe(1_000);
|
||||
});
|
||||
|
||||
it("cancels an NPC teammate's hostile cast immediately on lost sight so it can pursue", () => {
|
||||
register();
|
||||
const member = usePartyStore.getState().members[0];
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
updatePartyRuntimeTargetVisibility(member.id, "enemy", true);
|
||||
usePartyStore.setState((state) => ({ members: state.members.map((candidate) => candidate.id === member.id
|
||||
? { ...candidate, activeCastTargetId: "enemy", activeCast: {
|
||||
abilityId: "mage-frostbolt", school: "frost", mode: "cast", startedAt: 1_000,
|
||||
completesAt: 5_000, originalDurationMs: 4_000, pushbackHits: 0,
|
||||
origin: [0, 0, 0] as const, targetId: "enemy",
|
||||
} } : candidate) }));
|
||||
visible = false;
|
||||
advanceDungeonPartyCombat(1_100);
|
||||
expect(usePartyStore.getState().members[0]).toMatchObject({ activeCast: null, status: "advancing" });
|
||||
});
|
||||
|
||||
it("nearby teleport recovery leaves mob health, threat, target, and other mobs unchanged", () => {
|
||||
register(); register("other");
|
||||
const before = useCombatStore.getState().mobs;
|
||||
const navigator = createMobPursuit({ graph: () => null, hasLineOfSight: (from) => from[0] > 2,
|
||||
directAllowed: () => false, routeAllowed: () => true, validateLanding: (point) => point });
|
||||
const position: [number, number, number] = [0, 0, 0];
|
||||
const sample = { id: "enemy", targetId: "tank", position, target: [4, 0, 0] as const,
|
||||
home: [0, 0, 0] as const, phase: "chasing" as const, health: before.enemy.health,
|
||||
engaged: true, active: true, casting: false, controlled: false, attackRange: 1.5,
|
||||
leashRange: 40, maximumStep: 0, nowMs: 0 };
|
||||
navigator.step(sample);
|
||||
navigator.step({ ...sample, nowMs: 2_000 });
|
||||
expect(navigator.step({ ...sample, nowMs: 5_000 })).toBe("teleported");
|
||||
expect(position[0]).toBeGreaterThan(2);
|
||||
expect(useCombatStore.getState().mobs.enemy).toBe(before.enemy);
|
||||
expect(useCombatStore.getState().mobs.other).toBe(before.other);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMobPursuit, chooseMobPursuitLanding, type MobPursuitSample, type MobPursuitSpatial } from "./mobPursuit";
|
||||
import type { PartyNavigationGraph } from "./partyNavigation";
|
||||
|
||||
function sample(patch: Partial<MobPursuitSample> = {}): MobPursuitSample {
|
||||
return { id: "mob", targetId: "tank", position: [0, 0, 0], target: [4, 0, 0], home: [0, 0, 0],
|
||||
phase: "chasing", health: 100, engaged: true, active: true, casting: false, controlled: false,
|
||||
attackRange: 1.5, leashRange: 40, maximumStep: 0, nowMs: 0, ...patch };
|
||||
}
|
||||
|
||||
function spatial(patch: Partial<MobPursuitSpatial> = {}): MobPursuitSpatial {
|
||||
return { graph: () => null, hasLineOfSight: () => false, directAllowed: () => false,
|
||||
routeAllowed: () => true, validateLanding: (point) => point, ...patch };
|
||||
}
|
||||
|
||||
describe("wall-aware mob pursuit", () => {
|
||||
it("moves toward a blocked target even when the old combat snapshot says attacking", () => {
|
||||
const navigator = createMobPursuit(spatial({ directAllowed: () => true }));
|
||||
const state = sample({ phase: "attacking", attackRange: 6, maximumStep: 0.2 });
|
||||
expect(navigator.step(state)).toBe("none");
|
||||
expect(state.position[0]).toBeCloseTo(0.2);
|
||||
});
|
||||
|
||||
it("holds only when range and clear sight agree", () => {
|
||||
const navigator = createMobPursuit(spatial({ hasLineOfSight: () => true, directAllowed: () => true }));
|
||||
const state = sample({ attackRange: 6, maximumStep: 0.2 });
|
||||
navigator.step(state);
|
||||
expect(state.position).toEqual([0, 0, 0]);
|
||||
expect(navigator.step({ ...state, nowMs: 10_000 })).toBe("none");
|
||||
});
|
||||
|
||||
it("routes around a wall through a doorway rather than cutting through it", () => {
|
||||
const positions = [[-2, 0, 0], [-2, 0, 3], [2, 0, 3], [2, 0, 0]] as const;
|
||||
const graph: PartyNavigationGraph = {
|
||||
nodes: positions.map((position, id) => ({ id, position, neighbors: [id - 1, id + 1]
|
||||
.filter((to) => to >= 0 && to < positions.length).map((to) => ({ to, cost: 4 })) })),
|
||||
weldTolerance: 0.001, sourceVertexCount: 4, sourceTriangleCount: 0,
|
||||
weldedNodeCount: 4, componentCount: 1, discardedNodeCount: 0,
|
||||
};
|
||||
const clear = (a: readonly number[], b: readonly number[]) => {
|
||||
if (a[0] * b[0] >= 0) return true;
|
||||
const t = -a[0] / (b[0] - a[0]);
|
||||
return a[2] + (b[2] - a[2]) * t >= 2.5;
|
||||
};
|
||||
const navigator = createMobPursuit(spatial({ graph: () => graph, directAllowed: clear, hasLineOfSight: clear }));
|
||||
const state = sample({ position: [-2, 0, 0], home: [-2, 0, 0], target: [2, 0, 0], maximumStep: 0.1, attackRange: 0.5 });
|
||||
let reachedDoorway = false;
|
||||
for (let frame = 0; frame < 120; frame += 1) {
|
||||
const previous = [...state.position];
|
||||
expect(navigator.step({ ...state, nowMs: frame * 50 })).toBe("none");
|
||||
expect(clear(previous, state.position)).toBe(true);
|
||||
if (state.position[2] >= 2.5) reachedDoorway = true;
|
||||
}
|
||||
expect(reachedDoorway).toBe(true);
|
||||
expect(Math.hypot(state.position[0] - 2, state.position[2])).toBeLessThanOrEqual(0.6);
|
||||
});
|
||||
|
||||
it("recovers a nearby stalled mob only after five seconds and preserves a ten-second cooldown", () => {
|
||||
const navigator = createMobPursuit(spatial({ hasLineOfSight: (a) => a[0] > 2 }));
|
||||
const state = sample();
|
||||
navigator.step(state);
|
||||
expect(navigator.step({ ...state, nowMs: 2_000 })).toBe("none");
|
||||
expect(navigator.step({ ...state, nowMs: 4_999 })).toBe("none");
|
||||
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("teleported");
|
||||
expect(state.position[0]).toBeGreaterThan(2);
|
||||
state.position[0] = 0;
|
||||
navigator.step({ ...state, nowMs: 5_001 });
|
||||
navigator.step({ ...state, nowMs: 7_001 });
|
||||
expect(navigator.step({ ...state, nowMs: 14_999 })).toBe("none");
|
||||
expect(navigator.step({ ...state, nowMs: 15_000 })).toBe("teleported");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ active: false }, { casting: true }, { controlled: true }, { health: 0 }, { engaged: false },
|
||||
])("does not recover intentional immobility %j", (exclusion) => {
|
||||
const navigator = createMobPursuit(spatial());
|
||||
const state = sample(exclusion);
|
||||
navigator.step(state);
|
||||
expect(navigator.step({ ...state, nowMs: 10_000 })).toBe("none");
|
||||
expect(state.position).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ target: [12, 0, 0] as const }, { target: [4, 5, 0] as const }, { leashRange: 2 },
|
||||
])("resets instead of teleporting to a distant, wrong-floor, or out-of-leash target %j", (patch) => {
|
||||
const navigator = createMobPursuit(spatial());
|
||||
const state = sample(patch);
|
||||
navigator.step(state);
|
||||
navigator.step({ ...state, nowMs: 2_000 });
|
||||
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("reset");
|
||||
expect(state.position).toEqual(state.home);
|
||||
});
|
||||
|
||||
it("resets when every nearby target landing is blocked without applying an invalid home landing", () => {
|
||||
const navigator = createMobPursuit(spatial({ validateLanding: () => null }));
|
||||
const state = sample({ position: [1, 0, 0] });
|
||||
navigator.step(state);
|
||||
navigator.step({ ...state, nowMs: 2_000 });
|
||||
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("reset");
|
||||
expect(state.position).toEqual([1, 0, 0]);
|
||||
});
|
||||
|
||||
it("recovers a stalled return to validated home, never to the old target", () => {
|
||||
const navigator = createMobPursuit(spatial());
|
||||
const state = sample({ engaged: false, phase: "returning", position: [5, 0, 0] });
|
||||
navigator.step(state);
|
||||
navigator.step({ ...state, nowMs: 2_000 });
|
||||
expect(navigator.step({ ...state, nowMs: 5_000 })).toBe("home");
|
||||
expect(state.position).toEqual(state.home);
|
||||
});
|
||||
|
||||
it("bounds landing checks, rejects unsafe projections, and never blindly uses the target", () => {
|
||||
let checks = 0;
|
||||
const result = chooseMobPursuitLanding(sample(), spatial({
|
||||
hasLineOfSight: () => true,
|
||||
validateLanding: () => { checks += 1; return [20, 0, 0]; },
|
||||
}));
|
||||
expect(result).toBeNull();
|
||||
expect(checks).toBe(16);
|
||||
});
|
||||
|
||||
it("keeps different mobs' timers independent", () => {
|
||||
const navigator = createMobPursuit(spatial({ hasLineOfSight: (a) => a[0] > 2 }));
|
||||
const first = sample();
|
||||
const second = sample({ id: "other", position: [0, 0, 0] });
|
||||
navigator.step(first);
|
||||
navigator.step({ ...second, nowMs: 4_000 });
|
||||
navigator.step({ ...first, nowMs: 2_000 });
|
||||
expect(navigator.step({ ...first, nowMs: 5_000 })).toBe("teleported");
|
||||
expect(navigator.step({ ...second, nowMs: 5_000 })).toBe("none");
|
||||
expect(second.position).toEqual([0, 0, 0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { PartyNavigationGraph } from "./partyNavigation";
|
||||
import { distanceSquared, moveMobToward, type MobAiPhase, type MobAiPosition, type MutableMobAiPosition } from "./mobAi";
|
||||
import { emptyPartyRouteState, nextPartyRouteRecoveryWaypoint, nextPartyRouteWaypoint, partyRouteNeedsPlanning, planPartyRoute } from "./partyPathing";
|
||||
import { createPartyStuckRecoveryState, markPartyStuckRecovered, updatePartyStuckRecovery, type PartyStuckRecoveryState } from "./partyStuckRecovery";
|
||||
|
||||
export const MOB_NEAR_RECOVERY_DISTANCE = 10;
|
||||
const SAME_FLOOR_TOLERANCE = 1.25;
|
||||
|
||||
export interface MobPursuitSample {
|
||||
readonly id: string;
|
||||
readonly targetId: string | null;
|
||||
readonly position: MutableMobAiPosition;
|
||||
readonly target: MobAiPosition;
|
||||
readonly home: MobAiPosition;
|
||||
readonly phase: MobAiPhase;
|
||||
readonly health: number;
|
||||
readonly engaged: boolean;
|
||||
readonly active: boolean;
|
||||
readonly casting: boolean;
|
||||
readonly controlled: boolean;
|
||||
readonly attackRange: number;
|
||||
readonly leashRange: number;
|
||||
readonly maximumStep: number;
|
||||
readonly nowMs: number;
|
||||
}
|
||||
|
||||
export interface MobPursuitSpatial {
|
||||
readonly graph: () => PartyNavigationGraph | null;
|
||||
readonly hasLineOfSight: (from: MobAiPosition, to: MobAiPosition) => boolean;
|
||||
readonly directAllowed: (from: MobAiPosition, to: MobAiPosition) => boolean;
|
||||
readonly routeAllowed: (from: MobAiPosition, to: MobAiPosition) => boolean;
|
||||
readonly validateLanding: (point: MobAiPosition, anchor: MobAiPosition) => MobAiPosition | null;
|
||||
}
|
||||
|
||||
/** Never blindly land inside the target, a wall, or an overlapping dungeon floor. */
|
||||
export function chooseMobPursuitLanding(sample: MobPursuitSample, spatial: MobPursuitSpatial): MobAiPosition | null {
|
||||
const { position, target, home } = sample;
|
||||
if (distanceSquared(position, target) > MOB_NEAR_RECOVERY_DISTANCE ** 2
|
||||
|| Math.abs(position[1] - target[1]) > SAME_FLOOR_TOLERANCE
|
||||
|| distanceSquared(home, target) > sample.leashRange ** 2) return null;
|
||||
const bearing = Math.atan2(position[0] - target[0], position[2] - target[2]);
|
||||
const radius = Math.max(0.85, Math.min(2, sample.attackRange * 0.6));
|
||||
for (let offset = 0; offset < 16; offset += 1) {
|
||||
const angle = bearing + offset * Math.PI / 8;
|
||||
const candidate: MobAiPosition = [target[0] + Math.sin(angle) * radius, target[1], target[2] + Math.cos(angle) * radius];
|
||||
const landing = spatial.validateLanding(candidate, target);
|
||||
if (landing?.every(Number.isFinite) && distanceSquared(landing, candidate) <= 0.85 ** 2
|
||||
&& distanceSquared(landing, target) <= sample.attackRange ** 2
|
||||
&& distanceSquared(landing, home) <= sample.leashRange ** 2
|
||||
&& Math.abs(landing[1] - target[1]) <= SAME_FLOOR_TOLERANCE
|
||||
&& spatial.hasLineOfSight(landing, target)) return [...landing];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Shared imperative adapter for static spawns, roaming packs, and summoned enemies. */
|
||||
export function createMobPursuit(spatial: MobPursuitSpatial) {
|
||||
const states = new Map<string, {
|
||||
route: ReturnType<typeof emptyPartyRouteState>;
|
||||
watchdog: PartyStuckRecoveryState;
|
||||
graph: PartyNavigationGraph | null;
|
||||
}>();
|
||||
const desired: MutableMobAiPosition = [0, 0, 0];
|
||||
const next: MutableMobAiPosition = [0, 0, 0];
|
||||
return {
|
||||
clear: () => states.clear(),
|
||||
step(sample: MobPursuitSample): "none" | "teleported" | "reset" | "home" {
|
||||
const { position, target, home, nowMs } = sample;
|
||||
if (![...position, ...target, ...home, nowMs, sample.attackRange, sample.leashRange, sample.maximumStep].every(Number.isFinite)) return "none";
|
||||
let state = states.get(sample.id);
|
||||
if (!state) {
|
||||
state = { route: emptyPartyRouteState(), watchdog: createPartyStuckRecoveryState(position, nowMs), graph: spatial.graph() };
|
||||
states.set(sample.id, state);
|
||||
}
|
||||
const returning = sample.phase === "returning";
|
||||
const goal = returning ? home : target;
|
||||
const visible = spatial.hasLineOfSight(position, target);
|
||||
const inRange = visible && distanceSquared(position, target) <= sample.attackRange ** 2;
|
||||
const movementExpected = returning ? distanceSquared(position, home) > 0.2 ** 2 : sample.engaged && !inRange;
|
||||
const excluded = !sample.active || sample.health <= 0 || sample.controlled || sample.casting;
|
||||
const goalKey = returning ? "return-home" : `target:${sample.targetId}`;
|
||||
const graph = spatial.graph();
|
||||
if (state.graph !== graph) {
|
||||
state.graph = graph;
|
||||
state.route = emptyPartyRouteState();
|
||||
}
|
||||
let waypoint: MobAiPosition | null = null;
|
||||
let approvedRouteStep = false;
|
||||
if (movementExpected && !excluded) {
|
||||
if (spatial.directAllowed(position, goal)) waypoint = goal;
|
||||
else {
|
||||
if (partyRouteNeedsPlanning(state.route, goalKey, position, goal, nowMs, { replanIntervalMs: 350, blockedRetryMs: 800 })) {
|
||||
state.route = planPartyRoute(graph, goalKey, position, goal, nowMs, {
|
||||
maxOriginSnapDistance: 3, maxDestinationSnapDistance: 3,
|
||||
directSegmentAllowed: spatial.directAllowed,
|
||||
routeSegmentAllowed: graph?.triangles?.length ? spatial.routeAllowed : undefined,
|
||||
destinationReached: (surface, destination) => distanceSquared(surface, destination)
|
||||
<= (returning ? 0.2 : sample.attackRange) ** 2
|
||||
&& (returning || spatial.hasLineOfSight(surface, destination)),
|
||||
});
|
||||
}
|
||||
if (nextPartyRouteWaypoint(state.route, position, desired)) {
|
||||
waypoint = desired;
|
||||
approvedRouteStep = true;
|
||||
} else if (nextPartyRouteRecoveryWaypoint(state.route, position, desired, spatial.directAllowed)) waypoint = desired;
|
||||
}
|
||||
if (waypoint) {
|
||||
moveMobToward(position, waypoint, sample.maximumStep, next);
|
||||
// Recast edges are already agent-radius eroded. A second full-radius
|
||||
// sweep at boundary vertices can reject a valid stair corridor;
|
||||
// use the same center/wall check as companions on approved edges.
|
||||
// Off-surface connectors and direct shortcuts still need body sweeps.
|
||||
if (spatial.directAllowed(position, next) || (approvedRouteStep && spatial.routeAllowed(position, next))) {
|
||||
position[0] = next[0]; position[1] = next[1]; position[2] = next[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
const recovery = updatePartyStuckRecovery(state.watchdog, {
|
||||
position, waypoint, goalKey, movementExpected, nowMs, health: sample.health,
|
||||
paused: !sample.active, stopped: false, casting: sample.casting, controlled: sample.controlled,
|
||||
});
|
||||
if (returning && !excluded && !movementExpected) return "home";
|
||||
if (recovery !== "none") state.route = emptyPartyRouteState();
|
||||
if (recovery !== "recover") return "none";
|
||||
const landing = returning ? spatial.validateLanding(home, home) : chooseMobPursuitLanding(sample, spatial);
|
||||
if (landing && (!returning || distanceSquared(landing, home) <= 0.85 ** 2)) {
|
||||
position[0] = landing[0]; position[1] = landing[1]; position[2] = landing[2];
|
||||
markPartyStuckRecovered(state.watchdog, position, nowMs);
|
||||
return returning ? "home" : "teleported";
|
||||
}
|
||||
if (!returning) {
|
||||
const homeLanding = spatial.validateLanding(home, home);
|
||||
if (homeLanding?.every(Number.isFinite) && distanceSquared(homeLanding, home) <= 0.85 ** 2) {
|
||||
position[0] = homeLanding[0]; position[1] = homeLanding[1]; position[2] = homeLanding[2];
|
||||
}
|
||||
markPartyStuckRecovered(state.watchdog, position, nowMs);
|
||||
return "reset";
|
||||
}
|
||||
return "none";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,14 +7,11 @@ import {
|
||||
nextPartyBreadcrumbWaypoint,
|
||||
partyCombatMovementGoal,
|
||||
partyFormationPosition,
|
||||
partyMadeStuckProgress,
|
||||
partyMovementProfile,
|
||||
partyNaturalFollowPosition,
|
||||
partyPersonalizedCombatMovementGoal,
|
||||
partyPresentationY,
|
||||
partyShouldRecallForStuck,
|
||||
partyShouldMoveToComfortTarget,
|
||||
PARTY_STUCK_RECALL_DISTANCE,
|
||||
personalizedPartyCombatRangeBand,
|
||||
resetPartyBreadcrumbTrail,
|
||||
stepPartyPosition,
|
||||
@@ -125,17 +122,6 @@ describe("party world movement", () => {
|
||||
)).toBe("hold");
|
||||
});
|
||||
|
||||
it("recalls only distant allies that should be moving and stopped making progress", () => {
|
||||
expect(partyMadeStuckProgress([0, 0, 0], [0.45, 4, 0])).toBe(true);
|
||||
expect(partyMadeStuckProgress([0, 0, 0], [0.44, 0, 0])).toBe(false);
|
||||
|
||||
expect(PARTY_STUCK_RECALL_DISTANCE).toBe(80);
|
||||
expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], true, 4_500, 1_000)).toBe(true);
|
||||
expect(partyShouldRecallForStuck([79.9, 0, 0], [0, 0, 0], true, 4_500, 1_000)).toBe(false);
|
||||
expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], false, 4_500, 1_000)).toBe(false);
|
||||
expect(partyShouldRecallForStuck([80, 0, 0], [0, 0, 0], true, 4_499, 1_000)).toBe(false);
|
||||
});
|
||||
|
||||
it("makes melee approach a fleeing target and hold once in range", () => {
|
||||
const out: [number, number, number] = [0, 0, 0];
|
||||
expect(partyCombatMovementGoal(
|
||||
|
||||
@@ -86,45 +86,6 @@ export function partyMovementProfile(memberId: string, livingRank: number): Part
|
||||
};
|
||||
}
|
||||
|
||||
// Allow companions to operate across a full boss arena before the stuck
|
||||
// watchdog treats their separation from the player as a reason to recall.
|
||||
export const PARTY_STUCK_RECALL_DISTANCE = 80;
|
||||
export const PARTY_STUCK_RECALL_DELAY_MS = 3_500;
|
||||
export const PARTY_STUCK_PROGRESS_DISTANCE = 0.45;
|
||||
|
||||
/** Tests whether an expected move has made enough planar progress to reset the stuck timer. */
|
||||
export function partyMadeStuckProgress(
|
||||
previous: PartyWorldPosition,
|
||||
current: PartyWorldPosition,
|
||||
minimumDistance = PARTY_STUCK_PROGRESS_DISTANCE,
|
||||
): boolean {
|
||||
const minimum = Math.max(0, Number.isFinite(minimumDistance) ? minimumDistance : 0);
|
||||
const distance = Math.hypot(current[0] - previous[0], current[2] - previous[2]);
|
||||
return Number.isFinite(distance) && distance >= minimum;
|
||||
}
|
||||
|
||||
/**
|
||||
* A distant ally is stuck only while it should be moving and its progress
|
||||
* timer has expired. Intentional ranged/melee holds never enter this state.
|
||||
*/
|
||||
export function partyShouldRecallForStuck(
|
||||
current: PartyWorldPosition,
|
||||
player: PartyWorldPosition,
|
||||
movementExpected: boolean,
|
||||
nowMs: number,
|
||||
lastProgressAtMs: number,
|
||||
recallDistance = PARTY_STUCK_RECALL_DISTANCE,
|
||||
stuckDelayMs = PARTY_STUCK_RECALL_DELAY_MS,
|
||||
): boolean {
|
||||
if (!movementExpected || !Number.isFinite(nowMs) || !Number.isFinite(lastProgressAtMs)) return false;
|
||||
const distance = Math.hypot(current[0] - player[0], current[2] - player[2]);
|
||||
const minimumDistance = Math.max(0, Number.isFinite(recallDistance) ? recallDistance : 0);
|
||||
const delay = Math.max(0, Number.isFinite(stuckDelayMs) ? stuckDelayMs : 0);
|
||||
return Number.isFinite(distance)
|
||||
&& distance >= minimumDistance
|
||||
&& nowMs - lastProgressAtMs >= delay;
|
||||
}
|
||||
|
||||
export function createPartyBreadcrumbTrail(): PartyBreadcrumbTrail {
|
||||
return { points: [], nextId: 0 };
|
||||
}
|
||||
|
||||
@@ -92,6 +92,19 @@ describe("party path controller", () => {
|
||||
expect(route.blocked).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an in-range surface finish only when combat sight approves it", () => {
|
||||
const options = { maxSnapDistance: 1, localFinishDistance: 1, directSegmentAllowed: () => false };
|
||||
const visible = planPartyRoute(corridorGraph, "mob", [0, 0, 0], [2.5, 0, 2], 100, {
|
||||
...options, destinationReached: (surface, target) => Math.hypot(surface[0] - target[0], surface[2] - target[2]) <= 1,
|
||||
});
|
||||
expect(visible.blocked).toBe(false);
|
||||
expect(visible.waypoints.at(-1)).toEqual([2, 0, 2]);
|
||||
const obstructed = planPartyRoute(corridorGraph, "mob", [0, 0, 0], [2.5, 0, 2], 100, {
|
||||
...options, destinationReached: () => false,
|
||||
});
|
||||
expect(obstructed.blocked).toBe(true);
|
||||
});
|
||||
|
||||
it("advances only one close-spaced waypoint at a corner", () => {
|
||||
const route = {
|
||||
...emptyPartyRouteState(),
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface PartyRouteOptions {
|
||||
start: PartyWorldPosition,
|
||||
end: PartyWorldPosition,
|
||||
) => boolean;
|
||||
/** Combat may finish on the retained surface only when range AND sight allow a hit. */
|
||||
readonly destinationReached?: (surface: PartyWorldPosition, destination: PartyWorldPosition) => boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_SNAP_DISTANCE = 3;
|
||||
@@ -117,7 +119,7 @@ export function planPartyRoute(
|
||||
) return blocked(waypoints);
|
||||
const finishDistance = fullDistance(waypoints[waypoints.length - 1], destination);
|
||||
if (finishDistance > localFinishDistance) return blocked(waypoints);
|
||||
if (finishDistance > reachedDistance * 0.5) {
|
||||
if (finishDistance > reachedDistance * 0.5 && !options.destinationReached?.(waypoints[waypoints.length - 1], destination)) {
|
||||
if (!options.directSegmentAllowed?.(waypoints[waypoints.length - 1], destination)) {
|
||||
return blocked(waypoints);
|
||||
}
|
||||
|
||||
@@ -611,7 +611,7 @@ function emitPartyAbility(
|
||||
member: PartyMember,
|
||||
ability: AbilityDefinition,
|
||||
targetId: string,
|
||||
phase: "cast-start" | "release" | "impact",
|
||||
phase: "cast-start" | "cast-cancel" | "release" | "impact",
|
||||
now: number,
|
||||
instantCast = false,
|
||||
): void {
|
||||
@@ -1148,6 +1148,16 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
const desiredStatus = hostileCast
|
||||
? member.role === "tank" ? "guarding" : "attacking"
|
||||
: "healing";
|
||||
const hostilePosition = hostileCast ? getMobPosition(castTargetId) : null;
|
||||
if (hostileCast && (!hostilePosition || !combatHasLineOfSight(
|
||||
getPartyRuntimePosition(member.id) ?? currentCombat.playerPosition, hostilePosition,
|
||||
))) {
|
||||
emitPartyAbility(member, castingAbility, castTargetId, "cast-cancel", now);
|
||||
memberTargetIds[member.id] = castTargetId;
|
||||
nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null,
|
||||
status: "advancing", nextActionAt: now + 250 };
|
||||
continue;
|
||||
}
|
||||
if (now < member.activeCast.completesAt) {
|
||||
nextMembers[index] = member.status === desiredStatus ? member : { ...member, status: desiredStatus };
|
||||
if (hostileCast) memberTargetIds[member.id] = castTargetId;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyPartyStuckRecoveryLanding,
|
||||
choosePartyStuckRecoveryPosition,
|
||||
createPartyStuckRecoveryState,
|
||||
markPartyStuckRecovered,
|
||||
resetPartyStuckRecoveryProgress,
|
||||
updatePartyStuckRecovery,
|
||||
type PartyStuckRecoverySample,
|
||||
} from "./partyStuckRecovery";
|
||||
import type { MutablePartyWorldPosition, PartyWorldPosition } from "./partyRuntimeRegistry";
|
||||
|
||||
const origin: PartyWorldPosition = [0, 0, 0];
|
||||
const sample = (nowMs: number, overrides: Partial<PartyStuckRecoverySample> = {}): PartyStuckRecoverySample => ({
|
||||
position: origin, waypoint: [10, 0, 0], goalKey: "follow:leader", movementExpected: true,
|
||||
health: 100, paused: false, stopped: false, casting: false, controlled: false, nowMs, ...overrides,
|
||||
});
|
||||
|
||||
describe("per-teammate stuck navigation", () => {
|
||||
it("replans at two seconds and recovers at five, without a player-distance gate", () => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
expect(updatePartyStuckRecovery(state, sample(0))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(1_999))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(2_000))).toBe("replan");
|
||||
expect(updatePartyStuckRecovery(state, sample(2_500))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(4_999))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(5_000))).toBe("recover");
|
||||
expect(updatePartyStuckRecovery(state, sample(5_100))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(6_000))).toBe("recover");
|
||||
});
|
||||
|
||||
it("does not restart the timer when a replan changes waypoints or yields no path", () => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
updatePartyStuckRecovery(state, sample(0));
|
||||
expect(updatePartyStuckRecovery(state, sample(2_000))).toBe("replan");
|
||||
updatePartyStuckRecovery(state, sample(3_000, { waypoint: [0, 0, 10] }));
|
||||
updatePartyStuckRecovery(state, sample(4_000, { waypoint: null }));
|
||||
expect(updatePartyStuckRecovery(state, sample(5_000, { waypoint: null }))).toBe("recover");
|
||||
});
|
||||
|
||||
it("cancels back-and-forth doorway shuffling instead of treating it as repeated progress", () => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
updatePartyStuckRecovery(state, sample(0));
|
||||
updatePartyStuckRecovery(state, sample(500, { position: [0.6, 0, 0] }));
|
||||
const actions: string[] = [];
|
||||
for (let tick = 1; tick <= 10; tick += 1) {
|
||||
actions.push(updatePartyStuckRecovery(state, sample(500 + tick * 500, {
|
||||
position: tick % 2 ? origin : [0.6, 0, 0],
|
||||
})));
|
||||
}
|
||||
expect(actions).toContain("replan");
|
||||
expect(actions.at(-1)).toBe("recover");
|
||||
expect(state.lastProgressAtMs).toBe(500);
|
||||
});
|
||||
|
||||
it("counts movement toward route waypoints even when the route goes away from the player", () => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
updatePartyStuckRecovery(state, sample(0));
|
||||
for (let tick = 1; tick <= 12; tick += 1) {
|
||||
expect(updatePartyStuckRecovery(state, sample(tick * 500, { position: [tick * 0.5, 0, 0] }))).toBe("none");
|
||||
}
|
||||
expect(state.lastProgressAtMs).toBe(6_000);
|
||||
});
|
||||
|
||||
it("does not mistake target movement or sideways motion for useful actor progress", () => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
updatePartyStuckRecovery(state, sample(0));
|
||||
for (let tick = 1; tick <= 5; tick += 1) {
|
||||
updatePartyStuckRecovery(state, sample(tick * 1_000, { position: [0, 0, tick % 2], waypoint: [10 + tick, 0, 0] }));
|
||||
}
|
||||
expect(state.lastProgressAtMs).toBe(0);
|
||||
});
|
||||
|
||||
it("advances through dense waypoint chains and corners without false recovery", () => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
updatePartyStuckRecovery(state, sample(0, { waypoint: [0.08, 0, 0] }));
|
||||
for (let frame = 1; frame <= 400; frame += 1) {
|
||||
const point = (step: number): PartyWorldPosition => step <= 200
|
||||
? [step * 0.08, 0, 0] : [16, 0, (step - 200) * 0.08];
|
||||
expect(updatePartyStuckRecovery(state, sample(frame * 1000 / 60, {
|
||||
position: point(frame), waypoint: point(frame + 1),
|
||||
}))).toBe("none");
|
||||
}
|
||||
expect(state.lastProgressAtMs).toBeGreaterThan(6_000);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ movementExpected: false }, { health: 0 }, { paused: true }, { stopped: true }, { casting: true }, { controlled: true },
|
||||
])("excludes intentional immobility %j and starts a fresh timer afterward", (excluded) => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
updatePartyStuckRecovery(state, sample(0));
|
||||
expect(updatePartyStuckRecovery(state, sample(10_000, excluded))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(11_000))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(12_999))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(13_000))).toBe("replan");
|
||||
});
|
||||
|
||||
it("keeps a ten-second teleport cooldown across pauses, holds, and goal changes", () => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
markPartyStuckRecovered(state, origin, 5_000);
|
||||
updatePartyStuckRecovery(state, sample(5_100));
|
||||
expect(updatePartyStuckRecovery(state, sample(7_100))).toBe("replan");
|
||||
expect(updatePartyStuckRecovery(state, sample(10_100))).toBe("none");
|
||||
resetPartyStuckRecoveryProgress(state);
|
||||
updatePartyStuckRecovery(state, sample(10_200, { goalKey: "mob:new-target" }));
|
||||
expect(updatePartyStuckRecovery(state, sample(12_200, { goalKey: "mob:new-target" }))).toBe("replan");
|
||||
expect(updatePartyStuckRecovery(state, sample(14_999, { goalKey: "mob:new-target" }))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(15_200, { goalKey: "mob:new-target" }))).toBe("recover");
|
||||
});
|
||||
|
||||
it("keeps independent teammate timers", () => {
|
||||
const stuck = createPartyStuckRecoveryState(origin, 0);
|
||||
const moving = createPartyStuckRecoveryState(origin, 0);
|
||||
updatePartyStuckRecovery(stuck, sample(0));
|
||||
updatePartyStuckRecovery(moving, sample(0));
|
||||
updatePartyStuckRecovery(stuck, sample(2_000));
|
||||
for (let tick = 1; tick <= 10; tick += 1) updatePartyStuckRecovery(moving, sample(tick * 500, { position: [tick * 0.5, 0, 0] }));
|
||||
expect(updatePartyStuckRecovery(stuck, sample(5_000))).toBe("recover");
|
||||
expect(updatePartyStuckRecovery(moving, sample(5_001, { position: [5, 0, 0] }))).toBe("none");
|
||||
expect(moving.cooldownUntilMs).toBe(0);
|
||||
});
|
||||
|
||||
it("moves only the affected teammate and starts only its teleport cooldown", () => {
|
||||
const positions = new Map<string, MutablePartyWorldPosition>([
|
||||
["tank", [0, 0, 0]], ["rogue", [2, 0, 1]], ["mage", [3, 0, 2]],
|
||||
]);
|
||||
const states = new Map([...positions].map(([id, position]) => [id, createPartyStuckRecoveryState(position, 0)]));
|
||||
expect(applyPartyStuckRecoveryLanding(positions, "rogue", [10, 0, 0], states.get("rogue")!, 5_000)).toBe(true);
|
||||
expect(positions.get("rogue")).toEqual([10, 0, 0]);
|
||||
expect(positions.get("tank")).toEqual([0, 0, 0]);
|
||||
expect(positions.get("mage")).toEqual([3, 0, 2]);
|
||||
expect(states.get("rogue")!.cooldownUntilMs).toBe(15_000);
|
||||
expect(states.get("tank")!.cooldownUntilMs).toBe(0);
|
||||
expect(states.get("mage")!.cooldownUntilMs).toBe(0);
|
||||
});
|
||||
|
||||
it("does not apply missing or malformed landings", () => {
|
||||
const positions = new Map<string, MutablePartyWorldPosition>([["tank", [0, 0, 0]]]);
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
expect(applyPartyStuckRecoveryLanding(positions, "missing", [10, 0, 0], state, 5_000)).toBe(false);
|
||||
expect(applyPartyStuckRecoveryLanding(positions, "tank", [NaN, 0, 0], state, 5_000)).toBe(false);
|
||||
expect(positions.get("tank")).toEqual(origin);
|
||||
expect(state.cooldownUntilMs).toBe(0);
|
||||
});
|
||||
|
||||
it("handles malformed samples without poisoning later progress", () => {
|
||||
const state = createPartyStuckRecoveryState(origin, 0);
|
||||
expect(updatePartyStuckRecovery(state, sample(NaN))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(1, { position: [NaN, 0, 0] }))).toBe("none");
|
||||
expect(updatePartyStuckRecovery(state, sample(10_000))).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("safe stuck-recovery landings", () => {
|
||||
const player: PartyWorldPosition = [10, 0, 0];
|
||||
const choose = (trailPoints: readonly PartyWorldPosition[],
|
||||
validate: (point: PartyWorldPosition) => PartyWorldPosition | null = (point) => point) => choosePartyStuckRecoveryPosition({
|
||||
current: origin, player, groupAnchor: player, trailPoints, validate,
|
||||
});
|
||||
|
||||
it("prefers a visited trail point near the group over returning to the player", () => {
|
||||
expect(choose([[2, 0, 0], [9, 0, 0]])).toEqual({ position: [9, 0, 0], source: "trail" });
|
||||
});
|
||||
|
||||
it("skips wrong floors, distant points, current position, and blocked/unsafe landings", () => {
|
||||
const result = choose([[0.2, 0, 0], [10, 5, 0], [50, 0, 0], [9, 0, 0], [8, 0, 0]],
|
||||
(point) => point[0] === 9 ? null : point);
|
||||
expect(result).toEqual({ position: [8, 0, 0], source: "trail" });
|
||||
});
|
||||
|
||||
it("falls back to a validated player point when no safe local trail exists", () => {
|
||||
expect(choose([], (point) => point)).toEqual({ position: player, source: "player" });
|
||||
expect(choose([[9, 0, 0]], (point) => point[0] === 10 ? point : null)).toEqual({ position: player, source: "player" });
|
||||
});
|
||||
|
||||
it("never blindly teleports to an invalid player point or a far-away projected room", () => {
|
||||
expect(choose([], () => null)).toBeNull();
|
||||
expect(choose([[9, 0, 0]], () => [10, 0, 5])).toBeNull();
|
||||
expect(choose([], () => [10, -5, 0])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { MutablePartyWorldPosition, PartyWorldPosition } from "./partyRuntimeRegistry";
|
||||
|
||||
export const PARTY_STUCK_REPLAN_MS = 2_000;
|
||||
export const PARTY_STUCK_RECOVER_MS = 5_000;
|
||||
export const PARTY_STUCK_RECOVERY_COOLDOWN_MS = 10_000;
|
||||
const PROGRESS_DISTANCE = 0.45;
|
||||
const RECOVERY_RETRY_MS = 1_000;
|
||||
const MAX_TRAIL_GROUP_DISTANCE = 12;
|
||||
const MAX_TRAIL_HEIGHT_DIFFERENCE = 1.25;
|
||||
const MIN_RECOVERY_DISTANCE = 1;
|
||||
|
||||
export interface PartyStuckRecoveryState {
|
||||
goalKey: string | null;
|
||||
readonly previousPosition: MutablePartyWorldPosition;
|
||||
waypoint: MutablePartyWorldPosition | null;
|
||||
netProgress: number;
|
||||
lastProgressAtMs: number;
|
||||
replanned: boolean;
|
||||
cooldownUntilMs: number;
|
||||
retryAtMs: number;
|
||||
}
|
||||
|
||||
export interface PartyStuckRecoverySample {
|
||||
readonly position: PartyWorldPosition;
|
||||
readonly waypoint: PartyWorldPosition | null;
|
||||
readonly goalKey: string;
|
||||
readonly movementExpected: boolean;
|
||||
readonly health: number;
|
||||
readonly paused: boolean;
|
||||
readonly stopped: boolean;
|
||||
readonly casting: boolean;
|
||||
readonly controlled: boolean;
|
||||
readonly nowMs: number;
|
||||
}
|
||||
|
||||
function distance(a: PartyWorldPosition, b: PartyWorldPosition): number {
|
||||
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||
}
|
||||
|
||||
function finitePosition(position: PartyWorldPosition): boolean {
|
||||
return position.every(Number.isFinite);
|
||||
}
|
||||
|
||||
export function createPartyStuckRecoveryState(position: PartyWorldPosition, nowMs: number): PartyStuckRecoveryState {
|
||||
return {
|
||||
goalKey: null,
|
||||
previousPosition: [...position],
|
||||
waypoint: null,
|
||||
netProgress: 0,
|
||||
lastProgressAtMs: nowMs,
|
||||
replanned: false,
|
||||
cooldownUntilMs: 0,
|
||||
retryAtMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Clear navigation timing without letting pauses/holds bypass a teleport cooldown. */
|
||||
export function resetPartyStuckRecoveryProgress(state: PartyStuckRecoveryState): void {
|
||||
state.goalKey = null;
|
||||
state.waypoint = null;
|
||||
state.netProgress = 0;
|
||||
state.replanned = false;
|
||||
}
|
||||
|
||||
/** Signed progress toward the previous waypoint cancels doorway back-and-forth. */
|
||||
export function updatePartyStuckRecovery(
|
||||
state: PartyStuckRecoveryState,
|
||||
sample: PartyStuckRecoverySample,
|
||||
): "none" | "replan" | "recover" {
|
||||
const { position, waypoint, nowMs } = sample;
|
||||
if (!Number.isFinite(nowMs) || !finitePosition(position) || (waypoint && !finitePosition(waypoint))) {
|
||||
resetPartyStuckRecoveryProgress(state);
|
||||
return "none";
|
||||
}
|
||||
const excluded = !sample.movementExpected || sample.health <= 0 || sample.paused
|
||||
|| sample.stopped || sample.casting || sample.controlled;
|
||||
if (excluded || state.goalKey !== sample.goalKey || nowMs < state.lastProgressAtMs) {
|
||||
resetPartyStuckRecoveryProgress(state);
|
||||
state.lastProgressAtMs = nowMs;
|
||||
if (!excluded) state.goalKey = sample.goalKey;
|
||||
} else if (state.waypoint) {
|
||||
// Target/waypoint movement itself is not actor progress. Count only the
|
||||
// actor's displacement relative to the last sampled route waypoint.
|
||||
// Once the old waypoint was reached, movement belongs to the next leg.
|
||||
// Otherwise dense waypoint chains would count every forward step as
|
||||
// moving away from the just-completed point.
|
||||
const progressWaypoint = waypoint && distance(state.previousPosition, state.waypoint) <= 0.06
|
||||
&& distance(waypoint, state.waypoint) > 0.04 ? waypoint : state.waypoint;
|
||||
state.netProgress += distance(state.previousPosition, progressWaypoint) - distance(position, progressWaypoint);
|
||||
if (state.netProgress >= PROGRESS_DISTANCE) {
|
||||
state.netProgress = 0;
|
||||
state.lastProgressAtMs = nowMs;
|
||||
state.replanned = false;
|
||||
}
|
||||
}
|
||||
state.previousPosition[0] = position[0];
|
||||
state.previousPosition[1] = position[1];
|
||||
state.previousPosition[2] = position[2];
|
||||
if (waypoint && !excluded) {
|
||||
state.waypoint ??= [0, 0, 0];
|
||||
state.waypoint[0] = waypoint[0];
|
||||
state.waypoint[1] = waypoint[1];
|
||||
state.waypoint[2] = waypoint[2];
|
||||
} else state.waypoint = null;
|
||||
if (excluded) return "none";
|
||||
|
||||
const elapsed = nowMs - state.lastProgressAtMs;
|
||||
if (!state.replanned && elapsed >= PARTY_STUCK_REPLAN_MS) {
|
||||
state.replanned = true;
|
||||
return "replan";
|
||||
}
|
||||
if (elapsed >= PARTY_STUCK_RECOVER_MS && nowMs >= state.cooldownUntilMs && nowMs >= state.retryAtMs) {
|
||||
state.retryAtMs = nowMs + RECOVERY_RETRY_MS;
|
||||
return "recover";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
export function markPartyStuckRecovered(state: PartyStuckRecoveryState, position: PartyWorldPosition, nowMs: number): void {
|
||||
resetPartyStuckRecoveryProgress(state);
|
||||
state.previousPosition[0] = position[0];
|
||||
state.previousPosition[1] = position[1];
|
||||
state.previousPosition[2] = position[2];
|
||||
state.lastProgressAtMs = nowMs;
|
||||
state.cooldownUntilMs = nowMs + PARTY_STUCK_RECOVERY_COOLDOWN_MS;
|
||||
state.retryAtMs = state.cooldownUntilMs;
|
||||
}
|
||||
|
||||
/** Position-only recovery: no squad recall, healing, threat reset, or resurrection. */
|
||||
export function applyPartyStuckRecoveryLanding(
|
||||
positions: ReadonlyMap<string, MutablePartyWorldPosition>,
|
||||
memberId: string,
|
||||
landing: PartyWorldPosition,
|
||||
state: PartyStuckRecoveryState,
|
||||
nowMs: number,
|
||||
): boolean {
|
||||
const current = positions.get(memberId);
|
||||
if (!current || !finitePosition(landing) || !Number.isFinite(nowMs)) return false;
|
||||
current[0] = landing[0];
|
||||
current[1] = landing[1];
|
||||
current[2] = landing[2];
|
||||
markPartyStuckRecovered(state, current, nowMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Only visited trail positions are local candidates; never jump to an arbitrary nearest room. */
|
||||
export function choosePartyStuckRecoveryPosition({
|
||||
current, player, groupAnchor, trailPoints, validate,
|
||||
}: {
|
||||
readonly current: PartyWorldPosition;
|
||||
readonly player: PartyWorldPosition;
|
||||
readonly groupAnchor: PartyWorldPosition;
|
||||
readonly trailPoints: readonly PartyWorldPosition[];
|
||||
readonly validate: (position: PartyWorldPosition, anchor: PartyWorldPosition) => PartyWorldPosition | null;
|
||||
}): { readonly position: PartyWorldPosition; readonly source: "trail" | "player" } | null {
|
||||
if (!finitePosition(current) || !finitePosition(player) || !finitePosition(groupAnchor)) return null;
|
||||
const candidates = trailPoints.filter((point) => finitePosition(point)
|
||||
&& distance(point, current) >= MIN_RECOVERY_DISTANCE
|
||||
&& distance(point, groupAnchor) <= MAX_TRAIL_GROUP_DISTANCE
|
||||
&& Math.abs(point[1] - groupAnchor[1]) <= MAX_TRAIL_HEIGHT_DIFFERENCE)
|
||||
.sort((a, b) => distance(a, groupAnchor) - distance(b, groupAnchor));
|
||||
// Bound expensive path/physics checks even when a looping trail has many samples.
|
||||
for (const candidate of candidates.slice(0, 16)) {
|
||||
const position = validate(candidate, groupAnchor);
|
||||
if (position && finitePosition(position) && distance(position, current) >= MIN_RECOVERY_DISTANCE
|
||||
&& distance(position, candidate) <= 0.85
|
||||
&& Math.abs(position[1] - groupAnchor[1]) <= MAX_TRAIL_HEIGHT_DIFFERENCE) {
|
||||
return { position: [...position], source: "trail" };
|
||||
}
|
||||
}
|
||||
const position = validate(player, player);
|
||||
return position && finitePosition(position) && distance(position, player) <= 0.85
|
||||
? { position: [...position], source: "player" }
|
||||
: null;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { NodeIO } from "@gltf-transform/core";
|
||||
import RAPIER from "@dimforge/rapier3d-compat";
|
||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
import { BufferAttribute, BufferGeometry, Group, Mesh } from "three";
|
||||
import { BufferAttribute, BufferGeometry, Group, Mesh, Ray, Vector3 } from "three";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import {
|
||||
@@ -16,10 +16,13 @@ import { shouldLoadPartyNavigation } from "./partyNavigationPolicy";
|
||||
import { planPartyRoute } from "./partyPathing";
|
||||
import { partyCombatMovementGoal, stepPartyPosition } from "./partyMovement";
|
||||
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
||||
import { collisionTrimeshes, prepareCollision } from "../scene/productionDungeonCollision";
|
||||
import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator } from "../scene/partyTraversal";
|
||||
import { collisionTrimeshes, collisionLineOfSightTrimeshes, prepareCollision } from "../scene/productionDungeonCollision";
|
||||
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS, hasDungeonLineOfSight } from "../scene/dungeonLineOfSight";
|
||||
import { createPartyDirectSegmentValidator, createPartyRouteSegmentValidator, createPartyRecoveryPointValidator } from "../scene/partyTraversal";
|
||||
import { configurePlayerCharacterController, PLAYER_CHARACTER_CONTROLLER_OFFSET } from "./playerMovement";
|
||||
import { ACTOR_COLLISION_GROUPS, ACTOR_TERRAIN_QUERY_GROUPS } from "../scene/actorCollision";
|
||||
import { choosePartyStuckRecoveryPosition } from "./partyStuckRecovery";
|
||||
import { createMobPursuit } from "./mobPursuit";
|
||||
|
||||
const STOCKADES_NAVIGATION = "public/assets/game/manastorm/34-stormwindjail/navigation.glb";
|
||||
const TANK_RANGE = Object.freeze({ minimum: 0, preferred: 2.9, maximum: 3.15 });
|
||||
@@ -83,13 +86,20 @@ beforeAll(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
function collisionWorld(useRamps = true): RAPIER.World {
|
||||
function collisionWorld(useRamps = true, includeSight = false): RAPIER.World {
|
||||
const world = new RAPIER.World({ x: 0, y: -18, z: 0 });
|
||||
const prepared = prepareCollision(sourceCollision, useRamps ? stockades.traversalRamps : []);
|
||||
for (const trimesh of collisionTrimeshes(prepared)) {
|
||||
world.createCollider(RAPIER.ColliderDesc.trimesh(
|
||||
Float32Array.from(trimesh.vertices), Uint32Array.from(trimesh.indices),
|
||||
));
|
||||
).setCollisionGroups(DUNGEON_PHYSICAL_COLLISION_GROUPS));
|
||||
}
|
||||
if (includeSight) {
|
||||
for (const trimesh of collisionLineOfSightTrimeshes(prepared)) {
|
||||
world.createCollider(RAPIER.ColliderDesc.trimesh(
|
||||
Float32Array.from(trimesh.vertices), Uint32Array.from(trimesh.indices),
|
||||
).setCollisionGroups(DUNGEON_SIGHT_BLOCKER_COLLISION_GROUPS).setSensor(true));
|
||||
}
|
||||
}
|
||||
for (const child of prepared.children) (child as Mesh).geometry.dispose();
|
||||
world.step();
|
||||
@@ -101,6 +111,91 @@ function distance(left: readonly number[], right: readonly number[]): number {
|
||||
}
|
||||
|
||||
describe("Stormwind Stockade party navigation", () => {
|
||||
it("blocks combat sight across shipping room walls in both directions while preserving clear stair sight", () => {
|
||||
const world = collisionWorld(true, true);
|
||||
try {
|
||||
const prepared = prepareCollision(sourceCollision, stockades.traversalRamps);
|
||||
let checkedWalls = 0;
|
||||
for (const mesh of collisionLineOfSightTrimeshes(prepared)) {
|
||||
for (let offset = 0; offset < mesh.indices.length && checkedWalls < 12; offset += 3) {
|
||||
const a = Number(mesh.indices[offset]) * 3;
|
||||
const b = Number(mesh.indices[offset + 1]) * 3;
|
||||
const c = Number(mesh.indices[offset + 2]) * 3;
|
||||
const v = mesh.vertices;
|
||||
const ab = [Number(v[b]) - Number(v[a]), Number(v[b + 1]) - Number(v[a + 1]), Number(v[b + 2]) - Number(v[a + 2])];
|
||||
const ac = [Number(v[c]) - Number(v[a]), Number(v[c + 1]) - Number(v[a + 1]), Number(v[c + 2]) - Number(v[a + 2])];
|
||||
const normalX = ab[1] * ac[2] - ab[2] * ac[1];
|
||||
const normalZ = ab[0] * ac[1] - ab[1] * ac[0];
|
||||
const length = Math.hypot(normalX, normalZ);
|
||||
if (length < 0.1) continue;
|
||||
const center = [0, 1, 2].map((axis) => (Number(v[a + axis]) + Number(v[b + axis]) + Number(v[c + axis])) / 3);
|
||||
const first = findNearestPartyNavigationSurface(graph,
|
||||
[center[0] + normalX / length, center[1] - 1.05, center[2] + normalZ / length], 2.5)?.position;
|
||||
const second = findNearestPartyNavigationSurface(graph,
|
||||
[center[0] - normalX / length, center[1] - 1.05, center[2] - normalZ / length], 2.5)?.position;
|
||||
if (!first || !second || Math.abs(first[1] - second[1]) > 0.4 || distance(first, second) < 1.5
|
||||
|| Math.abs((first[1] + second[1]) / 2 + 1.05 - center[1]) > 0.15
|
||||
|| Math.hypot(first[0] - center[0], first[2] - center[2]) > 1.3
|
||||
|| Math.hypot(second[0] - center[0], second[2] - center[2]) > 1.3) continue;
|
||||
const sideA = (first[0] - center[0]) * normalX + (first[2] - center[2]) * normalZ;
|
||||
const sideB = (second[0] - center[0]) * normalX + (second[2] - center[2]) * normalZ;
|
||||
if (sideA * sideB >= 0) continue;
|
||||
// Nearby decorative faces/door-frame triangles are not room walls
|
||||
// unless the actors' chest ray actually crosses this source face.
|
||||
const origin = new Vector3(first[0], first[1] + 1.05, first[2]);
|
||||
const destination = new Vector3(second[0], second[1] + 1.05, second[2]);
|
||||
const ray = new Ray(origin, destination.clone().sub(origin).normalize());
|
||||
const intersection = ray.intersectTriangle(
|
||||
new Vector3(Number(v[a]), Number(v[a + 1]), Number(v[a + 2])),
|
||||
new Vector3(Number(v[b]), Number(v[b + 1]), Number(v[b + 2])),
|
||||
new Vector3(Number(v[c]), Number(v[c + 1]), Number(v[c + 2])), false, new Vector3(),
|
||||
);
|
||||
if (!intersection || intersection.distanceTo(origin) >= destination.distanceTo(origin)) continue;
|
||||
expect(hasDungeonLineOfSight(RAPIER, world, first, second), JSON.stringify({ center, first, second })).toBe(false);
|
||||
expect(hasDungeonLineOfSight(RAPIER, world, second, first), JSON.stringify({ center, first, second })).toBe(false);
|
||||
checkedWalls += 1;
|
||||
}
|
||||
}
|
||||
for (const child of prepared.children) (child as Mesh).geometry.dispose();
|
||||
expect(checkedWalls).toBeGreaterThanOrEqual(8);
|
||||
for (const ramp of stockades.traversalRamps ?? []) {
|
||||
expect(hasDungeonLineOfSight(RAPIER, world, ramp.start, ramp.end)).toBe(true);
|
||||
expect(hasDungeonLineOfSight(RAPIER, world, ramp.end, ramp.start)).toBe(true);
|
||||
}
|
||||
} finally { world.free(); }
|
||||
}, 20_000);
|
||||
|
||||
it("moves pursuing mobs up and down all shipping ramps without a false teleport or reset", () => {
|
||||
const world = collisionWorld(true, true);
|
||||
try {
|
||||
const directAllowed = createPartyDirectSegmentValidator(RAPIER, world);
|
||||
const routeAllowed = createPartyRouteSegmentValidator(RAPIER, world);
|
||||
const navigator = createMobPursuit({ graph: () => graph, directAllowed, routeAllowed,
|
||||
hasLineOfSight: (a, b) => hasDungeonLineOfSight(RAPIER, world, a, b),
|
||||
validateLanding: createPartyRecoveryPointValidator(RAPIER, world, graph) });
|
||||
for (const ramp of stockades.traversalRamps ?? []) {
|
||||
for (const [start, target] of [[ramp.start, ramp.end], [ramp.end, ramp.start]]) {
|
||||
const initialRoute = planPartyRoute(graph, "probe", start, target, 0, {
|
||||
maxOriginSnapDistance: 3, maxDestinationSnapDistance: 3,
|
||||
directSegmentAllowed: directAllowed, routeSegmentAllowed: routeAllowed,
|
||||
destinationReached: (surface, destination) => distance(surface, destination) <= 1.5
|
||||
&& hasDungeonLineOfSight(RAPIER, world, surface, destination),
|
||||
});
|
||||
expect(initialRoute.blocked, JSON.stringify({ ramp: ramp.id, waypoints: initialRoute.waypoints })).toBe(false);
|
||||
const position: [number, number, number] = [...start];
|
||||
for (let frame = 0; frame < 500 && distance(position, target) > 1.5; frame += 1) {
|
||||
const before = [...position];
|
||||
expect(navigator.step({ id: `${ramp.id}:${start[1]}`, targetId: "target", position,
|
||||
target, home: start, phase: "chasing", health: 100, engaged: true, active: true,
|
||||
casting: false, controlled: false, attackRange: 1.5, leashRange: 40,
|
||||
maximumStep: 0.175, nowMs: frame * 50 }), JSON.stringify({ ramp: ramp.id, start, target, before, frame })).toBe("none");
|
||||
}
|
||||
expect(distance(position, target)).toBeLessThanOrEqual(1.5);
|
||||
}
|
||||
}
|
||||
} finally { world.free(); }
|
||||
});
|
||||
|
||||
it("uses the reviewed warm indoor lighting profile", () => {
|
||||
expect(stockades.presentation).toMatchObject({
|
||||
background: "#171815",
|
||||
@@ -310,6 +405,42 @@ describe("Stormwind Stockade party navigation", () => {
|
||||
world.free();
|
||||
});
|
||||
|
||||
it("finds grounded recovery landings on all three stair ramps", () => {
|
||||
const world = collisionWorld();
|
||||
const allowed = createPartyRecoveryPointValidator(RAPIER, world, graph);
|
||||
for (const ramp of stockades.traversalRamps!) {
|
||||
const point = (t: number): [number, number, number] => [
|
||||
ramp.start[0] + (ramp.end[0] - ramp.start[0]) * t,
|
||||
ramp.start[1] + (ramp.end[1] - ramp.start[1]) * t + 0.05,
|
||||
ramp.start[2] + (ramp.end[2] - ramp.start[2]) * t,
|
||||
];
|
||||
expect(allowed(point(0.5), point(0.52)), ramp.id).not.toBeNull();
|
||||
}
|
||||
world.free();
|
||||
});
|
||||
|
||||
it("recovers from boss-room approaches onto a validated visited trail near the group", () => {
|
||||
const world = collisionWorld();
|
||||
const allowed = createPartyRecoveryPointValidator(RAPIER, world, graph);
|
||||
const routeAllowed = createPartyRouteSegmentValidator(RAPIER, world);
|
||||
const entrance = findNearestPartyNavigationSurface(graph, stockades.entrance.footPosition, 6)!.position;
|
||||
for (const objective of stockades.objectiveOrder) {
|
||||
const current = findNearestPartyNavigationSurface(graph, objective.position, 15)!.position;
|
||||
const path = findPartyNavigationPath(graph, entrance, current, { routeSegmentAllowed: routeAllowed })!;
|
||||
const trail = path.slice(1).map((point, index): [number, number, number] => [
|
||||
(path[index][0] + point[0]) / 2, (path[index][1] + point[1]) / 2, (path[index][2] + point[2]) / 2,
|
||||
]).filter((point) => distance(point, current) >= 2 && distance(point, current) <= 12
|
||||
&& Math.abs(point[1] - current[1]) <= 1.25 && allowed(point, point));
|
||||
expect(trail.length, objective.name).toBeGreaterThan(0);
|
||||
const anchor = trail.at(-1)!;
|
||||
const landing = choosePartyStuckRecoveryPosition({ current, player: entrance, groupAnchor: anchor,
|
||||
trailPoints: trail, validate: allowed });
|
||||
expect(landing?.source, objective.name).toBe("trail");
|
||||
expect(allowed(landing!.position, anchor), objective.name).not.toBeNull();
|
||||
}
|
||||
world.free();
|
||||
});
|
||||
|
||||
it("walks the player capsule down and back up every ramp without jumping", () => {
|
||||
for (const ramp of stockades.traversalRamps!) {
|
||||
const world = collisionWorld();
|
||||
|
||||
Reference in new issue
Block a user