forked from phenom/healer-man
Release Healer Man 0.1.29
This commit is contained in:
1 parent
8c9809d3e0
commit
544c1bee91
120 files changed
+4363
-1036
No files matched your search
@@ -0,0 +1,38 @@
|
||||
import { build } from "esbuild";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
|
||||
// A private copy of the existing engine keeps dedicated matches and offline
|
||||
// practice isolated from the React player's stores. No second spell interpreter.
|
||||
await build({
|
||||
entryPoints: ["scripts/arena-pipeline/combat-runtime.ts"],
|
||||
outfile: "server/generated/arena-combat-runtime.mjs",
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "neutral",
|
||||
mainFields: ["module", "main"],
|
||||
target: "es2022",
|
||||
// Escape catalog line breaks in string literals, preserving tooltip text
|
||||
// without generating source lines that end in whitespace.
|
||||
supported: { "template-literal": false },
|
||||
minify: true,
|
||||
define: { "import.meta.env.MODE": '"test"', "import.meta.env.DEV": "false", "import.meta.env.BASE_URL": '"/"', "process.env.NODE_ENV": '"production"' },
|
||||
plugins: [{
|
||||
name: "arena-engine-host",
|
||||
setup(builder) {
|
||||
builder.onLoad({ filter: /coaAbilityCatalogLoader\.ts$/ }, ({ path }) => {
|
||||
const paths = readdirSync(resolve(dirname(path), "generated/coaAbilities")).filter((name) => name.endsWith(".json"))
|
||||
.map((name) => `./generated/coaAbilities/${name}`);
|
||||
return { contents: readFileSync(path, "utf8").replace(/import\.meta\.glob<[^>]+>\([^;]+\)/, `{${paths.map((name) => `${JSON.stringify(name)}: () => import(${JSON.stringify(name)})`).join(",")}}`), loader: "ts" };
|
||||
});
|
||||
builder.onResolve({ filter: /^zustand$/ }, () => ({ path: resolve("scripts/arena-pipeline/runtime-store.ts") }));
|
||||
builder.onResolve({ filter: /^\.\/store$/ }, ({ importer }) => importer.endsWith("combatStore.ts") ? ({ path: "game-store", namespace: "arena-host" }) : undefined);
|
||||
builder.onResolve({ filter: /^\.\/dungeonEncounterRuntime$/ }, ({ importer }) => importer.endsWith("combatStore.ts") ? ({ path: "encounter", namespace: "arena-host" }) : undefined);
|
||||
builder.onResolve({ filter: /^\.\/onlineSessionRuntime$/ }, ({ importer }) => importer.endsWith("combatStore.ts") ? ({ path: "online", namespace: "arena-host" }) : undefined);
|
||||
builder.onLoad({ filter: /.*/, namespace: "arena-host" }, ({ path }) => ({ contents: path === "game-store"
|
||||
? 'export const useGameStore = { getState: () => ({ gameMode: "arena" }) };'
|
||||
: path === "online" ? 'export const onlinePlayerRuntimeActors = () => []; export const onlinePlayerRuntimeActor = () => null;'
|
||||
: 'export const dispatchDungeonEncounterSignal = () => {}; export const dungeonEncounterStateMatches = () => false;', loader: "js" }));
|
||||
},
|
||||
}],
|
||||
});
|
||||
@@ -28,9 +28,8 @@ const DEFINITIONS = Object.freeze([
|
||||
primaryPattern: /7vs_blackrookhold_arena\.wmo$/i,
|
||||
alignmentOffset: [117.542488, -102.29949, -224.463489],
|
||||
legacyCollision: "public/assets/game/manastorm/1784-blackrookholdarena/collision.glb",
|
||||
gameplayBounds: { min: [-82, -10, -242], max: [312, 30, -110] },
|
||||
legacyNavigation: "public/assets/game/manastorm/1784-blackrookholdarena/navigation.glb",
|
||||
teamSpawns: [[-66.098564, 8.416428, -201.852448], [123.158585, 9.916428, -219.909592]],
|
||||
gameplayBounds: { min: [-37, -32, -286], max: [126, 20, -138] },
|
||||
teamSpawns: [[-35.043, -28.317, -204.435], [108.031, -28.317, -205.573]],
|
||||
},
|
||||
{
|
||||
slug: "ashamanes-fall",
|
||||
@@ -273,11 +272,8 @@ async function build(definition) {
|
||||
reportFile: path.join(outputDirectory, "recast-report.json"),
|
||||
source: { kind: "adt-placed-wmo-repair", adtFiles, placedWmos: sources, legacyCollision: definition.legacyCollision, gameplayBounds: definition.gameplayBounds },
|
||||
});
|
||||
const selectionSource = definition.legacyNavigation
|
||||
? path.join(projectRoot, ...definition.legacyNavigation.split("/"))
|
||||
: rawNavigationFile;
|
||||
const connectedNavigation = await extractNavigationComponent({
|
||||
source: selectionSource,
|
||||
source: rawNavigationFile,
|
||||
output: navigationFile,
|
||||
teamSpawns: definition.teamSpawns,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NodeIO } from "@gltf-transform/core";
|
||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||
import { MeshoptDecoder } from "meshoptimizer";
|
||||
import { Matrix4, Vector3 } from "three";
|
||||
import { writeFileSync, readdirSync } from "node:fs";
|
||||
import catalog from "../../shared/arena-stages.json" with { type: "json" };
|
||||
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ "meshopt.decoder": MeshoptDecoder });
|
||||
const stages = {};
|
||||
for (const stage of catalog.stages) {
|
||||
stages[stage.id] = {};
|
||||
for (const kind of ["navigation", "collision"]) {
|
||||
const triangles = [];
|
||||
const files = kind === "collision" ? readdirSync(`public${stage.assetBaseUrl}`).filter((name) => /^collision(?:-\d+)?\.glb$/.test(name)) : ["navigation.glb"];
|
||||
for (const file of files) {
|
||||
const document = await io.read(`public${stage.assetBaseUrl}/${file}`);
|
||||
for (const node of document.getRoot().listNodes()) {
|
||||
const mesh = node.getMesh();
|
||||
if (!mesh) continue;
|
||||
const matrix = new Matrix4().fromArray(node.getWorldMatrix());
|
||||
for (const primitive of mesh.listPrimitives()) {
|
||||
const positions = primitive.getAttribute("POSITION");
|
||||
if (!positions) continue;
|
||||
const indices = primitive.getIndices()?.getArray() ?? Array.from({ length: positions.getCount() }, (_, index) => index);
|
||||
for (let offset = 0; offset < indices.length; offset += 3) {
|
||||
const triangle = Array.from({ length: 3 }, (_, corner) => new Vector3().fromArray(positions.getElement(indices[offset + corner], [])).applyMatrix4(matrix).toArray());
|
||||
if ([0, 2].some((axis) => triangle.every((point) => point[axis] < stage.bounds.min[axis] - 3) || triangle.every((point) => point[axis] > stage.bounds.max[axis] + 3))) continue;
|
||||
triangles.push(...triangle.flat().map((value) => Math.round(value * 1000) / 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stages[stage.id][kind] = triangles;
|
||||
}
|
||||
console.log(`${stage.id}: ${stages[stage.id].navigation.length / 9} navigation, ${stages[stage.id].collision.length / 9} collision triangles`);
|
||||
}
|
||||
writeFileSync("server/generated/arena-geometry.json", `${JSON.stringify(stages)}\n`);
|
||||
writeFileSync("server/generated/arena-navigation.json", `${JSON.stringify(Object.fromEntries(Object.entries(stages).map(([id, stage]) => [id, stage.navigation])))}\n`);
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useCombatStore, PLAYER_AURA_ENTITY_ID, characterCombatDefender, abilityDamageSchool, type CombatState, type CharacterCombatSeed } from "../../src/game/combatStore";
|
||||
import { usePartyStore } from "../../src/game/partyStore";
|
||||
import { installCombatClock } from "../../src/game/combatClock";
|
||||
import { registerCombatSpatialProvider } from "../../src/game/combatSpatial";
|
||||
import { clearMobRuntimeRegistry, registerMobRuntime, getMobPosition } from "../../src/game/mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, getPartyRuntimePosition } from "../../src/game/partyRuntimeRegistry";
|
||||
import { targetingRulesForAbility } from "../../src/game/abilityRules";
|
||||
import { resourceProfileForClass, abilityAtLevel, isAbilityUnlocked } from "../../src/game/abilityCatalog";
|
||||
import { deriveCharacterStats } from "../../src/game/wotlkStats";
|
||||
import { subscribeCombatPresentation } from "../../src/game/combatPresentation";
|
||||
import { arenaDampening } from "../../server/arena-rules.mjs";
|
||||
import { createDefaultAppearance } from "../../src/app/characterCatalog";
|
||||
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "../../src/game/equipment";
|
||||
|
||||
const PLAYER = PLAYER_AURA_ENTITY_ID;
|
||||
const combatDefaults = useCombatStore.getState();
|
||||
const partyDefaults = usePartyStore.getState();
|
||||
let presentation: any[] = [];
|
||||
subscribeCombatPresentation((message) => { if (message.kind === "event") presentation.push(message.event); });
|
||||
|
||||
const remap = (id: string | null | undefined, from: string, to: string) => id === from ? to : id;
|
||||
const mapAura = (aura: any, from: string, to: string) => ({ ...aura, sourceId: remap(aura.sourceId, from, to), targetId: remap(aura.targetId, from, to) });
|
||||
const mapEffect = (effect: any, from: string, to: string) => ({ ...effect, sourceActorId: remap(effect.sourceActorId, from, to), targetId: remap(effect.targetId, from, to) });
|
||||
|
||||
export function initializeArenaCharacter(actor: any, now: number) {
|
||||
if (actor.combat) return;
|
||||
const kit = actor.characterKit;
|
||||
const seed: CharacterCombatSeed = kit?.seed ?? { classId: kit?.classId ?? (actor.role === "healer" ? "priest" : "warrior"), level: kit?.level ?? 80, actionBindings: kit?.actionBindings };
|
||||
const restore = installCombatClock(() => now);
|
||||
try {
|
||||
usePartyStore.setState({ ...partyDefaults, members: [], playerRole: actor.role === "healer" ? "healer" : "damage" }, true);
|
||||
useCombatStore.setState(combatDefaults, true);
|
||||
useCombatStore.getState().initializeCharacter(seed);
|
||||
actor.combat = useCombatStore.getState();
|
||||
const state = actor.combat;
|
||||
actor.avatar = { classId: state.classId, raceId: seed.raceId ?? (actor.team === 1 ? "orc" : "human"),
|
||||
gender: (seed as any).gender ?? "male", appearance: (seed as any).appearance ?? createDefaultAppearance() };
|
||||
actor.visualEquipment = equipmentItemsForOwner(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID);
|
||||
const abilities = state.abilities.filter((ability: any) => !ability.passive && isAbilityUnlocked(ability, state.level)
|
||||
&& (ability.talentEntryId === undefined || (state.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0))
|
||||
.map((ability: any) => { const resolved = abilityAtLevel(ability, state.level, ability.talentEntryId === undefined ? undefined : state.talentRanks[`coa-entry-${ability.talentEntryId}`]); return { ...resolved, targeting: targetingRulesForAbility(resolved) }; });
|
||||
actor.characterKit = { ...kit, seed, classId: state.classId, level: state.level,
|
||||
resource: { ...resourceProfileForClass(state.classId), maximum: state.maxResource, initial: state.resource },
|
||||
abilities: Object.fromEntries(abilities.map((ability: any) => [ability.id, ability])), actionBindings: state.actionBindings };
|
||||
actor.health = state.health;
|
||||
actor.maxHealth = state.maxHealth;
|
||||
actor.resource = state.resource;
|
||||
actor.maxResource = state.maxResource;
|
||||
actor.auras = state.auras.map((aura: any) => mapAura(aura, PLAYER, actor.id));
|
||||
actor.statuses = [];
|
||||
} finally { restore(); }
|
||||
}
|
||||
|
||||
export function arenaKitFromSeed(seed: CharacterCombatSeed) {
|
||||
const actor: any = { id: "kit", role: seed.role ?? "damage", characterKit: { seed } };
|
||||
initializeArenaCharacter(actor, 0);
|
||||
return { ...actor.characterKit, stats: { ...deriveCharacterStats(actor.combat.classId, actor.combat.level, actor.combat.gearStats, actor.combat.raceId, {}, actor.combat.secondaryClassId), health: actor.maxHealth } };
|
||||
}
|
||||
|
||||
function partyMember(actor: any) {
|
||||
const state = actor.combat;
|
||||
return { id: actor.id, name: actor.name, classId: state.classId, raceId: state.raceId, gender: "male", appearance: {},
|
||||
specialization: "", specializationId: state.specializationId, talentTreeId: "", talentRanks: state.talentRanks,
|
||||
role: actor.role === "healer" ? "healer" : "damage", level: state.level, health: actor.health, maxHealth: actor.maxHealth,
|
||||
resourceType: actor.characterKit.resource.type, resourceName: state.resourceName, resource: actor.resource, maxResource: actor.maxResource,
|
||||
resourcePools: state.resourcePools, lastResourceSpentAt: state.lastResourceSpentAt, cooldowns: state.cooldowns,
|
||||
globalCooldownEndsAt: state.globalCooldownEndsAt, activeCast: state.activeCast, activeCastTargetId: actor.targetActorId,
|
||||
schoolLockedUntil: state.schoolLockedUntil, runes: state.deathKnightRunes, gearStats: state.gearStats,
|
||||
status: actor.alive ? "ready" : "down", nextActionAt: 0, tauntReadyAt: 0,
|
||||
controlledUntil: state.controlledUntil, controlMechanic: state.controlMechanic };
|
||||
}
|
||||
|
||||
function enter(simulation: any, actor: any, now: number, ticking: boolean) {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
presentation = [];
|
||||
const allies = [...simulation.actors.values()].filter((other: any) => other !== actor && other.team === actor.team);
|
||||
const friendlyPets = allies.flatMap((owner: any) => owner.combat.summons.map((summon: any) => ({ owner, summon })));
|
||||
const members = [...allies.map(partyMember), ...friendlyPets.map(({ owner, summon }: any) => ({ ...partyMember(owner),
|
||||
id: summon.id, name: summon.name, health: summon.health, maxHealth: summon.maxHealth,
|
||||
resource: 0, maxResource: 0, activeCast: null, status: summon.health > 0 ? "ready" : "down" }))];
|
||||
usePartyStore.setState({ ...partyDefaults, members, playerRole: actor.role === "healer" ? "healer" : "damage",
|
||||
selectedMemberId: allies.some((other: any) => other.id === actor.targetActorId) ? actor.targetActorId : null,
|
||||
selfSelected: actor.targetActorId === actor.id }, true);
|
||||
const effects = simulation.combatEffects ?? [];
|
||||
const auras = [...simulation.actors.values()].flatMap((other: any) => other.auras).concat(simulation.summonAuras ?? []).map((aura: any) => mapAura(aura, actor.id, PLAYER));
|
||||
useCombatStore.setState({ ...actor.combat, health: actor.health, maxHealth: actor.maxHealth,
|
||||
resource: actor.resource, maxResource: actor.maxResource, playerPosition: [...actor.position],
|
||||
selectedTargetId: simulation.targetActor(actor.targetActorId)?.team !== actor.team ? actor.targetActorId : null,
|
||||
selectedFriendlyActorId: actor.targetActorId === actor.id ? PLAYER : simulation.targetActor(actor.targetActorId)?.team === actor.team ? actor.targetActorId : null,
|
||||
auras, effects: effects.filter((effect: any) => !ticking || effect.sourceActorId === actor.id).map((effect: any) => mapEffect(effect, actor.id, PLAYER)),
|
||||
mobs: {}, actors: {}, combatEvents: [], settings: { ...actor.combat.settings, autoTargetNearest: false } }, true);
|
||||
for (const other of simulation.actors.values()) {
|
||||
if (other === actor) continue;
|
||||
if (other.team === actor.team) { registerPartyRuntimePosition(other.id, other.position); continue; }
|
||||
registerMobRuntime(other.id, other.position, other.facing);
|
||||
useCombatStore.getState().registerMob(other.id, { name: other.name, level: other.combat.level, maxHealth: other.maxHealth, xpReward: 0 });
|
||||
const stats = deriveCharacterStats(other.combat.classId, other.combat.level, other.combat.gearStats, other.combat.raceId, {}, other.combat.secondaryClassId);
|
||||
useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [other.id]: { ...state.mobs[other.id],
|
||||
health: other.health, maxHealth: other.maxHealth, dead: !other.alive, armor: stats.armor, maxMana: other.maxResource,
|
||||
characterDefender: characterCombatDefender({ ...other.combat, auras: other.auras.map((aura: any) => mapAura(aura, other.id, PLAYER)) }),
|
||||
characterShield: other.combat.shield, characterResources: other.combat.resourcePools,
|
||||
statuses: other.statuses ?? [], controlDiminishingReturns: other.controlDiminishingReturns ?? {},
|
||||
mechanicImmunities: other.auras.flatMap((aura: any) => aura.definition.controlImmunities ?? []),
|
||||
activeCast: other.combat.activeCast ? { ...other.combat.activeCast, attackId: other.combat.activeCast.abilityId, interruptible: true } : null,
|
||||
schoolLockedUntil: other.combat.schoolLockedUntil, engaged: true, experienceGranted: true,
|
||||
resistances: { arcane: stats.arcaneResistance, fire: stats.fireResistance, frost: stats.frostResistance, nature: stats.natureResistance, shadow: stats.shadowResistance, holy: 0 },
|
||||
} } }));
|
||||
}
|
||||
for (const owner of simulation.actors.values()) for (const summon of owner.combat.summons) {
|
||||
if (owner.team === actor.team) {
|
||||
if (owner !== actor) registerPartyRuntimePosition(summon.id, summon.position);
|
||||
continue;
|
||||
}
|
||||
registerMobRuntime(summon.id, summon.position, owner.facing);
|
||||
useCombatStore.getState().registerMob(summon.id, { name: summon.name, level: owner.combat.level, maxHealth: summon.maxHealth, xpReward: 0 });
|
||||
useCombatStore.setState((state) => ({ mobs: { ...state.mobs, [summon.id]: { ...state.mobs[summon.id], health: summon.health, dead: summon.health <= 0, experienceGranted: true } } }));
|
||||
}
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const restoreClock = installCombatClock(() => now);
|
||||
const restoreSpatial = registerCombatSpatialProvider({
|
||||
actor: (id) => { const target = id === PLAYER ? actor : simulation.targetActor(id); return target ? { id, position: target.position, yaw: target.facing } : null; },
|
||||
hasLineOfSight: (from, to) => simulation.hasLineOfSight({ position: from }, { position: to }),
|
||||
projectGroundPoint: (requested, range, origin = actor.position) => simulation.projectGroundPoint(origin, requested, range),
|
||||
});
|
||||
return () => { restoreSpatial(); restoreClock(); };
|
||||
}
|
||||
|
||||
function commit(simulation: any, actor: any, now: number, ticking: boolean) {
|
||||
const state = useCombatStore.getState();
|
||||
const dampening = simulation.activeStartedAt == null ? 0 : arenaDampening(now - simulation.activeStartedAt);
|
||||
const healed = new Map<string, number>();
|
||||
const damaged = new Map<string, number>();
|
||||
const previous = new Map<string, { health: number; maxHealth: number }>([...simulation.actors.values()].map((other: any) => [other.id, { health: other.health, maxHealth: other.maxHealth }]));
|
||||
for (const event of state.combatEvents) if (event.kind === "healing") {
|
||||
const id = remap(event.targetActorId, PLAYER, actor.id)!;
|
||||
healed.set(id, (healed.get(id) ?? 0) + event.effectiveAmount);
|
||||
}
|
||||
for (const event of state.combatEvents) if (event.kind === "damage") {
|
||||
const id = remap(event.targetActorId, PLAYER, actor.id)!;
|
||||
damaged.set(id, (damaged.get(id) ?? 0) + event.effectiveAmount);
|
||||
}
|
||||
const healthAfterHealing = (id: string, health: number, maxHealth = previous.get(id)?.maxHealth ?? health) => {
|
||||
const before = previous.get(id);
|
||||
const inferred = before ? Math.max(0, health - before.health + (damaged.get(id) ?? 0) - Math.max(0, maxHealth - before.maxHealth)) : 0;
|
||||
return Math.max(0, health - Math.round(Math.max(healed.get(id) ?? 0, inferred) * dampening));
|
||||
};
|
||||
actor.combat = { ...state, mobs: {}, actors: {}, effects: [], auras: [], health: actor.health <= 0 ? 0 : healthAfterHealing(actor.id, state.health, state.maxHealth) };
|
||||
actor.health = actor.combat.health;
|
||||
actor.maxHealth = state.maxHealth;
|
||||
actor.resource = state.resource;
|
||||
actor.maxResource = state.maxResource;
|
||||
actor.position = state.playerPosition.some((value, axis) => value !== actor.position[axis])
|
||||
? simulation.resolvePosition(actor, state.playerPosition, true) : [...actor.position];
|
||||
actor.cooldowns = new Map(Object.entries(state.cooldowns));
|
||||
actor.gcdUntil = state.globalCooldownEndsAt;
|
||||
actor.casting = state.activeCast ? { ...state.activeCast, targetActorId: remap(state.activeCast.targetId, PLAYER, actor.id) } : null;
|
||||
actor.statuses = (actor.statuses ?? []).filter((status: any) => status.endsAt > now && !(state.controlledUntil === 0 && ["stun", "fear", "sleep", "freeze", "knockdown"].includes(status.kind)));
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
const other = simulation.actor(member.id);
|
||||
if (!other) {
|
||||
for (const owner of simulation.actors.values()) if (owner !== actor && owner.combat.summons.some((summon: any) => summon.id === member.id)) {
|
||||
owner.combat = { ...owner.combat, summons: owner.combat.summons.map((summon: any) => summon.id === member.id ? { ...summon, health: healthAfterHealing(member.id, member.health) } : summon) };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
other.health = healthAfterHealing(other.id, member.health, member.maxHealth);
|
||||
other.maxHealth = member.maxHealth;
|
||||
other.resource = member.resource;
|
||||
const moved = getPartyRuntimePosition(member.id);
|
||||
if (moved && moved.some((value, axis) => value !== other.position[axis])) other.position = simulation.resolvePosition(other, moved, true);
|
||||
other.combat = { ...other.combat, resourcePools: member.resourcePools, schoolLockedUntil: member.schoolLockedUntil,
|
||||
controlledUntil: member.controlledUntil, controlMechanic: member.controlMechanic, deathKnightRunes: member.runes };
|
||||
}
|
||||
for (const mob of Object.values(state.mobs)) {
|
||||
const other = simulation.actor(mob.id);
|
||||
if (!other) {
|
||||
for (const owner of simulation.actors.values()) if (owner !== actor && owner.combat.summons.some((summon: any) => summon.id === mob.id)) {
|
||||
owner.combat = { ...owner.combat, summons: owner.combat.summons.map((summon: any) => summon.id === mob.id ? { ...summon, health: mob.health } : summon).filter((summon: any) => summon.health > 0) };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
other.health = mob.health;
|
||||
other.statuses = mob.statuses;
|
||||
other.controlDiminishingReturns = mob.controlDiminishingReturns;
|
||||
const moved = getMobPosition(mob.id);
|
||||
if (moved && moved.some((value, axis) => value !== other.position[axis])) other.position = simulation.resolvePosition(other, moved, true);
|
||||
const control = mob.statuses.filter((status) => ["stun", "fear", "confuse", "blind", "charm", "freeze", "knockdown", "sleep"].includes(status.kind)).sort((a, b) => b.endsAt - a.endsAt)[0];
|
||||
other.combat = { ...other.combat, controlledUntil: control?.endsAt ?? 0, controlMechanic: control?.kind ?? null,
|
||||
shield: mob.characterShield ?? other.combat.shield, resourcePools: mob.characterResources ?? other.combat.resourcePools,
|
||||
schoolLockedUntil: mob.schoolLockedUntil, activeCast: mob.activeCast ? { ...other.combat.activeCast, ...mob.activeCast } : null };
|
||||
other.resource = other.combat.resourcePools[other.characterKit.resource.type] ?? other.resource;
|
||||
other.casting = other.combat.activeCast;
|
||||
}
|
||||
const worldAuras = state.auras.map((aura) => mapAura(aura, PLAYER, actor.id));
|
||||
simulation.summonAuras = worldAuras.filter((aura) => !simulation.actors.has(aura.targetId));
|
||||
for (const other of simulation.actors.values()) {
|
||||
other.auras = worldAuras.filter((aura) => aura.targetId === other.id);
|
||||
other.statuses = (other.statuses ?? []).filter((status: any) => status.endsAt > now
|
||||
&& (!status.sourceAbilityId.startsWith("aura:") || other.auras.some((aura: any) => `aura:${aura.definition.id}` === status.sourceAbilityId))
|
||||
&& !other.auras.some((aura: any) => aura.definition.controlImmunities?.includes(status.kind)));
|
||||
const control = other.statuses.filter((status: any) => ["stun", "fear", "confuse", "blind", "charm", "freeze", "knockdown", "sleep"].includes(status.kind)).sort((a: any, b: any) => b.endsAt - a.endsAt)[0];
|
||||
other.combat = { ...other.combat, controlledUntil: control?.endsAt ?? 0, controlMechanic: control?.kind ?? null };
|
||||
other.alive = other.health > 0;
|
||||
if (!other.alive) { other.movement = [0, 0]; other.casting = null; other.combat = { ...other.combat, activeCast: null }; }
|
||||
}
|
||||
simulation.combatEffects = [
|
||||
...(ticking ? (simulation.combatEffects ?? []).filter((effect: any) => effect.sourceActorId !== actor.id) : []),
|
||||
...state.effects.map((effect) => mapEffect(effect, PLAYER, actor.id)),
|
||||
];
|
||||
simulation.combatPresentation = [...(simulation.combatPresentation ?? []), ...presentation.map((event) => ({ ...event,
|
||||
sourceActorId: remap(event.sourceActorId, PLAYER, actor.id), targetActorId: remap(event.targetActorId, PLAYER, actor.id) }))].slice(-80);
|
||||
simulation.combatEvents = [...(simulation.combatEvents ?? []), ...state.combatEvents.map((event) => ({ ...event,
|
||||
sourceActorId: remap(event.sourceActorId, PLAYER, actor.id), targetActorId: remap(event.targetActorId, PLAYER, actor.id) }))].slice(-100);
|
||||
}
|
||||
|
||||
export function useArenaCharacterAbility(simulation: any, actor: any, abilityId: string, targetActorId: string | null, now: number, groundPosition?: readonly number[]) {
|
||||
const ability = actor.characterKit.abilities[abilityId];
|
||||
if (ability && (actor.statuses ?? []).some((status: any) => status.kind === "silence" && status.endsAt > now)
|
||||
&& (ability.castRules?.school ?? abilityDamageSchool(actor.combat.classId, ability)) !== "physical") return { accepted: false, reason: "controlled", message: "You are silenced." };
|
||||
const restore = enter(simulation, actor, now, false);
|
||||
try {
|
||||
const requestedTarget = targetActorId ?? actor.targetActorId;
|
||||
if (requestedTarget === actor.id) {
|
||||
useCombatStore.setState({ selectedFriendlyActorId: PLAYER, selectedTargetId: null });
|
||||
usePartyStore.setState({ selfSelected: true, selectedMemberId: null });
|
||||
}
|
||||
const result = useCombatStore.getState().castAbility(abilityId, { now, origin: actor.position,
|
||||
targetActorId: requestedTarget === actor.id ? null : requestedTarget, groundPosition: groundPosition as any });
|
||||
const message = useCombatStore.getState().feedback?.message;
|
||||
commit(simulation, actor, now, false);
|
||||
if (result.ok) { actor.lastAbilityId = abilityId; actor.abilityRevision += 1; }
|
||||
return { accepted: result.ok, reason: result.reason, message, casting: Boolean(actor.casting) };
|
||||
} finally { restore(); }
|
||||
}
|
||||
|
||||
export function tickArenaCharacter(simulation: any, actor: any, now: number, delta: number) {
|
||||
const restore = enter(simulation, actor, now, true);
|
||||
try {
|
||||
if (actor.casting && !actor.casting.canCastWhileMoving && Math.hypot(...actor.movement) > .01) useCombatStore.getState().cancelCast("movement");
|
||||
useCombatStore.getState().tick(delta, now);
|
||||
commit(simulation, actor, now, true);
|
||||
} finally { restore(); }
|
||||
}
|
||||
|
||||
export function arenaCharacterSnapshot(actor: any, now: number) {
|
||||
const state: CombatState = actor.combat;
|
||||
return { resourcePools: state.resourcePools, maxResourcePools: state.maxResourcePools, runes: state.deathKnightRunes,
|
||||
talentRanks: state.talentRanks, gearStats: state.gearStats, specializationId: state.specializationId,
|
||||
comboPoints: state.comboPoints, comboTargetId: state.comboTargetId, shield: state.shield,
|
||||
activeCast: state.activeCast, playerAnimationEvent: state.playerAnimationEvent, summons: state.summons.map((summon) => ({ ...summon, ownerActorId: actor.id })),
|
||||
areaEffects: state.areaEffects.map((area) => ({ ...area, sourceActorId: actor.id })),
|
||||
controlledUntil: state.controlledUntil, schoolLockedUntil: state.schoolLockedUntil, now };
|
||||
}
|
||||
|
||||
export function damageArenaCharacter(simulation: any, actor: any, amount: number, school: any, now: number) {
|
||||
const restore = enter(simulation, actor, now, false);
|
||||
try {
|
||||
useCombatStore.getState().damagePlayer(amount, school);
|
||||
commit(simulation, actor, now, false);
|
||||
} finally { restore(); }
|
||||
}
|
||||
|
||||
export function commandArenaCharacter(simulation: any, actor: any, command: string, now: number) {
|
||||
if (command === "cancel-cast") {
|
||||
const restore = enter(simulation, actor, now, false);
|
||||
try { useCombatStore.getState().cancelCast(); commit(simulation, actor, now, false); } finally { restore(); }
|
||||
}
|
||||
if (command === "attack" || command === "defend" || command === "stop" || command === "recall") {
|
||||
const targetId = command === "attack" ? actor.targetActorId : null;
|
||||
actor.combat = { ...actor.combat, autoAttackTargetId: targetId, summons: actor.combat.summons.map((summon: any) => ({ ...summon,
|
||||
targetId, ...(command === "recall" ? { position: [...actor.position] } : {}), nextActionAt: command === "stop" ? Number.MAX_SAFE_INTEGER : now })) };
|
||||
}
|
||||
}
|
||||
|
||||
export function bindArenaCharacter(actor: any, bindings: Record<string, string | null>) {
|
||||
const next = Object.fromEntries(Object.keys(actor.characterKit.actionBindings).map((id) => [id,
|
||||
bindings[id] && actor.characterKit.abilities[bindings[id]!] ? bindings[id] : null]));
|
||||
actor.characterKit = { ...actor.characterKit, actionBindings: next };
|
||||
actor.combat = { ...actor.combat, actionBindings: next };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Same vanilla store contract used by Zustand, without a React dependency on
|
||||
// the dedicated server. Each arena operation restores its own saved state.
|
||||
export function create<T>(initialize: (set: any, get: () => T, api: any) => T) {
|
||||
let state: T;
|
||||
const listeners = new Set<(state: T, previous: T) => void>();
|
||||
const api = {
|
||||
getState: () => state,
|
||||
setState(update: any, replace = false) {
|
||||
const previous = state;
|
||||
const patch = typeof update === "function" ? update(state) : update;
|
||||
state = replace ? patch : Object.assign({}, state, patch);
|
||||
if (state !== previous) for (const listener of listeners) listener(state, previous);
|
||||
},
|
||||
subscribe(listener: (state: T, previous: T) => void) { listeners.add(listener); return () => listeners.delete(listener); },
|
||||
};
|
||||
state = initialize(api.setState, api.getState, api);
|
||||
return api;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { gzip, gunzip } from "node:zlib";
|
||||
|
||||
const compress = promisify(gzip);
|
||||
const decompress = promisify(gunzip);
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
/** Original hashes remain the runtime contract; gzip is only a storage representation. */
|
||||
export async function prepareContentObjects(files, { directory, baseUrl, keepOriginal = false }) {
|
||||
await mkdir(directory, { recursive: true });
|
||||
const unique = new Map();
|
||||
for (const file of files) unique.set(`${file.sha256}${file.extension}`, file);
|
||||
const objects = [...unique.entries()];
|
||||
const prepared = new Map();
|
||||
let next = 0;
|
||||
let writtenObjects = 0;
|
||||
const writeVerified = async (name, bytes) => {
|
||||
const destination = path.join(directory, name);
|
||||
try {
|
||||
const existing = await readFile(destination);
|
||||
if (!existing.equals(bytes)) throw new Error(`Published object differs from its hash: ${destination}`);
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
await writeFile(destination, bytes, { flag: "wx" });
|
||||
writtenObjects++;
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: 4 }, async () => {
|
||||
while (next < objects.length) {
|
||||
const [name, file] = objects[next++];
|
||||
const original = await readFile(file.sourcePath);
|
||||
if (original.length !== file.bytes || digest(original) !== file.sha256) {
|
||||
throw new Error(`Asset changed during packaging: ${file.logicalPath}`);
|
||||
}
|
||||
const compressed = await compress(original, { level: 6 });
|
||||
const useful = compressed.length + 64 < original.length;
|
||||
let download;
|
||||
if (useful) {
|
||||
if (!(await decompress(compressed)).equals(original)) throw new Error(`Gzip round trip failed: ${file.logicalPath}`);
|
||||
const compressedHash = digest(compressed);
|
||||
// AAPT expands and renames .gz assets even with noCompress. Keep gzip
|
||||
// bytes under a neutral suffix so APK and browser catalogs stay identical.
|
||||
const compressedName = `${compressedHash}${file.extension}.gzip`;
|
||||
await writeVerified(compressedName, compressed);
|
||||
download = { url: `${baseUrl}/${compressedName}`, encoding: "gzip", sha256: compressedHash, bytes: compressed.length };
|
||||
}
|
||||
if (keepOriginal || !useful) await writeVerified(name, original);
|
||||
prepared.set(name, { objectUrl: `${baseUrl}/${name}`, ...(download ? { download } : {}), storedBytes: useful ? compressed.length : original.length });
|
||||
}
|
||||
}));
|
||||
return {
|
||||
files: files.map((file) => ({ ...file, ...prepared.get(`${file.sha256}${file.extension}`) })),
|
||||
writtenObjects,
|
||||
uniqueObjects: objects.length,
|
||||
logicalBytes: files.reduce((sum, file) => sum + file.bytes, 0),
|
||||
uniqueBytes: objects.reduce((sum, [, file]) => sum + file.bytes, 0),
|
||||
storedBytes: [...prepared.values()].reduce((sum, object) => sum + object.storedBytes, 0),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { prepareContentObjects } from "./content-objects.mjs";
|
||||
import { selectBootstrapContent } from "./prepare-bootstrap.mjs";
|
||||
import { createContentManifest } from "./publish-content.mjs";
|
||||
|
||||
test("ships one lossless object for identical assets and verifies original bytes", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "healer-content-compression-"));
|
||||
try {
|
||||
const sourcePath = path.join(directory, "original.glb");
|
||||
const original = Buffer.from("original geometry, textures, and animation\n".repeat(4096));
|
||||
await writeFile(sourcePath, original);
|
||||
const common = { sourcePath, bytes: original.length, sha256: createHash("sha256").update(original).digest("hex"), extension: ".glb" };
|
||||
const files = ["first", "second"].map((id) => ({ ...common, logicalPath: `assets/creatures/${id}/model.glb`, packId: `dungeon:${id}` }));
|
||||
const result = await prepareContentObjects(files, { directory: path.join(directory, "objects"), baseUrl: "/content/bundled" });
|
||||
assert.equal(result.uniqueObjects, 1);
|
||||
assert.equal(result.uniqueBytes, original.length);
|
||||
assert.equal(result.logicalBytes, original.length * 2);
|
||||
assert.ok(result.storedBytes < original.length / 10);
|
||||
assert.equal((await readdir(path.join(directory, "objects"))).length, 1);
|
||||
assert.deepEqual(result.files[0].download, result.files[1].download);
|
||||
assert.ok(result.files[0].download.url.endsWith(".gzip"), "avoid Android's automatic .gz expansion");
|
||||
const stored = await readFile(path.join(directory, "objects", path.basename(result.files[0].download.url)));
|
||||
assert.deepEqual(gunzipSync(stored), original);
|
||||
assert.deepEqual(await readFile(sourcePath), original);
|
||||
const again = await prepareContentObjects(files, { directory: path.join(directory, "objects"), baseUrl: "/content/bundled" });
|
||||
assert.equal(again.writtenObjects, 0);
|
||||
assert.equal(createContentManifest(result.files).version, createContentManifest(again.files).version);
|
||||
await writeFile(sourcePath, "changed");
|
||||
await assert.rejects(prepareContentObjects(files, { directory: path.join(directory, "objects"), baseUrl: "/content/bundled" }), /changed during packaging/);
|
||||
} finally { await rm(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("bundles regular dungeons and exact map dependencies without installing Manastorm", () => {
|
||||
const ids = ["core:ui", "dungeon:first", "dungeon:second", "shared:creatures", "manastorm:shared-map", "manastorm:optional-map", "characters:extra"];
|
||||
const files = ids.map((packId, index) => ({ logicalPath: `assets/${index}.glb`, packId, sha256: String(index).repeat(64), bytes: 100, extension: ".glb" }));
|
||||
const result = selectBootstrapContent(files, [
|
||||
{ id: "dungeon:first", dependencies: ["manastorm:shared-map", "shared:creatures"] },
|
||||
{ id: "dungeon:second", dependencies: [] },
|
||||
]);
|
||||
assert.deepEqual(result.files.map((file) => file.packId), ids.slice(0, 5));
|
||||
const packs = new Map(result.packDefinitions.map((pack) => [pack.id, pack]));
|
||||
assert.equal(packs.get("dungeon:first").bundledOnAndroid, true);
|
||||
assert.equal(packs.get("dungeon:second").bundledOnAndroid, true);
|
||||
assert.equal(packs.get("manastorm:shared-map").bundledOnAndroid, false);
|
||||
assert.equal(packs.has("manastorm:optional-map"), false);
|
||||
});
|
||||
@@ -1,69 +1,3 @@
|
||||
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
BASIC_HUMAN_TEXTURE_PATHS,
|
||||
PROJECT_ROOT,
|
||||
createContentManifest,
|
||||
scanContentSources,
|
||||
} from "./publish-content.mjs";
|
||||
import { prepareBootstrap } from "./prepare-bootstrap.mjs";
|
||||
|
||||
const outputDirectory = path.join(PROJECT_ROOT, ".android-public");
|
||||
if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirectory) !== ".android-public") {
|
||||
throw new Error(`Refusing to replace unexpected Android staging directory: ${outputDirectory}`);
|
||||
}
|
||||
|
||||
const sources = [
|
||||
{ directory: path.join(PROJECT_ROOT, "public", "basis"), logicalPrefix: "basis" },
|
||||
{ directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" },
|
||||
{ directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" },
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "manifest.json"),
|
||||
logicalPrefix: "assets/characters/wow335a-v1/manifest.json",
|
||||
},
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "human"),
|
||||
logicalPrefix: "assets/characters/wow335a-v1/human",
|
||||
},
|
||||
...BASIC_HUMAN_TEXTURE_PATHS.map((logicalPath) => ({
|
||||
directory: path.join(PROJECT_ROOT, "public", ...logicalPath.split("/")),
|
||||
logicalPrefix: logicalPath,
|
||||
})),
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
|
||||
logicalPrefix: "assets/creatures/wailing-caverns",
|
||||
},
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
|
||||
logicalPrefix: "assets/game/arenas",
|
||||
},
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
|
||||
logicalPrefix: "assets/game/dungeons/wailing-caverns",
|
||||
},
|
||||
];
|
||||
|
||||
await rm(outputDirectory, { recursive: true, force: true });
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
const files = await scanContentSources(sources);
|
||||
for (const file of files) {
|
||||
const destination = path.join(outputDirectory, ...file.logicalPath.split("/"));
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
await copyFile(file.sourcePath, destination);
|
||||
}
|
||||
|
||||
const generated = createContentManifest(files, { baseUrl: "/" });
|
||||
const bootstrapManifest = {
|
||||
...generated,
|
||||
files: Object.fromEntries(Object.entries(generated.files).map(([logicalPath, entry]) => [
|
||||
logicalPath,
|
||||
{ ...entry, url: logicalPath },
|
||||
])),
|
||||
};
|
||||
const manifestPath = path.join(outputDirectory, "content", "bootstrap-manifest.json");
|
||||
await mkdir(path.dirname(manifestPath), { recursive: true });
|
||||
await writeFile(manifestPath, `${JSON.stringify(bootstrapManifest, null, 2)}\n`, "utf8");
|
||||
|
||||
const bytes = files.reduce((total, file) => total + file.bytes, 0);
|
||||
console.log(`Prepared Android bootstrap content: ${files.length} files (${bytes} bytes)`);
|
||||
console.log(`Bootstrap version: ${bootstrapManifest.version}`);
|
||||
await prepareBootstrap("android");
|
||||
@@ -0,0 +1,63 @@
|
||||
import { copyFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
BUNDLED_ANDROID_PACKS, DEFAULT_CONTENT_SOURCES, PROJECT_ROOT,
|
||||
createContentManifest, loadCampaignDungeonPackDefinitions, scanContentSources,
|
||||
} from "./publish-content.mjs";
|
||||
import { prepareContentObjects } from "./content-objects.mjs";
|
||||
|
||||
export function selectBootstrapContent(files, packDefinitions) {
|
||||
const manifest = createContentManifest(files, { packDefinitions });
|
||||
const packs = new Map(manifest.packs.map((pack) => [pack.id, pack]));
|
||||
const included = new Set();
|
||||
function include(id) {
|
||||
if (included.has(id)) return;
|
||||
included.add(id);
|
||||
for (const dependency of packs.get(id)?.dependencies ?? []) include(dependency);
|
||||
}
|
||||
for (const pack of manifest.packs) {
|
||||
if (BUNDLED_ANDROID_PACKS.includes(pack.id) || pack.id.startsWith("dungeon:")) include(pack.id);
|
||||
}
|
||||
return {
|
||||
files: files.filter((file) => included.has(file.packId)),
|
||||
packDefinitions: manifest.packs.filter((pack) => included.has(pack.id)).map((pack) => ({
|
||||
...pack,
|
||||
// Shared maps can be physically bundled without installing Manastorm gameplay.
|
||||
bundledOnAndroid: !pack.id.startsWith("manastorm:"),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareBootstrap(platform) {
|
||||
if (!["web", "android"].includes(platform)) throw new Error(`Unknown bootstrap platform: ${platform}`);
|
||||
const outputDirectory = path.resolve(PROJECT_ROOT, `.${platform}-public`);
|
||||
if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirectory) !== `.${platform}-public`) {
|
||||
throw new Error(`Refusing to replace unexpected staging directory: ${outputDirectory}`);
|
||||
}
|
||||
const files = await scanContentSources([
|
||||
...DEFAULT_CONTENT_SOURCES,
|
||||
{ directory: path.join(PROJECT_ROOT, "public/basis"), logicalPrefix: "basis" },
|
||||
{ directory: path.join(PROJECT_ROOT, "public/data"), logicalPrefix: "data" },
|
||||
]);
|
||||
const selected = selectBootstrapContent(files, await loadCampaignDungeonPackDefinitions());
|
||||
await rm(outputDirectory, { recursive: true, force: true });
|
||||
const prepared = await prepareContentObjects(selected.files, {
|
||||
directory: path.join(outputDirectory, "content/bundled"), baseUrl: "/content/bundled",
|
||||
});
|
||||
const manifest = createContentManifest(prepared.files, {
|
||||
baseUrl: "/content/bundled", packDefinitions: selected.packDefinitions,
|
||||
});
|
||||
await writeFile(path.join(outputDirectory, "content/bootstrap-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
await copyFile(path.join(PROJECT_ROOT, "public/content-cache-sw.js"), path.join(outputDirectory, "content-cache-sw.js"));
|
||||
const storage = {
|
||||
logicalBytes: prepared.logicalBytes, uniqueBytes: prepared.uniqueBytes, storedBytes: prepared.storedBytes,
|
||||
duplicateBytesRemoved: prepared.logicalBytes - prepared.uniqueBytes,
|
||||
uniqueObjects: prepared.uniqueObjects, logicalFiles: selected.files.length,
|
||||
bundledDungeons: manifest.packs.filter((pack) => pack.id.startsWith("dungeon:")).length,
|
||||
bundledManastormPacks: manifest.packs.filter((pack) => pack.id.startsWith("manastorm:") && pack.bundledOnAndroid).length,
|
||||
};
|
||||
await writeFile(path.join(outputDirectory, "content/storage-report.json"), `${JSON.stringify(storage, null, 2)}\n`);
|
||||
console.log(`Prepared ${platform} bootstrap: ${storage.bundledDungeons} dungeons, ${prepared.uniqueObjects} shared objects`);
|
||||
console.log(`Original: ${storage.logicalBytes} bytes; duplicates removed: ${storage.duplicateBytesRemoved} bytes; shipped: ${storage.storedBytes} bytes`);
|
||||
return { manifest, storage, outputDirectory };
|
||||
}
|
||||
@@ -1,75 +1,3 @@
|
||||
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
BASIC_HUMAN_TEXTURE_PATHS,
|
||||
PROJECT_ROOT,
|
||||
createContentManifest,
|
||||
scanContentSources,
|
||||
} from "./publish-content.mjs";
|
||||
import { prepareBootstrap } from "./prepare-bootstrap.mjs";
|
||||
|
||||
const outputDirectory = path.join(PROJECT_ROOT, ".web-public");
|
||||
if (path.dirname(outputDirectory) !== PROJECT_ROOT || path.basename(outputDirectory) !== ".web-public") {
|
||||
throw new Error(`Refusing to replace unexpected browser staging directory: ${outputDirectory}`);
|
||||
}
|
||||
|
||||
const sources = [
|
||||
{ directory: path.join(PROJECT_ROOT, "public", "basis"), logicalPrefix: "basis" },
|
||||
{ directory: path.join(PROJECT_ROOT, "public", "data"), logicalPrefix: "data" },
|
||||
{ directory: path.join(PROJECT_ROOT, "public", "assets", "ui"), logicalPrefix: "assets/ui" },
|
||||
{ directory: path.join(PROJECT_ROOT, "public", "assets", "equipment"), logicalPrefix: "assets/equipment" },
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "manifest.json"),
|
||||
logicalPrefix: "assets/characters/wow335a-v1/manifest.json",
|
||||
},
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "characters", "wow335a-v1", "human"),
|
||||
logicalPrefix: "assets/characters/wow335a-v1/human",
|
||||
},
|
||||
...BASIC_HUMAN_TEXTURE_PATHS.map((logicalPath) => ({
|
||||
directory: path.join(PROJECT_ROOT, "public", ...logicalPath.split("/")),
|
||||
logicalPrefix: logicalPath,
|
||||
})),
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
|
||||
logicalPrefix: "assets/creatures/wailing-caverns",
|
||||
},
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
|
||||
logicalPrefix: "assets/game/arenas",
|
||||
},
|
||||
{
|
||||
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
|
||||
logicalPrefix: "assets/game/dungeons/wailing-caverns",
|
||||
},
|
||||
];
|
||||
|
||||
await rm(outputDirectory, { recursive: true, force: true });
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
const files = await scanContentSources(sources);
|
||||
for (const file of files) {
|
||||
const destination = path.join(outputDirectory, ...file.logicalPath.split("/"));
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
await copyFile(file.sourcePath, destination);
|
||||
}
|
||||
|
||||
await copyFile(
|
||||
path.join(PROJECT_ROOT, "public", "content-cache-sw.js"),
|
||||
path.join(outputDirectory, "content-cache-sw.js"),
|
||||
);
|
||||
|
||||
const generated = createContentManifest(files, { baseUrl: "/" });
|
||||
const bootstrapManifest = {
|
||||
...generated,
|
||||
files: Object.fromEntries(Object.entries(generated.files).map(([logicalPath, entry]) => [
|
||||
logicalPath,
|
||||
{ ...entry, url: logicalPath },
|
||||
])),
|
||||
};
|
||||
const manifestPath = path.join(outputDirectory, "content", "bootstrap-manifest.json");
|
||||
await mkdir(path.dirname(manifestPath), { recursive: true });
|
||||
await writeFile(manifestPath, `${JSON.stringify(bootstrapManifest, null, 2)}\n`, "utf8");
|
||||
|
||||
const bytes = files.reduce((total, file) => total + file.bytes, 0);
|
||||
console.log(`Prepared browser bootstrap content: ${files.length} files (${bytes} bytes)`);
|
||||
console.log(`Bootstrap version: ${bootstrapManifest.version}`);
|
||||
await prepareBootstrap("web");
|
||||
@@ -1,7 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
constants,
|
||||
copyFile,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
@@ -11,6 +9,7 @@ import {
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { prepareContentObjects } from "./content-objects.mjs";
|
||||
|
||||
const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const PROJECT_ROOT = path.resolve(SCRIPT_DIRECTORY, "../..");
|
||||
@@ -190,7 +189,7 @@ export async function scanContentSources(sources = DEFAULT_CONTENT_SOURCES) {
|
||||
|
||||
function contentVersion(files, packs) {
|
||||
const digest = createHash("sha256");
|
||||
for (const file of files) digest.update(`${file.logicalPath}\0${file.sha256}\0${file.bytes}\n`);
|
||||
for (const file of files) digest.update(`${file.logicalPath}\0${file.sha256}\0${file.bytes}\0${JSON.stringify(file.download ?? null)}\n`);
|
||||
for (const pack of packs) {
|
||||
digest.update(`pack\0${pack.id}\0${pack.label}\0${pack.bundledOnAndroid}\0${pack.dependencies.join(",")}\n`);
|
||||
}
|
||||
@@ -305,6 +304,7 @@ export function createContentManifest(files, options = {}) {
|
||||
url: `${baseUrl}/${encodeUrlPath(objectName)}`,
|
||||
sha256: file.sha256,
|
||||
bytes: file.bytes,
|
||||
...(file.download ? { download: file.download } : {}),
|
||||
};
|
||||
const pack = packs.get(file.packId) ?? {
|
||||
id: file.packId,
|
||||
@@ -351,6 +351,11 @@ export function createContentManifest(files, options = {}) {
|
||||
}
|
||||
|
||||
const sortedPacks = [...packs.values()].sort((left, right) => left.id.localeCompare(right.id));
|
||||
for (const pack of sortedPacks) {
|
||||
const objects = new Map(files.filter((file) => file.packId === pack.id)
|
||||
.map((file) => [file.sha256, file.download?.bytes ?? file.bytes]));
|
||||
pack.downloadBytes = [...objects.values()].reduce((sum, bytes) => sum + bytes, 0);
|
||||
}
|
||||
const version = options.version ?? contentVersion(files, sortedPacks);
|
||||
|
||||
return {
|
||||
@@ -420,24 +425,6 @@ export function assertCampaignDungeonManifest(manifest, definitions) {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyObjectIfMissing(file, objectDirectory) {
|
||||
const objectName = `${file.sha256}${file.extension}`;
|
||||
const destination = path.join(objectDirectory, objectName);
|
||||
try {
|
||||
const destinationStats = await stat(destination);
|
||||
if (destinationStats.size !== file.bytes) throw new Error(`Published object has the wrong size: ${destination}`);
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
try {
|
||||
await copyFile(file.sourcePath, destination, constants.COPYFILE_EXCL);
|
||||
} catch (error) {
|
||||
if (error?.code !== "EEXIST") throw error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function replaceJsonAtomically(destination, value) {
|
||||
const temporary = `${destination}.${process.pid}.${Date.now()}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
@@ -456,22 +443,24 @@ export async function publishContent(options = {}) {
|
||||
...campaignPackDefinitions,
|
||||
...(options.packDefinitions ?? []),
|
||||
];
|
||||
const manifest = createContentManifest(files, { ...options, packDefinitions });
|
||||
if (campaignDefinitions) assertCampaignDungeonManifest(manifest, campaignDefinitions);
|
||||
if (campaignDefinitions) assertCampaignDungeonManifest(createContentManifest(files, { ...options, packDefinitions }), campaignDefinitions);
|
||||
|
||||
const objectDirectory = path.join(contentDirectory, "objects");
|
||||
const manifestDirectory = path.join(contentDirectory, "manifests");
|
||||
await mkdir(objectDirectory, { recursive: true });
|
||||
await mkdir(manifestDirectory, { recursive: true });
|
||||
let copiedObjects = 0;
|
||||
for (const file of files) {
|
||||
if (await copyObjectIfMissing(file, objectDirectory)) copiedObjects += 1;
|
||||
}
|
||||
const prepared = await prepareContentObjects(files, {
|
||||
directory: objectDirectory,
|
||||
baseUrl: String(options.baseUrl ?? "/content/objects").replace(/\/+$/, ""),
|
||||
keepOriginal: true,
|
||||
});
|
||||
const manifest = createContentManifest(prepared.files, { ...options, packDefinitions });
|
||||
await replaceJsonAtomically(path.join(manifestDirectory, `${manifest.version}.json`), manifest);
|
||||
await replaceJsonAtomically(path.join(contentDirectory, "manifest.json"), manifest);
|
||||
return {
|
||||
contentDirectory,
|
||||
copiedObjects,
|
||||
copiedObjects: prepared.writtenObjects,
|
||||
storage: { logicalBytes: prepared.logicalBytes, uniqueBytes: prepared.uniqueBytes, storedBytes: prepared.storedBytes },
|
||||
manifest,
|
||||
campaignDungeonPackCount: campaignDefinitions?.length ?? 0,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Verify the packaged bytes, including Android asset-name/compression behavior."""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from zipfile import ZIP_STORED, ZipFile
|
||||
|
||||
|
||||
def verify_package(apk: Path) -> dict:
|
||||
with ZipFile(apk) as archive:
|
||||
manifest = json.loads(archive.read("assets/public/content/bootstrap-manifest.json"))
|
||||
checked = set()
|
||||
object_bytes = 0
|
||||
for logical_path, entry in manifest["files"].items():
|
||||
stored = entry.get("download", entry)
|
||||
if stored["url"] in checked:
|
||||
continue
|
||||
checked.add(stored["url"])
|
||||
name = "assets/public" + stored["url"]
|
||||
data = archive.read(name)
|
||||
if len(data) != stored["bytes"] or hashlib.sha256(data).hexdigest() != stored["sha256"]:
|
||||
raise ValueError(f"Packaged object failed verification: {logical_path}")
|
||||
object_bytes += len(data)
|
||||
if "download" in entry:
|
||||
if archive.getinfo(name).compress_type != ZIP_STORED:
|
||||
raise ValueError(f"Android recompressed an already compressed object: {name}")
|
||||
data = gzip.decompress(data)
|
||||
if len(data) != entry["bytes"] or hashlib.sha256(data).hexdigest() != entry["sha256"]:
|
||||
raise ValueError(f"Expanded object failed verification: {logical_path}")
|
||||
dungeons = sum(pack["id"].startswith("dungeon:") and pack["bundledOnAndroid"] for pack in manifest["packs"])
|
||||
expected_objects = {"assets/public" + url for url in checked}
|
||||
actual_objects = {name for name in archive.namelist() if name.startswith("assets/public/content/bundled/") and not name.endswith("/")}
|
||||
if actual_objects != expected_objects:
|
||||
raise ValueError("APK includes missing or unreferenced bundled objects.")
|
||||
manastorms = sum(pack["id"].startswith("manastorm:") and pack["bundledOnAndroid"] for pack in manifest["packs"])
|
||||
if dungeons != 84 or manastorms != 0:
|
||||
raise ValueError(f"Unexpected bundled content: {dungeons} dungeons, {manastorms} Manastorm packs")
|
||||
return {
|
||||
"apk": str(apk.resolve()), "apkBytes": apk.stat().st_size,
|
||||
"manifestVersion": manifest["version"], "logicalFiles": len(manifest["files"]),
|
||||
"verifiedUniqueObjects": len(checked), "storedObjectBytes": object_bytes,
|
||||
"bundledDungeons": dungeons, "bundledManastormPacks": manastorms,
|
||||
"allOriginalHashesMatch": True,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("apk", type=Path)
|
||||
parser.add_argument("--report", type=Path)
|
||||
args = parser.parse_args()
|
||||
result = json.dumps(verify_package(args.apk), indent=2) + "\n"
|
||||
if args.report:
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(result, encoding="utf-8")
|
||||
print(result, end="")
|
||||
@@ -56,6 +56,13 @@ try {
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Gradle $Task failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
$packageType = if ($Task -eq 'assembleRelease') { 'release' } else { 'debug' }
|
||||
$packageDirectory = Join-Path $projectRoot "android\app\build\outputs\apk\$packageType"
|
||||
$builtPackage = Get-ChildItem -LiteralPath $packageDirectory -Filter '*.apk' |
|
||||
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if (-not $builtPackage) { throw "No APK was produced in $packageDirectory" }
|
||||
& python (Join-Path $projectRoot 'scripts\content\verify-android-package.py') $builtPackage.FullName
|
||||
if ($LASTEXITCODE -ne 0) { throw "Packaged content verification failed." }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
Reference in new issue
Block a user