Release Healer Man 0.1.29

This commit is contained in:
phenom committed 2026-09-11 14:26:15 -04:00
1 parent 8c9809d3e0
commit 544c1bee91
120 files changed
+4363 -1036

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
import { arenaKitFromSeed } from "./generated/arena-combat-runtime.mjs";
/** Server-owned character/loadout data enters the shared character initializer. */
export function arenaKitForLoadout(loadout, character = {}) {
return arenaKitFromSeed({ ...character, classId: loadout.classId, specializationId: loadout.specializationId,
role: loadout.role, level: 80, talentRanks: loadout.talents ?? character.talentRanks,
actionBindings: Object.keys(loadout.actions ?? {}).length ? loadout.actions : character.actionBindings,
settings: { ...character.settings, autoTargetNearest: false } });
}
+63
View File
@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import test from "node:test";
import { arenaKitForLoadout } from "./arena-character-kit.mjs";
import { ArenaSimulation } from "./arena-simulation.mjs";
import { ARENA_STAGES } from "./arena-stages.mjs";
function fixture() {
const kit = arenaKitForLoadout({classId:"priest",talents:{},actions:{"primary:face-left":"priest-flash-heal","primary:face-bottom":"priest-smite"}});
const simulation = new ArenaSimulation({id:"character-kit",stage:{ ...ARENA_STAGES[0], id:"test-floorless", lineOfSight:[] },bracket:"2v2",mode:"practice",ruleset:"wow",startedAt:0,
participants:[{actorId:"player",characterId:"saved-priest",name:"My Priest",team:0,role:"healer",isBot:false,characterKit:kit},
{actorId:"ally",team:0,role:"damage",isBot:false},{actorId:"enemy",team:1,role:"damage",isBot:false}]});
for(let i=0;i<200;i++)simulation.step();
const player=simulation.actor("player"), ally=simulation.actor("ally"), enemy=simulation.actor("enemy");
player.position=[0,0,0];ally.position=[1,0,0];enemy.position=[2,0,0];
return {simulation,player,ally,enemy,kit};
}
test("all arena spawns have finite character transforms before the first movement",()=>{
for(const stage of ARENA_STAGES) {
const simulation=new ArenaSimulation({id:stage.id,stage,bracket:"2v2",mode:"practice",ruleset:"wow",participants:[
{actorId:"left",team:0,role:"healer",isBot:false},{actorId:"right",team:1,role:"damage",isBot:false}]});
for(const actor of simulation.snapshot().actors) {
assert.ok(Number.isFinite(actor.facing),stage.id);
assert.ok(actor.position.every(Number.isFinite),stage.id);
assert.deepEqual(actor.position,stage.teamSpawns[actor.team],stage.id);
}
}
});
test("saved priest identity, spell bindings, and usable mana reach the snapshot",()=>{
const {simulation,kit}=fixture();
const player=simulation.snapshot().actors.find(actor=>actor.id==="player");
assert.equal(player.characterId,"saved-priest");assert.equal(player.classId,"priest");
assert.equal(player.actionBindings["primary:face-left"],"priest-flash-heal");
assert.ok(player.resource>kit.abilities["priest-flash-heal"].cost.amount);
assert.equal(simulation.receiveIntent("player",{sequence:1,type:"ability",abilityId:"heal"}).reason,"unknown-ability");
});
test("saved Flash Heal pays mana, has a cast time, heals the chosen ally and reports cooldown",()=>{
const {simulation,player,ally}=fixture();ally.health=200;
const resource=player.resource;
assert.equal(simulation.receiveIntent("player",{sequence:1,type:"ability",abilityId:"priest-flash-heal",targetActorId:"ally"}).accepted,true);
assert.equal(player.resource,resource);assert.equal(ally.health,200);
assert.equal(simulation.receiveIntent("player",{sequence:2,type:"ability",abilityId:"priest-flash-heal",targetActorId:"ally"}).reason,"already-casting");
for(let i=0;i<30;i++)simulation.step();
assert.ok(ally.health>200);assert.ok(player.resource<resource);assert.equal(player.health,player.maxHealth);assert.equal(player.casting,null);
});
test("movement cancels a real spell and impossible targets give feedback",()=>{
const {simulation,player,enemy}=fixture();
enemy.position=[1000,0,0];
assert.equal(simulation.receiveIntent("player",{sequence:1,type:"ability",abilityId:"priest-smite",targetActorId:"enemy"}).reason,"out-of-range");
assert.equal(simulation.receiveIntent("player",{sequence:2,type:"ability",abilityId:"priest-flash-heal",targetActorId:"player"}).accepted,true);
simulation.receiveIntent("player",{sequence:3,type:"movement",movement:{x:1,z:0}});simulation.step();
assert.equal(player.casting,null);
});
test("Shadowfiend uses the character summoning engine",()=>{
const {simulation,player}=fixture();
player.facing=Math.PI/2;
assert.equal(simulation.receiveIntent("player",{sequence:1,type:"ability",abilityId:"wow335-priest-shadowfiend",targetActorId:"enemy"}).accepted,true);
assert.ok(player.combat.summons.length>0);
});
+171
View File
@@ -0,0 +1,171 @@
import geometry from "./generated/arena-geometry.json" with { type: "json" };
const CELL = 4;
const indexes = new Map();
const navigationGraphs = new Map();
function index(stageId, kind) {
const key = `${stageId}:${kind}`;
if (indexes.has(key)) return indexes.get(key);
const triangles = geometry[stageId]?.[kind] ?? [];
const cells = new Map();
for (let offset = 0; offset < triangles.length; offset += 9) {
const xs = [triangles[offset], triangles[offset + 3], triangles[offset + 6]];
const zs = [triangles[offset + 2], triangles[offset + 5], triangles[offset + 8]];
for (let x = Math.floor(Math.min(...xs) / CELL); x <= Math.floor(Math.max(...xs) / CELL); x++) {
for (let z = Math.floor(Math.min(...zs) / CELL); z <= Math.floor(Math.max(...zs) / CELL); z++) {
const cell = `${x},${z}`;
if (!cells.has(cell)) cells.set(cell, []);
cells.get(cell).push(offset);
}
}
}
const value = { triangles, cells };
indexes.set(key, value);
return value;
}
export function arenaFloorHeight(stageId, x, z, previousY, maximumRise = 1) {
const { triangles: t, cells } = index(stageId, "navigation");
let height = null;
for (const o of cells.get(`${Math.floor(x / CELL)},${Math.floor(z / CELL)}`) ?? []) {
const ax = t[o], az = t[o + 2], bx = t[o + 3], bz = t[o + 5], cx = t[o + 6], cz = t[o + 8];
const denominator = (bz - cz) * (ax - cx) + (cx - bx) * (az - cz);
if (Math.abs(denominator) < .000001) continue;
const a = ((bz - cz) * (x - cx) + (cx - bx) * (z - cz)) / denominator;
const b = ((cz - az) * (x - cx) + (ax - cx) * (z - cz)) / denominator;
if (a < -.001 || b < -.001 || a + b > 1.001) continue;
const y = a * t[o + 1] + b * t[o + 4] + (1 - a - b) * t[o + 7];
if (y > previousY + maximumRise) continue;
if (height === null || y > height) height = y;
}
return height;
}
function hitsTriangle(from, to, t, o) {
const dx = to[0] - from[0], dy = to[1] - from[1], dz = to[2] - from[2];
const e1x = t[o + 3] - t[o], e1y = t[o + 4] - t[o + 1], e1z = t[o + 5] - t[o + 2];
const e2x = t[o + 6] - t[o], e2y = t[o + 7] - t[o + 1], e2z = t[o + 8] - t[o + 2];
const px = dy * e2z - dz * e2y, py = dz * e2x - dx * e2z, pz = dx * e2y - dy * e2x;
const determinant = e1x * px + e1y * py + e1z * pz;
if (Math.abs(determinant) < 1e-8) return false;
const inverse = 1 / determinant, sx = from[0] - t[o], sy = from[1] - t[o + 1], sz = from[2] - t[o + 2];
const u = (sx * px + sy * py + sz * pz) * inverse;
if (u < 0 || u > 1) return false;
const qx = sy * e1z - sz * e1y, qy = sz * e1x - sx * e1z, qz = sx * e1y - sy * e1x;
const v = (dx * qx + dy * qy + dz * qz) * inverse;
if (v < 0 || u + v > 1) return false;
const distance = (e2x * qx + e2y * qy + e2z * qz) * inverse;
return distance > .0001 && distance < .9999;
}
export function arenaGeometryLineClear(stageId, from, to) {
const { triangles, cells } = index(stageId, "collision");
const steps = Math.max(1, Math.ceil(Math.hypot(to[0] - from[0], to[2] - from[2]) / (CELL * .5)));
const visited = new Set();
for (let step = 0; step <= steps; step++) {
const ratio = step / steps;
const cell = `${Math.floor((from[0] + (to[0] - from[0]) * ratio) / CELL)},${Math.floor((from[2] + (to[2] - from[2]) * ratio) / CELL)}`;
for (const offset of cells.get(cell) ?? []) {
if (visited.has(offset)) continue;
visited.add(offset);
if (hitsTriangle(from, to, triangles, offset)) return false;
}
}
return true;
}
function navigationGraph(stageId) {
if (navigationGraphs.has(stageId)) return navigationGraphs.get(stageId);
const triangles = geometry[stageId]?.navigation ?? [];
const nodes = [], edges = new Map();
for (let offset = 0; offset < triangles.length; offset += 9) {
const vertices = [0, 3, 6].map((axis) => triangles.slice(offset + axis, offset + axis + 3));
const center = [0, 1, 2].map((axis) => vertices.reduce((sum, point) => sum + point[axis], 0) / 3);
const node = { id: nodes.length, center, neighbors: [], vertices };
nodes.push(node);
for (let edge = 0; edge < 3; edge++) {
const a = vertices[edge], b = vertices[(edge + 1) % 3];
const key = [a.join(","), b.join(",")].sort().join("/");
const portal = a.map((value, axis) => (value + b[axis]) / 2);
for (const other of edges.get(key) ?? []) {
node.neighbors.push({ id: other.id, portal });
other.neighbors.push({ id: node.id, portal });
}
if (!edges.has(key)) edges.set(key, []);
edges.get(key).push(node);
}
}
navigationGraphs.set(stageId, nodes);
return nodes;
}
const distance3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
function navigationStepClear(stageId, from, to) {
let height = from[1];
const steps = Math.max(1, Math.ceil(distance3(from, to) / .4));
for (let step = 1; step <= steps; step++) {
const ratio = step / steps;
const x = from[0] + (to[0] - from[0]) * ratio, z = from[2] + (to[2] - from[2]) * ratio;
const floor = arenaFloorHeight(stageId, x, z, height, .6);
if (floor === null || height - floor > .8) return false;
height = floor;
}
return Math.abs(height - to[1]) < .8;
}
function navigationNode(nodes, point) {
let nearest = null, nearestDistance = Infinity;
for (const node of nodes) {
const [a, b, c] = node.vertices;
const denominator = (b[2] - c[2]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[2] - c[2]);
if (Math.abs(denominator) < .000001) continue;
const u = ((b[2] - c[2]) * (point[0] - c[0]) + (c[0] - b[0]) * (point[2] - c[2])) / denominator;
const v = ((c[2] - a[2]) * (point[0] - c[0]) + (a[0] - c[0]) * (point[2] - c[2])) / denominator;
const inside = u >= -.001 && v >= -.001 && u + v <= 1.001;
const height = u * a[1] + v * b[1] + (1 - u - v) * c[1];
const distance = inside ? Math.abs(height - point[1]) : distance3(node.center, point) + 5;
if (distance < nearestDistance) { nearest = node; nearestDistance = distance; }
}
return nearest;
}
/** Follow the shipped walkable mesh around walls, ramps and starting rooms. */
export function arenaNavigationPath(stageId, from, to) {
const nodes = navigationGraph(stageId);
const start = navigationNode(nodes, from), goal = navigationNode(nodes, to);
if (!start || !goal) return [];
const open = new Set([start.id]), costs = new Map([[start.id, 0]]), previous = new Map();
let reached = false;
while (open.size) {
let current = null, score = Infinity;
for (const id of open) {
const candidate = costs.get(id) + distance3(nodes[id].center, goal.center);
if (candidate < score) { current = nodes[id]; score = candidate; }
}
if (current.id === goal.id) { reached = true; break; }
open.delete(current.id);
for (const edge of current.neighbors) {
const next = nodes[edge.id];
edge.walkable ??= navigationStepClear(stageId, current.center, edge.portal) && navigationStepClear(stageId, edge.portal, next.center) && [.55, 1.4].every((height) => {
const lift = (point) => [point[0], point[1] + height, point[2]];
return arenaGeometryLineClear(stageId, lift(current.center), lift(edge.portal))
&& arenaGeometryLineClear(stageId, lift(edge.portal), lift(next.center));
});
if (!edge.walkable) continue;
const cost = costs.get(current.id) + distance3(current.center, edge.portal) + distance3(edge.portal, next.center);
if (cost >= (costs.get(next.id) ?? Infinity)) continue;
costs.set(next.id, cost);
previous.set(next.id, { id: current.id, portal: edge.portal });
open.add(next.id);
}
}
if (!reached) return [];
const path = [[...to]];
for (let id = goal.id; id !== start.id;) {
const edge = previous.get(id);
path.push(edge.portal);
id = edge.id;
}
return path.reverse();
}
+52
View File
@@ -0,0 +1,52 @@
import test from "node:test";
import assert from "node:assert/strict";
import { arenaFloorHeight, arenaGeometryLineClear, arenaNavigationPath } from "./arena-geometry.mjs";
import { ARENA_STAGES } from "./arena-stages.mjs";
import { ArenaSimulation } from "./arena-simulation.mjs";
test("every arena spawn is supported by its shipped navigation surface", () => {
for (const stage of ARENA_STAGES) for (const spawn of stage.teamSpawns) {
const floor = arenaFloorHeight(stage.id, spawn[0], spawn[2], spawn[1], 2);
assert.notEqual(floor, null, `${stage.id} ${spawn}`);
assert.ok(Math.abs(floor - spawn[1]) < 2, `${stage.id} floor=${floor}, spawn=${spawn[1]}`);
}
});
test("characters can move away from both starting anchors into each arena", () => {
for (const stage of ARENA_STAGES) for (const team of [0, 1]) {
const sim = new ArenaSimulation({ id: "traversal", stage, bracket: "2v2", mode: "practice", ruleset: "wow", startedAt: 1000,
participants: [{ actorId: "player", name: "Player", team, role: "healer", isBot: false }] });
sim.phase = "active"; sim.activeStartedAt = 1000;
const actor = sim.actor("player"), start = [...actor.position], gate = stage.preparationGates[team];
const dx = gate[0] - start[0], dz = gate[2] - start[2], length = Math.hypot(dx, dz);
actor.movement = [dx / length, dz / length];
for (let tick = 0; tick < 50; tick++) sim.moveActor(actor, .05);
assert.ok(Math.hypot(actor.position[0] - start[0], actor.position[2] - start[2]) > 3, `${stage.id} team ${team}: ${actor.position} from ${start}`);
assert.ok(actor.position.every(Number.isFinite));
}
});
test("collision geometry blocks arena walls and preserves clear short sight lines", () => {
const stage = ARENA_STAGES.find((entry) => entry.id === "nagrand-arena");
const spawn = stage.teamSpawns[0];
assert.equal(arenaGeometryLineClear(stage.id, [spawn[0], spawn[1] + 1.2, spawn[2]], [spawn[0] + .05, spawn[1] + 1.2, spawn[2]]), true);
assert.equal(arenaGeometryLineClear(stage.id, [-60, 14.5, -42], [-35, 14.5, -42]), false);
});
test("bots can traverse each complete arena between both teams without invisible walls", () => {
for (const stage of ARENA_STAGES) for (const team of [0, 1]) {
const sim = new ArenaSimulation({ id: "path", stage, bracket: "3v3", mode: "practice", ruleset: "wow", startedAt: 1000,
participants: [0, 1, 2].map((index) => ({ actorId: `p${index}`, name: "Player", team, role: "damage", isBot: false })) });
sim.phase = "active"; sim.activeStartedAt = 1000;
for (const actor of sim.actors.values()) assert.notEqual(arenaFloorHeight(stage.id, actor.position[0], actor.position[2], actor.position[1], 2), null, `${stage.id} team ${team} ${actor.id} spawn`);
const actor = sim.actor("p0"), destination = stage.teamSpawns[1 - team];
actor.navigationPath = arenaNavigationPath(stage.id, actor.position, destination);
assert.ok(actor.navigationPath.length, `${stage.id} has a route`);
for (let tick = 0; tick < 2400; tick++) {
sim.tickNumber++;
sim.followBotPath(actor); sim.moveActor(actor, .05);
if (Math.hypot(actor.position[0] - destination[0], actor.position[2] - destination[2]) < 2) break;
}
assert.ok(Math.hypot(actor.position[0] - destination[0], actor.position[2] - destination[2]) < 2, `${stage.id} team ${team} stuck at ${actor.position}`);
}
});
+185
View File
@@ -0,0 +1,185 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ArenaSimulation } from "./arena-simulation.mjs";
import { ARENA_STAGES } from "./arena-stages.mjs";
import { initializeArenaCharacter } from "./generated/arena-combat-runtime.mjs";
function match(classId = "priest", secondaryClassId) {
const simulation = new ArenaSimulation({ id: `runtime-${classId}`, stage: { ...ARENA_STAGES[0], id: "test-floorless", lineOfSight: [] },
bracket: "2v2", mode: "practice", ruleset: "wow", startedAt: 1000,
participants: [{ actorId: "caster", name: "My Character", team: 0, role: "healer", isBot: false,
characterKit: { classId, level: 80, seed: { classId, secondaryClassId, level: 80, actionBindings: { "primary:face-left": "priest-flash-heal" } } } },
{ actorId: "ally", name: "Ally", team: 0, role: "damage", isBot: false },
{ actorId: "enemy", name: "Enemy", team: 1, role: "damage", isBot: false, characterKit: { classId: "mage", level: 80 } }] });
simulation.phase = "active"; simulation.activeStartedAt = 1000; simulation.phaseEndsAt = null;
const caster = simulation.actor("caster"), ally = simulation.actor("ally"), enemy = simulation.actor("enemy");
caster.position = [0, 0, 0]; caster.facing = 0; ally.position = [1, 0, 0]; enemy.position = [0, 0, 3]; enemy.facing = Math.PI;
const cast = (id, target = "enemy", ground) => simulation.useAbility(caster, id, target, ground);
const advance = (ms) => { for (let index = 0; index < ms / 50; index++) simulation.step(); };
return { simulation, caster, ally, enemy, cast, advance };
}
test("melee bots start weapon attacks and generate resources instead of waiting at zero rage", () => {
const { caster, enemy, advance } = match("warrior");
caster.isBot = true; caster.role = "damage";
const health = enemy.health;
advance(8000);
assert.ok(enemy.health < health);
assert.ok(caster.combat.combatEvents.length > 0 || caster.combat.autoAttackTargetId === enemy.id);
});
test("friendly dispels remove the movement restriction as well as the visible debuff", () => {
const { simulation, caster, enemy, cast } = match("priest");
assert.equal(simulation.useAbility(enemy, "mage-frost-nova", caster.id).accepted, true);
assert.equal(caster.statuses.find((status) => status.kind === "root")?.endsAt, 9000);
const dispel = cast("wow335-priest-dispel-magic", caster.id);
assert.equal(dispel.accepted, true, JSON.stringify(dispel));
assert.equal(caster.statuses.some((status) => status.kind === "root"), false);
caster.movement = [1, 0]; simulation.moveActor(caster, .05);
assert.ok(caster.position[0] > 0);
});
test("player crowd control diminishes once per cast, including duplicated catalog status entries", () => {
const { simulation, caster, enemy, advance } = match("priest");
for (const duration of [8000, 4000, 2000, 0]) {
enemy.cooldowns.clear(); enemy.combat = { ...enemy.combat, cooldowns: {}, globalCooldownEndsAt: 0 };
assert.equal(simulation.useAbility(enemy, "mage-frost-nova", caster.id).accepted, true);
const now = simulation.startedAt + simulation.tickNumber * 50;
assert.equal((caster.statuses.find((status) => status.kind === "root")?.endsAt ?? now) - now, duration);
advance(duration + 50);
}
});
test("a healer can target and heal a teammate's pet", () => {
const { simulation, caster, ally, cast, advance } = match("hunter");
assert.equal(cast("wow335-hunter-call-pet", caster.id).accepted, true);
advance(2000);
const pet = caster.combat.summons[0];
caster.combat = { ...caster.combat, summons: [{ ...pet, health: 500 }] };
ally.combat = null; ally.characterKit = { classId: "priest", level: 80 };
initializeArenaCharacter(ally, 3000);
assert.equal(simulation.useAbility(ally, "priest-flash-heal", pet.id).accepted, true);
advance(2000);
assert.ok(caster.combat.summons[0].health > 500);
});
test("channel deals successive damage, heals its caster, and stops on movement", () => {
const { caster, enemy, cast, advance } = match("warlock");
caster.health = 3000;
assert.equal(cast("warlock-drain-life").accepted, true);
assert.equal(caster.casting.mode, "channel");
const initial = enemy.health;
advance(2200);
assert.ok(enemy.health < initial);
assert.ok(caster.health > 3000);
caster.movement = [1, 0]; advance(50);
assert.equal(caster.casting, null);
});
test("runes are spent, gate repeat casts, regenerate, and restore with Empower Rune Weapon", () => {
const { caster, cast, advance } = match("death-knight");
assert.equal(cast("death-knight-icy-touch").accepted, true);
assert.ok(caster.combat.deathKnightRunes.some((rune) => rune.readyAt > 1000));
assert.ok(caster.resource > 0);
advance(1600);
assert.equal(cast("wow335-death-knight-empower-rune-weapon", "caster").accepted, true);
assert.ok(caster.combat.deathKnightRunes.every((rune) => rune.readyAt <= 2600));
});
test("combo points belong to the selected enemy and finishers consume them", () => {
const { caster, cast, advance } = match("rogue");
assert.equal(cast("rogue-sinister-strike").accepted, true);
assert.equal(caster.combat.comboPoints, 1);
assert.equal(caster.combat.comboTargetId, "enemy");
advance(1600);
assert.equal(cast("rogue-eviscerate").accepted, true);
assert.equal(caster.combat.comboPoints, 0);
});
test("pets spawn, receive orders, attack, and dismiss through the same spell engine", () => {
const { simulation, caster, enemy, cast, advance } = match("hunter");
assert.equal(cast("wow335-hunter-call-pet", "caster").accepted, true);
assert.ok(caster.combat.summons.some((summon) => summon.kind === "pet"));
simulation.receiveIntent("caster", { type: "target", targetActorId: "enemy", sequence: 1 });
simulation.receiveIntent("caster", { type: "command", command: "attack", sequence: 2 });
const before = enemy.health;
advance(3100);
assert.ok(enemy.health < before);
assert.equal(cast("wow335-hunter-dismiss-pet", "caster").accepted, true);
advance(Math.max(50, (caster.casting?.completesAt ?? 0) - (1000 + simulation.tickNumber * 50) + 50));
assert.equal(caster.combat.summons.length, 0);
});
test("an opposing player's interrupt cancels the cast and locks its school", () => {
const { simulation, caster, enemy, cast, advance } = match();
assert.equal(cast("priest-smite").accepted, true);
assert.equal(simulation.useAbility(enemy, "wow335-mage-counterspell", "caster").accepted, true);
assert.equal(caster.casting, null);
assert.ok(caster.combat.schoolLockedUntil.holy > 1000);
advance(1600);
assert.equal(cast("priest-smite").accepted, false);
});
test("healing reaches the requested teammate, is dampened, and does not alter another match", () => {
const first = match(), second = match();
first.ally.health = 500;
const untouched = second.caster.resource;
assert.equal(first.cast("priest-flash-heal", "ally").accepted, true);
first.advance(1600);
assert.ok(first.ally.health > 500);
assert.ok(first.caster.resource < untouched);
assert.equal(second.caster.resource, untouched);
assert.equal(second.caster.casting, null);
assert.ok(first.simulation.combatEvents.some((event) => event.kind === "healing" && event.targetActorId === "ally" && event.sourceActorId === "caster"));
assert.ok(first.simulation.combatPresentation.some((event) => event.sourceActorId === "caster"));
});
test("self shields absorb hostile-player damage and are consumed once", () => {
const { simulation, caster, enemy, cast, advance } = match("mage");
assert.equal(cast("mage-mana-shield", "caster").accepted, true);
const shield = caster.combat.shield;
assert.ok(shield > 0 || caster.auras.some((aura) => aura.absorbs.length));
const health = caster.health;
enemy.combat = { ...enemy.combat, gearStats: { ...enemy.combat.gearStats, hitRating: 100000 } };
assert.equal(simulation.useAbility(enemy, "mage-fire-blast", "caster").accepted, true);
assert.ok(caster.health >= health - 1010);
assert.ok(simulation.combatEvents.some((event) => event.kind === "damage" && event.targetActorId === "caster" && event.absorbed > 0), JSON.stringify(simulation.combatEvents));
advance(50);
});
test("RoM primary and secondary resource pools survive arena ticks and casting", () => {
const { caster, cast, advance } = match("rom-warlock", "rom-priest");
assert.ok(Object.keys(caster.combat.resourcePools).length > 1);
const focus = caster.combat.resourcePools.focus;
assert.equal(cast("rom-warlock-general-0").accepted, true);
advance(4000);
assert.ok(Number.isFinite(caster.combat.resourcePools.focus));
assert.ok(caster.combat.resourcePools.focus <= focus);
assert.ok(Number.isFinite(caster.combat.resourcePools.mana));
});
test("ground spells damage targets around the confirmed location", () => {
const { caster, enemy, cast } = match("mage");
const health = enemy.health;
assert.equal(cast("wow335-mage-blizzard", null, enemy.position).accepted, true);
assert.ok(enemy.health < health);
assert.ok(caster.resource >= 0);
});
test("mana burns drain an opposing character's actual resource pool", () => {
const { enemy, cast, advance } = match();
const before = enemy.resource;
assert.equal(cast("wow335-priest-mana-burn").accepted, true);
advance(3500);
assert.ok(enemy.resource < before);
});
test("opponents can target and damage player pets", () => {
const { simulation, caster, enemy, cast } = match("hunter");
assert.equal(cast("wow335-hunter-call-pet", "caster").accepted, true);
const pet = caster.combat.summons[0], before = pet.health;
enemy.combat = { ...enemy.combat, gearStats: { ...enemy.combat.gearStats, hitRating: 100000 } };
assert.equal(simulation.receiveIntent("enemy", { sequence: 1, type: "target", targetActorId: pet.id }).accepted, true);
assert.equal(simulation.useAbility(enemy, "mage-fire-blast", pet.id).accepted, true);
assert.ok((caster.combat.summons.find((entry) => entry.id === pet.id)?.health ?? 0) < before);
});
+17 -12
View File
@@ -3,6 +3,7 @@ import { GameDatabaseError } from "./database.mjs";
import { ARENA_BRACKETS, arenaMatchTolerance, assertArenaQueueInput, validateArenaComposition } from "./arena-rules.mjs";
import { ARENA_STAGES, chooseArenaStage } from "./arena-stages.mjs";
import { ArenaSimulation } from "./arena-simulation.mjs";
import { arenaKitForLoadout } from "./arena-character-kit.mjs";
const READY_CHECK_MS = 15_000;
const PVP_BOT_FILL_MS = 30_000;
@@ -222,18 +223,22 @@ export function createArenaService({ database, random = Math.random, now = () =>
}
function fillTeam(humans, team, bracket, rating) {
const members = humans.map((member) => ({
actorId: `player-${randomUUID()}`,
accountId: member.accountId,
characterId: member.characterId,
name: member.loadout.name || member.username,
team,
role: member.role,
isBot: false,
disconnected: false,
rating: database.getArenaRating(member.accountId, member.characterId, bracket).rating,
loadout: member.loadout,
}));
const members = humans.map((member) => {
const character = database.getCloudSave?.(member.accountId)?.data?.characters?.find((entry) => entry.id === member.characterId) ?? {};
return ({
actorId: `player-${randomUUID()}`,
accountId: member.accountId,
characterId: member.characterId,
name: character.name || member.username,
characterKit: arenaKitForLoadout(member.loadout, character),
team,
role: member.role,
isBot: false,
disconnected: false,
rating: database.getArenaRating(member.accountId, member.characterId, bracket).rating,
loadout: member.loadout,
});
});
const composition = validateArenaComposition(bracket, members.map((member) => member.role), { allowPartial: true });
if (!composition.ok) throw new GameDatabaseError(composition.message, 400, composition.code);
for (let index = 0; index < composition.missing.healer; index += 1) members.push(botMember(team, "healer", rating, index));
+3 -1
View File
@@ -1,5 +1,6 @@
import type {
ArenaAuthoritativeSnapshot,
ArenaCharacterKit,
ArenaCombatIntent,
ArenaResult,
ArenaRole,
@@ -8,6 +9,7 @@ import type {
import type { ArenaBracket, ArenaMode, ArenaRuleset } from "../src/game/arenaRules";
export interface ArenaSimulationParticipant {
characterKit?: ArenaCharacterKit;
actorId: string;
accountId?: string | null;
characterId?: string | null;
@@ -43,5 +45,5 @@ export declare class ArenaSimulation {
stop(): void;
step(): void;
snapshot(): ArenaAuthoritativeSnapshot;
receiveIntent(actorId: string, intent: ArenaCombatIntent): { accepted: boolean; reason?: string };
receiveIntent(actorId: string, intent: ArenaCombatIntent): { accepted: boolean; reason?: string; message?: string };
}
+198 -108
View File
@@ -1,19 +1,11 @@
import { arenaAiProfile, arenaDampening, arenaDrDuration, applyArenaHealing, shouldArenaBotChooseCorrectly } from "./arena-rules.mjs";
import { arenaAiProfile, arenaDampening, shouldArenaBotChooseCorrectly } from "./arena-rules.mjs";
import { initializeArenaCharacter, useArenaCharacterAbility, tickArenaCharacter, arenaCharacterSnapshot, damageArenaCharacter, commandArenaCharacter, bindArenaCharacter } from "./generated/arena-combat-runtime.mjs";
import { arenaFloorHeight, arenaGeometryLineClear, arenaNavigationPath } from "./arena-geometry.mjs";
export const ARENA_TICK_RATE = 20;
export const ARENA_SNAPSHOT_RATE = 10;
export const ARENA_TICK_MS = 1000 / ARENA_TICK_RATE;
const ABILITIES = Object.freeze({
attack: Object.freeze({ cooldownMs: 1500, range: 5, damage: 40 }),
strike: Object.freeze({ cooldownMs: 6000, range: 5, damage: 120 }),
"ranged-attack": Object.freeze({ cooldownMs: 2200, range: 30, damage: 52 }),
heal: Object.freeze({ cooldownMs: 0, range: 40, healing: 150, castMs: 1500, resource: 12 }),
interrupt: Object.freeze({ cooldownMs: 15000, range: 8, lockoutMs: 4000 }),
control: Object.freeze({ cooldownMs: 30000, range: 25, controlMs: 6000, drCategory: "incapacitate" }),
defensive: Object.freeze({ cooldownMs: 90000, range: 0, defensiveMs: 8000 }),
});
function distance2d(left, right) {
return Math.hypot(left.position[0] - right.position[0], left.position[2] - right.position[2]);
}
@@ -34,26 +26,34 @@ function segmentIntersectsBox(from, to, object) {
&& Math.abs(nearestZ - object.position[2]) <= radiusZ;
}
function newActor(participant, spawn, teamIndex, arenaCenter) {
const offset = participant.role === "healer" ? -2 : teamIndex * 2;
const facing = Math.atan2(arenaCenter[0] - spawn[0], arenaCenter[2] - spawn[2]);
function newActor(participant, spawn, teamIndex, preparationGate) {
const facing = Math.atan2(preparationGate[0] - spawn[0], preparationGate[2] - spawn[2]);
// Keep the first player on the validated anchor. Role-based sideways offsets
// put healers inside the starting-room wall in Nagrand.
const offset = teamIndex * 2;
return {
id: participant.actorId,
accountId: participant.accountId ?? null,
characterId: participant.characterId ?? null,
characterKit: participant.characterKit ?? null,
name: participant.name,
team: participant.team,
role: participant.role,
isBot: Boolean(participant.isBot),
disconnected: Boolean(participant.disconnected),
alive: true,
position: [spawn[0], spawn[1], spawn[2] + offset],
position: [spawn[0] + Math.sin(facing) * offset, spawn[1], spawn[2] + Math.cos(facing) * offset],
facing,
movement: [0, 0],
health: 1000,
maxHealth: 1000,
resource: 100,
maxResource: 100,
resource: participant.characterKit?.resource?.initial ?? 100,
maxResource: participant.characterKit?.resource?.maximum ?? 100,
gcdUntil: 0,
lastAbilityId: undefined,
abilityRevision: 0,
auras: [],
periodic: new Map(),
targetActorId: null,
cooldowns: new Map(),
effects: new Map(),
@@ -134,14 +134,13 @@ export class ArenaSimulation {
this.actors = new Map();
this.lastMechanicPulse = new Map();
const teamCounts = [0, 0];
const arenaCenter = [
(this.stage.bounds.min[0] + this.stage.bounds.max[0]) / 2,
(this.stage.bounds.min[2] + this.stage.bounds.max[2]) / 2,
];
for (const participant of options.participants) {
const team = participant.team;
const actor = newActor(participant, this.stage.teamSpawns[team], teamCounts[team]++, arenaCenter);
const teamIndex = teamCounts[team]++;
const actor = newActor(participant, this.stage.teamSpawns[team], teamIndex, this.stage.preparationGates[team]);
if (teamIndex) actor.position[1] = arenaFloorHeight(this.stage.id, actor.position[0], actor.position[2], actor.position[1], 2) ?? actor.position[1];
this.actors.set(actor.id, actor);
initializeArenaCharacter(actor, this.startedAt);
}
for (const object of this.stage.dynamicObjects) {
if (object.initialDelayRangeMs) {
@@ -173,19 +172,68 @@ export class ArenaSimulation {
}
hasLineOfSight(left, right, dynamicStates = null) {
if (!arenaGeometryLineClear(this.stage.id, [left.position[0], left.position[1] + 1.2, left.position[2]], [right.position[0], right.position[1] + 1.2, right.position[2]])) return false;
const states = dynamicStates ?? new Map(this.dynamicObjects().map((state) => [state.id, state]));
return !this.stage.lineOfSight.some((object) => {
if (object.dynamicObjectId && !states.get(object.dynamicObjectId)?.collisionEnabled) return false;
// Static walls come from the real mesh. Old proxy boxes created invisible
// obstacles and blocked routes that are open in the detailed arena.
if (!object.dynamicObjectId || !states.get(object.dynamicObjectId)?.collisionEnabled) return false;
return segmentIntersectsBox(left.position, right.position, object);
});
}
targetActor(actorId) {
const actor = this.actor(actorId);
if (actor) return actor;
for (const owner of this.actors.values()) {
const summon = owner.combat.summons.find((candidate) => candidate.id === actorId);
if (summon) return { ...owner, ...summon, team: owner.team, alive: summon.health > 0, facing: owner.facing };
}
return null;
}
projectGroundPoint(origin, requested, range) {
if (Math.hypot(requested[0] - origin[0], requested[2] - origin[2]) > range) return null;
if ([0, 2].some((axis) => requested[axis] < this.stage.bounds.min[axis] || requested[axis] > this.stage.bounds.max[axis])) return null;
const floor = arenaFloorHeight(this.stage.id, requested[0], requested[2], requested[1], 2);
if (floor === null && arenaFloorHeight(this.stage.id, origin[0], origin[2], origin[1], 2) !== null) return null;
const point = [requested[0], floor ?? requested[1], requested[2]];
return this.hasLineOfSight({ position: origin }, { position: point }) ? point : null;
}
resolvePosition(actor, requested, teleport = false) {
const x = Math.max(this.stage.bounds.min[0] + .35, Math.min(this.stage.bounds.max[0] - .35, requested[0]));
const z = Math.max(this.stage.bounds.min[2] + .35, Math.min(this.stage.bounds.max[2] - .35, requested[2]));
const floor = arenaFloorHeight(this.stage.id, x, z, actor.position[1], teleport ? 3 : 1);
const oldFloor = arenaFloorHeight(this.stage.id, actor.position[0], actor.position[2], actor.position[1], 2);
if (floor === null && oldFloor !== null) return [...actor.position];
const ground = floor ?? actor.groundY ?? actor.position[1];
const target = [x, Math.max(ground, requested[1]), z];
for (const height of [.55, 1.4]) {
const from = [actor.position[0], actor.position[1] + height, actor.position[2]];
const to = [target[0], target[1] + height, target[2]];
if (!arenaGeometryLineClear(this.stage.id, from, to)) return [...actor.position];
}
const dynamic = new Map(this.dynamicObjects().map((entry) => [entry.id, entry]));
for (const object of this.stage.lineOfSight) {
if (!object.dynamicObjectId || !dynamic.get(object.dynamicObjectId)?.collisionEnabled) continue;
if (target[1] > object.position[1] + object.size[1] / 2 || target[1] + 1.8 < object.position[1] - object.size[1] / 2) continue;
if (Math.abs(x - object.position[0]) < object.size[0] / 2 + .35 && Math.abs(z - object.position[2]) < object.size[2] / 2 + .35) return [...actor.position];
}
actor.groundY = ground;
return target;
}
receiveIntent(actorId, intent) {
const actor = this.actor(actorId);
if (!actor || actor.isBot || actor.disconnected || !actor.alive || this.phase !== "active") return { accepted: false, reason: "actor-unavailable" };
if (!actor || actor.isBot || actor.disconnected || !actor.alive || !["active", "preparation"].includes(this.phase)) return { accepted: false, reason: "actor-unavailable" };
const sequence = Math.trunc(Number(intent?.sequence));
if (!Number.isSafeInteger(sequence) || sequence <= actor.lastSequence) return { accepted: false, reason: "stale-sequence" };
actor.lastSequence = sequence;
if (intent.type === "bindings" && intent.actionBindings && typeof intent.actionBindings === "object") {
bindArenaCharacter(actor, intent.actionBindings);
return { accepted: true };
}
if (intent.type === "movement") {
const x = Math.max(-1, Math.min(1, Number(intent.movement?.x) || 0));
const z = Math.max(-1, Math.min(1, Number(intent.movement?.z) || 0));
@@ -198,56 +246,35 @@ export class ArenaSimulation {
return { accepted: true };
}
if (intent.type === "target") {
actor.targetActorId = this.actors.has(intent.targetActorId) ? intent.targetActorId : null;
actor.targetActorId = this.targetActor(intent.targetActorId) ? intent.targetActorId : null;
return { accepted: true };
}
if (intent.type === "ability") {
const ability = actor.characterKit.abilities[String(intent.abilityId ?? "")];
if (this.phase !== "active" && (ability?.target === "hostile" || ability?.effects.some((effect) => ["damage", "dot", "status", "execute", "finisher-damage"].includes(effect.kind)))) return { accepted: false, reason: "preparation" };
const ground = Array.isArray(intent.groundPosition) && intent.groundPosition.length === 3 && intent.groundPosition.every(Number.isFinite) ? intent.groundPosition : undefined;
return this.useAbility(actor, String(intent.abilityId ?? ""), intent.targetActorId, ground);
}
if (intent.type === "command") {
commandArenaCharacter(this, actor, intent.command, this.startedAt + this.tickNumber * ARENA_TICK_MS);
return { accepted: true };
}
if (intent.type === "jump") {
if (!actor.jumpVelocity && actor.combat.controlledUntil <= this.startedAt + this.tickNumber * ARENA_TICK_MS) actor.jumpVelocity = 7;
return { accepted: true };
}
if (intent.type === "ability") return this.useAbility(actor, String(intent.abilityId ?? ""), intent.targetActorId);
return { accepted: false, reason: "unknown-intent" };
}
useAbility(actor, abilityId, targetActorId = null) {
useAbility(actor, abilityId, targetActorId = null, groundPosition) {
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
const ability = ABILITIES[abilityId];
if (!ability || !actor.alive || now < (actor.cooldowns.get(abilityId) ?? 0) || now < actor.lockoutUntil) return { accepted: false, reason: "ability-unavailable" };
if (actor.effects.get("control")?.endsAt > now) return { accepted: false, reason: "controlled" };
const target = this.actor(targetActorId ?? actor.targetActorId) ?? (abilityId === "defensive" ? actor : null);
if (!target || !target.alive) return { accepted: false, reason: "invalid-target" };
const friendly = target.team === actor.team;
if ((ability.healing && !friendly) || (!ability.healing && abilityId !== "defensive" && friendly)) return { accepted: false, reason: "invalid-target" };
if (ability.range > 0 && distance2d(actor, target) > ability.range) return { accepted: false, reason: "out-of-range" };
if (target !== actor && !this.hasLineOfSight(actor, target)) return { accepted: false, reason: "line-of-sight" };
if (ability.resource && actor.resource < ability.resource) return { accepted: false, reason: "resource" };
if (ability.castMs) {
actor.resource -= ability.resource ?? 0;
actor.casting = { abilityId, targetActorId: target.id, completesAt: now + ability.castMs };
return { accepted: true, casting: true };
}
actor.cooldowns.set(abilityId, now + ability.cooldownMs);
if (ability.damage) this.damage(target, ability.damage);
if (ability.lockoutMs) {
if (!target.casting) return { accepted: true, interrupted: false };
target.casting = null;
target.lockoutUntil = now + ability.lockoutMs;
}
if (ability.controlMs) {
const state = target.dr.get(ability.drCategory) ?? { applications: 0, endedAt: -Infinity };
const dr = arenaDrDuration(ability.controlMs, state.applications, now - state.endedAt);
if (dr.durationMs > 0) target.effects.set("control", { id: "control", endsAt: now + dr.durationMs, drCategory: ability.drCategory });
target.dr.set(ability.drCategory, { applications: dr.applications, endedAt: now + dr.durationMs });
}
if (ability.defensiveMs) actor.effects.set("defensive", { id: "defensive", endsAt: now + ability.defensiveMs });
return { accepted: true };
if (actor.characterKit) return useArenaCharacterAbility(this, actor, abilityId, targetActorId, now, groundPosition);
return { accepted: false, reason: "actor-unavailable" };
}
damage(target, amount) {
damage(target, amount, school = "physical") {
const now = this.startedAt + this.tickNumber * ARENA_TICK_MS;
const reduction = target.effects.get("defensive")?.endsAt > now ? 0.5 : 0;
target.health = Math.max(0, target.health - Math.round(amount * (1 - reduction)));
if (target.health <= 0) {
target.alive = false;
target.movement = [0, 0];
target.casting = null;
}
damageArenaCharacter(this, target, amount, school, now);
}
dynamicObjects() {
@@ -268,6 +295,8 @@ export class ArenaSimulation {
this.updateActors(simulationNow);
this.applyMechanics(simulationNow);
this.checkResult();
} else if (this.phase === "preparation") {
for (const actor of this.actors.values()) tickArenaCharacter(this, actor, simulationNow, ARENA_TICK_MS / 1000);
}
if (this.tickNumber % (ARENA_TICK_RATE / ARENA_SNAPSHOT_RATE) === 0) this.onSnapshot(this.snapshot());
}
@@ -277,30 +306,33 @@ export class ArenaSimulation {
for (const actor of this.actors.values()) {
for (const [effectId, effect] of actor.effects) if (effect.endsAt <= now) actor.effects.delete(effectId);
for (const [abilityId, endsAt] of actor.cooldowns) if (endsAt <= now) actor.cooldowns.delete(abilityId);
actor.resource = Math.min(actor.maxResource, actor.resource + 3 * deltaSeconds);
tickArenaCharacter(this, actor, now, deltaSeconds);
if (!actor.alive) continue;
if (actor.casting?.completesAt <= now) {
const cast = actor.casting;
actor.casting = null;
if (cast.abilityId === "heal") {
const target = this.actor(cast.targetActorId);
if (target?.alive && target.team === actor.team && distance2d(actor, target) <= ABILITIES.heal.range && this.hasLineOfSight(actor, target)) {
const elapsed = Math.max(0, now - this.activeStartedAt);
target.health = Math.min(target.maxHealth, target.health + applyArenaHealing(ABILITIES.heal.healing, elapsed));
}
}
}
if (!actor.casting && !(actor.effects.get("control")?.endsAt > now)) this.moveActor(actor, deltaSeconds);
if (actor.isBot && actor.navigationPath?.length && !actor.casting) this.followBotPath(actor);
if (actor.combat.controlledUntil <= now) this.moveActor(actor, deltaSeconds);
if (actor.isBot && now >= actor.nextBotDecisionAt) this.botDecision(actor, now);
}
}
moveActor(actor, deltaSeconds) {
const speed = 7;
const statuses = actor.statuses ?? [];
const speed = statuses.some((status) => status.kind === "root") ? 0 : 7 * (1 - Math.max(0, ...statuses.filter((status) => status.kind === "slow").map((status) => status.magnitude ?? 0)));
const nextX = actor.position[0] + actor.movement[0] * speed * deltaSeconds;
const nextZ = actor.position[2] + actor.movement[1] * speed * deltaSeconds;
actor.position[0] = Math.max(this.stage.bounds.min[0], Math.min(this.stage.bounds.max[0], nextX));
actor.position[2] = Math.max(this.stage.bounds.min[2], Math.min(this.stage.bounds.max[2], nextZ));
const previous = actor.position;
actor.position = this.resolvePosition(actor, [nextX, previous[1], nextZ]);
if (actor.position[0] === previous[0] && actor.position[2] === previous[2]) {
actor.position = this.resolvePosition(actor, [nextX, previous[1], previous[2]]);
actor.position = this.resolvePosition(actor, [actor.position[0], previous[1], nextZ]);
}
if (Math.hypot(...actor.movement) > 0.01) actor.facing = Math.atan2(actor.movement[0], actor.movement[1]);
actor.groundY ??= actor.position[1];
if (actor.jumpVelocity || actor.position[1] > actor.groundY + .01) {
actor.jumpVelocity ??= 0;
actor.position[1] += actor.jumpVelocity * deltaSeconds;
actor.jumpVelocity -= 20 * deltaSeconds;
if (actor.position[1] <= actor.groundY) { actor.position[1] = actor.groundY; actor.jumpVelocity = 0; }
}
}
botDecision(actor, now) {
@@ -308,44 +340,86 @@ export class ArenaSimulation {
const profile = arenaAiProfile(rating);
actor.nextBotDecisionAt = now + (profile.reactionMinSeconds + this.random() * (profile.reactionMaxSeconds - profile.reactionMinSeconds)) * 1000;
const allies = [...this.actors.values()].filter((entry) => entry.team === actor.team && entry.alive);
const enemies = [...this.actors.values()].filter((entry) => entry.team !== actor.team && entry.alive && this.hasLineOfSight(actor, entry));
const enemies = [...this.actors.values()].filter((entry) => entry.team !== actor.team && entry.alive);
const target = enemies.sort((left, right) => left.health - right.health)[0];
if (!target) {
actor.movement = [0, 0];
return;
}
actor.targetActorId = target.id;
commandArenaCharacter(this, actor, "attack", now);
const lowAlly = allies.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0];
if (actor.role === "healer" && lowAlly.health < lowAlly.maxHealth * 0.86) {
actor.movement = [0, 0];
this.useAbility(actor, "heal", lowAlly.id);
if (actor.characterKit) {
if (actor.casting) { actor.movement = [0, 0]; return; }
const abilities = Object.values(actor.characterKit.abilities).filter((ability) => now >= (actor.cooldowns.get(ability.id) ?? 0));
const has = (ability, kind) => ability.effects.some((effect) => effect.kind === kind);
let candidates = [];
let castTarget = target;
if (actor.role === "healer" && lowAlly.health < lowAlly.maxHealth * .86) {
candidates = abilities.filter((ability) => (ability.target === "friendly" || ability.target === "any") && (has(ability, "heal") || has(ability, "hot")));
castTarget = lowAlly;
} else if (actor.health < actor.maxHealth * .4 && shouldArenaBotChooseCorrectly(profile.correctDefensiveChance, profile.minimumErrorChance, this.random)) {
candidates = abilities.filter((ability) => ability.target === "self" && (has(ability, "heal") || has(ability, "shield") || has(ability, "apply-aura")));
castTarget = actor;
} else {
if (target.casting && shouldArenaBotChooseCorrectly(profile.healerInterruptChance, profile.minimumErrorChance, this.random)) candidates.push(...abilities.filter((ability) => has(ability, "interrupt")));
candidates.push(...abilities.filter((ability) => ability.target === "hostile" && (has(ability, "damage") || has(ability, "finisher-damage") || has(ability, "dot"))));
}
const distance = distance2d(actor, castTarget);
const range = Math.max(4, ...candidates.map((ability) => ability.range.max));
const dx = castTarget.position[0] - actor.position[0], dz = castTarget.position[2] - actor.position[2];
actor.facing = Math.atan2(dx, dz);
const needsMovement = distance > Math.min(range - .5, 24) || !this.hasLineOfSight(actor, castTarget);
if (needsMovement) {
if (!actor.navigationPath?.length || now >= (actor.nextPathAt ?? 0)) {
actor.navigationPath = arenaNavigationPath(this.stage.id, actor.position, castTarget.position);
actor.nextPathAt = now + 2500;
}
actor.movement = [dx / Math.max(.001, distance), dz / Math.max(.001, distance)];
this.followBotPath(actor);
} else { actor.navigationPath = []; actor.movement = [0, 0]; }
actor.targetActorId = castTarget.id;
for (const ability of candidates) {
if (distance <= ability.range.max || ability.target === "self") {
if (this.useAbility(actor, ability.id, castTarget.id, ability.targeting?.shape === "ground" ? castTarget.position : undefined).accepted) {
if (actor.casting) actor.movement = [0, 0];
break;
}
}
}
return;
}
const castingHealer = enemies.find((entry) => entry.role === "healer" && entry.casting);
if (castingHealer && shouldArenaBotChooseCorrectly(profile.healerInterruptChance, profile.minimumErrorChance, this.random)) {
this.useAbility(actor, "interrupt", castingHealer.id);
return;
}
canWalkLine(actor, to) {
const probe = { ...actor, position: [...actor.position] };
const distance = distance2d(actor, { position: to });
const steps = Math.max(1, Math.ceil(distance / .5));
for (let step = 1; step <= steps; step++) {
const requested = [actor.position[0] + (to[0] - actor.position[0]) * step / steps, probe.position[1], actor.position[2] + (to[2] - actor.position[2]) * step / steps];
const resolved = this.resolvePosition(probe, requested);
if (Math.hypot(resolved[0] - requested[0], resolved[2] - requested[2]) > .01) return false;
// Keep the navmesh portals on ramps and drops. A straight shortcut can
// cross an upper floor, fall, and leave the bot below its next waypoint.
if (Math.abs((probe.groundY ?? resolved[1]) - actor.position[1]) > .2) return false;
probe.position = [resolved[0], probe.groundY ?? resolved[1], resolved[2]];
}
if (actor.health < actor.maxHealth * 0.4 && shouldArenaBotChooseCorrectly(profile.correctDefensiveChance, profile.minimumErrorChance, this.random)) {
this.useAbility(actor, "defensive", actor.id);
return;
}
if (shouldArenaBotChooseCorrectly(profile.coordinatedCcChance * 0.25, profile.minimumErrorChance, this.random)) {
const healer = enemies.find((entry) => entry.role === "healer");
if (healer && this.useAbility(actor, "control", healer.id).accepted) return;
}
const distance = distance2d(actor, target);
if (distance > 4.5) {
const dx = target.position[0] - actor.position[0];
const dz = target.position[2] - actor.position[2];
const length = Math.max(0.001, Math.hypot(dx, dz));
actor.movement = [dx / length, dz / length];
if (distance <= 30) this.useAbility(actor, "ranged-attack", target.id);
} else {
actor.movement = [0, 0];
this.useAbility(actor, "strike", target.id);
this.useAbility(actor, "attack", target.id);
return true;
}
followBotPath(actor) {
const path = actor.navigationPath;
if (!path?.length) return;
while (path.length > 1 && distance2d(actor, { position: path[0] }) < .7) path.shift();
// Smooth portals only across a collision-checked, continuously walkable line.
if ((actor.nextPathSmoothTick ?? 0) <= this.tickNumber) {
for (let index = Math.min(path.length - 1, 8); index > 0; index--) {
if (this.canWalkLine(actor, path[index])) { path.splice(0, index); break; }
}
actor.nextPathSmoothTick = this.tickNumber + 10;
}
const dx = path[0][0] - actor.position[0], dz = path[0][2] - actor.position[2], distance = Math.hypot(dx, dz);
actor.movement = distance < .2 ? [0, 0] : [dx / distance, dz / distance];
}
applyMechanics(now) {
@@ -417,6 +491,16 @@ export class ArenaSimulation {
dampening: this.phase === "active" || this.phase === "finished" ? arenaDampening(elapsed) : 0.1,
actors: [...this.actors.values()].map((actor) => ({
id: actor.id, name: actor.name, team: actor.team, role: actor.role,
characterId: actor.characterId,
avatar: actor.avatar, visualEquipment: actor.visualEquipment,
...(actor.characterKit ? {
classId: actor.characterKit.classId, level: actor.characterKit.level,
actionBindings: actor.characterKit.actionBindings, abilityIds: Object.keys(actor.characterKit.abilities),
resourceType: actor.characterKit.resource.type,
gcdRemainingMs: Math.max(0, actor.gcdUntil - now),
lastAbilityId: actor.lastAbilityId, abilityRevision: actor.abilityRevision,
combat: arenaCharacterSnapshot(actor, now),
} : {}),
isBot: actor.isBot, disconnected: actor.disconnected, alive: actor.alive,
position: [...actor.position], facing: actor.facing,
health: actor.health, maxHealth: actor.maxHealth,
@@ -424,11 +508,17 @@ export class ArenaSimulation {
targetActorId: actor.targetActorId,
cooldowns: Object.fromEntries([...actor.cooldowns].map(([id, endsAt]) => [id, Math.max(0, endsAt - now)])),
effects: [
...actor.auras.map((aura) => ({ id: aura.definition.name, icon: aura.definition.icon, disposition: aura.definition.disposition, remainingMs: aura.expiresAt == null ? 0 : Math.max(0, aura.expiresAt - now), stacks: aura.stacks })),
...(actor.statuses ?? []).map((status) => ({ id: status.kind, disposition: "debuff", remainingMs: Math.max(0, status.endsAt - now) })),
...[...actor.periodic.values()].map((effect) => ({ id: effect.id, remainingMs: Math.max(0, effect.nextAt - now) + (effect.ticks - 1) * effect.intervalMs })),
...(this.combatEffects ?? []).filter((effect) => effect.targetId === actor.id).map((effect) => ({ id: effect.sourceAbilityId, disposition: effect.kind === "dot" ? "debuff" : "buff", remainingMs: Math.max(0, effect.nextTickAt - now) + (effect.remainingTicks - 1) * effect.intervalMs })),
...[...actor.effects.values()].map((effect) => ({ id: effect.id, remainingMs: Math.max(0, effect.endsAt - now) })),
...(actor.casting ? [{ id: `casting:${actor.casting.abilityId}`, remainingMs: Math.max(0, actor.casting.completesAt - now) }] : []),
],
})),
dynamicObjects: this.dynamicObjects(),
combatPresentation: this.combatPresentation ?? [],
combatEvents: this.combatEvents ?? [],
};
}
}
+54
View File
@@ -0,0 +1,54 @@
import { createReadStream, readFileSync } from "node:fs";
import path from "node:path";
import { pipeline } from "node:stream";
import { createGunzip } from "node:zlib";
export function contentMimeType(logicalPath) {
return ({
".glb": "model/gltf-binary", ".json": "application/json", ".js": "text/javascript",
".wasm": "application/wasm", ".ktx2": "image/ktx2", ".png": "image/png",
".webp": "image/webp", ".avif": "image/avif", ".svg": "image/svg+xml",
".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".wav": "audio/wav", ".webm": "video/webm",
})[path.extname(logicalPath)] ?? "application/octet-stream";
}
function acceptsGzip(value = "") {
const encodings = new Map(String(value).split(",").map((entry) => {
const [name, ...parameters] = entry.trim().toLowerCase().split(";");
const quality = parameters.find((part) => part.trim().startsWith("q="));
return [name, quality ? Number(quality.trim().slice(2)) : 1];
}));
return (encodings.get("gzip") ?? encodings.get("*") ?? 0) > 0;
}
/** Resolve logical URLs to one immutable bundled object without expanding it on disk. */
export function createBundledContentHandler(root) {
let manifest;
return (request, response, pathname) => {
if (!["GET", "HEAD"].includes(request.method) || !/^\/(assets|basis|data)\//.test(pathname)) return false;
if (manifest === undefined) {
try { manifest = JSON.parse(readFileSync(path.join(root, "content/bootstrap-manifest.json"), "utf8")); }
catch (error) { if (error.code !== "ENOENT") throw error; manifest = null; }
}
const entry = manifest?.files[pathname];
if (!entry) return false;
const object = entry.download ?? entry;
if (!/^\/content\/bundled\/[a-f0-9]{64}\.[a-z0-9]+(?:\.g(?:z|zip))?$/.test(object.url)) return false;
const encoded = entry.download?.encoding === "gzip";
const sendGzip = encoded && acceptsGzip(request.headers["accept-encoding"]);
response.writeHead(200, {
"Content-Type": contentMimeType(pathname),
"Content-Length": sendGzip ? object.bytes : entry.bytes,
"Cache-Control": "public, max-age=3600",
"X-Content-Type-Options": "nosniff",
Vary: "Accept-Encoding",
...(sendGzip ? { "Content-Encoding": "gzip" } : {}),
});
if (request.method === "HEAD") { response.end(); return true; }
const stream = createReadStream(path.join(root, object.url.slice(1)));
const done = (error) => { if (error) response.destroy(error); };
if (encoded && !sendGzip) pipeline(stream, createGunzip(), response, done);
else pipeline(stream, response, done);
return true;
};
}
+48
View File
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { createServer, request } from "node:http";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { gzipSync, gunzipSync } from "node:zlib";
import { createBundledContentHandler } from "./bundled-content.mjs";
test("first-launch logical asset URLs serve shared gzip objects with lossless identity fallback", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "healer-bundled-server-"));
const original = Buffer.from("glTF original model bytes ".repeat(1000));
const compressed = gzipSync(original);
const objectUrl = `/content/bundled/${"a".repeat(64)}.glb.gzip`;
await mkdir(path.join(root, "content/bundled"), { recursive: true });
await writeFile(path.join(root, objectUrl.slice(1)), compressed);
await writeFile(path.join(root, "content/bootstrap-manifest.json"), JSON.stringify({ files: {
"/assets/game/dungeons/test/visual.glb": { bytes: original.length, download: { url: objectUrl, encoding: "gzip", bytes: compressed.length } },
} }));
const serve = createBundledContentHandler(root);
const server = createServer((req, res) => {
if (!serve(req, res, new URL(req.url, "http://localhost").pathname)) { res.writeHead(404); res.end(); }
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = server.address().port;
const get = (encoding, method = "GET") => new Promise((resolve, reject) => {
request({ host: "127.0.0.1", port, path: "/assets/game/dungeons/test/visual.glb?runtime=1", method, headers: { "Accept-Encoding": encoding } }, (response) => {
const chunks = [];
response.on("data", (chunk) => chunks.push(chunk));
response.on("end", () => resolve({ headers: response.headers, body: Buffer.concat(chunks) }));
}).on("error", reject).end();
});
try {
const gzip = await get("gzip");
assert.equal(gzip.headers["content-encoding"], "gzip");
assert.deepEqual(gzip.body, compressed);
assert.deepEqual(gunzipSync(gzip.body), original);
const identity = await get("gzip;q=0, *;q=1");
assert.equal(identity.headers["content-encoding"], undefined);
assert.deepEqual(identity.body, original);
const head = await get("gzip", "HEAD");
assert.equal(head.body.length, 0);
assert.equal(Number(head.headers["content-length"]), compressed.length);
} finally {
await new Promise((resolve) => server.close(resolve));
await rm(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,6 @@
var ql=Object.create;var xs=Object.defineProperty;var Rl=Object.getOwnPropertyDescriptor;var jl=Object.getOwnPropertyNames;var Dl=Object.getPrototypeOf,El=Object.prototype.hasOwnProperty;var Ze=(e,a,t)=>()=>{if(t)throw t[0];try{return e&&(a=e(e=0)),a}catch(i){throw t=[i],i}};var we=(e,a)=>()=>{try{return a||e((a={exports:{}}).exports,a),a.exports}catch(t){throw a=0,t}},Yo=(e,a)=>{for(var t in a)xs(e,t,{get:a[t],enumerable:!0})},Bl=(e,a,t,i)=>{if(a&&typeof a=="object"||typeof a=="function")for(let s of jl(a))!El.call(e,s)&&s!==t&&xs(e,s,{get:()=>a[s],enumerable:!(i=Rl(a,s))||i.enumerable});return e};var Ce=(e,a,t)=>(t=e!=null?ql(Dl(e)):{},Bl(a||!e||!e.__esModule?xs(t,"default",{value:e,enumerable:!0}):t,e));function fe(e,a={}){return{...Vl,feature:e,...a}}function G(e,a,t,i,s){return{skinColor:e,face:a,hairStyle:t,hairColor:i,feature:s}}function Be(e){let a=e.featureLabel??"Features";return{id:e.id,dbcId:e.dbcId,name:e.name,faction:e.faction,genders:e.genders,unrestrictedClasses:!0,sigil:e.sigil,accent:e.accent,description:e.description,labels:{male:fe(a),female:fe(a)},counts:{male:e.male,female:e.female??e.male}}}function Vi(e){return ai.find(a=>a.id===e)??ai[0]}function fa(e){return qa.find(a=>a.id===e)??qa[0]}function Xe(e){return fa(e).archetype}function Wl(e){return Vi(e).categoryId??"wow"}function As(e){return fa(e).categoryId??"wow"}function Nl(e){return Wl(e)==="rom"}function st(e){return As(e)==="rom"}function Qo(e,a){let t=Vi(e);if(Nl(e)){if(a&&a!=="rom")return[];let o=new Set(Ol[e]);return qa.filter(r=>o.has(r.id))}if(a==="rom")return[];let i=o=>a?As(o.id)===a:As(o.id)!=="rom";if(t.unrestrictedClasses)return qa.filter(o=>i(o));let s=t.dbcId;return qa.filter(o=>i(o)&&_l[o.id].includes(s))}function Zo(e){return Vi(e).genders??["male","female"]}function Xo(){return{skinColor:0,face:0,hairStyle:0,hairColor:0,feature:0}}function er(e,a,t=Math.random){let i=Vi(e).counts[a];return Object.fromEntries(Hl.map(s=>[s,Math.floor(t()*i[s])]))}var Vl,ai,Fl,qs,qa,tt,Rs,it,_l,Ol,Hl,sa=Ze(()=>{Vl={skinColor:"Skin Color",face:"Face",hairStyle:"Hair Style",hairColor:"Hair Color",feature:"Features"};ai=[{id:"human",dbcId:1,name:"Human",faction:"alliance",sigil:"HU",accent:"#6f9dd7",description:"Versatile champions of the Alliance kingdoms.",labels:{male:fe("Facial Hair"),female:fe("Piercings")},counts:{male:G(10,12,17,10,9),female:G(10,15,24,10,7)}},{id:"orc",dbcId:2,name:"Orc",faction:"horde",sigil:"OR",accent:"#bd6450",description:"Unyielding warriors bound by strength and honor.",labels:{male:fe("Facial Hair"),female:fe("Piercings")},counts:{male:G(9,9,12,8,11),female:G(9,9,13,8,7)}},{id:"dwarf",dbcId:3,name:"Dwarf",faction:"alliance",sigil:"DW",accent:"#c58c5a",description:"Hardy mountain folk, explorers, and steadfast allies.",labels:{male:fe("Facial Hair"),female:fe("Piercings")},counts:{male:G(9,10,16,10,11),female:G(9,10,19,10,6)}},{id:"night-elf",dbcId:4,name:"Night Elf",faction:"alliance",sigil:"NE",accent:"#9275cf",description:"Ancient guardians attuned to shadow and nature.",labels:{male:fe("Facial Hair"),female:fe("Markings")},counts:{male:G(9,9,12,8,6),female:G(9,9,12,8,10)}},{id:"undead",dbcId:5,name:"Undead",faction:"horde",sigil:"UD",accent:"#8ba39d",description:"Forsaken survivors wielding grim determination.",labels:{male:fe("Features"),female:fe("Features")},counts:{male:G(6,10,15,10,17),female:G(6,10,15,10,8)}},{id:"tauren",dbcId:6,name:"Tauren",faction:"horde",sigil:"TA",accent:"#ad805c",description:"Powerful nomads guided by the Earth Mother.",labels:{male:fe("Facial Hair",{hairStyle:"Horn Style",hairColor:"Horn Color"}),female:fe("Hair",{hairStyle:"Horn Style",hairColor:"Horn Color"})},counts:{male:G(19,5,13,3,7),female:G(11,4,12,3,5)}},{id:"gnome",dbcId:7,name:"Gnome",faction:"alliance",sigil:"GN",accent:"#d28faf",description:"Brilliant inventors with courage beyond their stature.",labels:{male:fe("Facial Hair"),female:fe("Earrings")},counts:{male:G(5,7,12,9,8),female:G(5,7,12,9,7)}},{id:"troll",dbcId:8,name:"Troll",faction:"horde",sigil:"TR",accent:"#50a99a",description:"Fierce jungle hunters carrying an ancient legacy.",labels:{male:fe("Tusks"),female:fe("Tusks")},counts:{male:G(6,5,10,10,11),female:G(6,6,10,10,6)}},{id:"blood-elf",dbcId:10,name:"Blood Elf",faction:"horde",sigil:"BE",accent:"#d75f76",description:"Arcane survivors rebuilding a proud magical dynasty.",labels:{male:fe("Facial Hair"),female:fe("Earrings")},counts:{male:G(10,10,16,10,10),female:G(10,10,20,10,11)}},{id:"draenei",dbcId:11,name:"Draenei",faction:"alliance",sigil:"DR",accent:"#6986d8",description:"Exiled voyagers devoted to hope and the Light.",labels:{male:fe("Facial Hair"),female:fe("Horn Style")},counts:{male:G(14,10,14,7,8),female:G(12,10,16,7,7)}},Be({id:"goblin",dbcId:9,name:"Goblin",faction:"horde",sigil:"GO",accent:"#84ad4b",description:"Cunning cartels turn invention, nerve, and explosive ambition into power.",male:G(16,5,19,9,24),female:G(9,10,18,9,24)}),Be({id:"zandalari-troll",dbcId:12,name:"Zandalari TrLine truncated
/*! Bundled license information:
@capacitor/core/dist/index.js:
(*! Capacitor: https://capacitorjs.com/ - MIT License *)
*/
+1
View File
@@ -0,0 +1 @@
{"nagrand-arena":{"navigation":[-24.812,12.3,-40.042,-18.812,15.3,-40.842,-25.037,12.2,-41.192,-18.812,15.3,-40.842,-24.375,12.5,-49.678,-24.424,12.5,-44.649,-24.375,12.5,-49.678,-24.994,12.3,-50.66,-25.632,12.2,-49.462,-24.994,12.3,-50.66,-25.612,12.4,-51.642,-26.412,12.4,-50.842,-26.412,12.4,-50.842,-26.612,12.4,-49.242,-25.632,12.2,-49.462,-26.612,12.4,-49.242,-25.487,12.2,-43.492,-25.604,12.2,-48.253,-25.487,12.2,-43.492,-25.037,12.2,-41.192,-24.424,12.5,-44.649,-25.037,12.2,-41.192,-18.812,15.3,-40.842,-24.424,12.5,-44.649,-24.424,12.5,-44.649,-24.375,12.5,-49.678,-25.604,12.2,-48.253,-25.632,12.2,-49.462,-24.994,12.3,-50.66,-26.412,12.4,-50.842,-24.375,12.5,-49.678,-25.632,12.2,-49.462,-25.604,12.2,-48.253,-25.632,12.2,-49.462,-26.612,12.4,-49.242,-25.604,12.2,-48.253,-25.604,12.2,-48.253,-25.487,12.2,-43.492,-24.424,12.5,-44.649,-24.812,12.3,-40.042,-25.037,12.2,-41.192,-25.612,12.2,-41.985,-25.037,12.2,-41.192,-25.487,12.2,-43.492,-25.612,12.2,-41.985,-25.487,12.2,-43.492,-27.612,12.6,-46.842,-25.612,12.2,-41.985,-25.487,12.2,-43.492,-27.412,12.6,-48.442,-27.612,12.6,-46.842,-25.487,12.2,-43.492,-26.612,12.4,-49.242,-27.412,12.6,-48.442,-25.612,12.5,-52.842,-24.212,12.9,-55.242,-24.812,12.9,-54.842,-12.72,17.9,-49.719,-12.812,17.9,-56.642,-18.411,15.6,-49.426,-12.812,17.9,-56.642,-23.072,13.2,-55.382,-18.411,15.6,-49.426,-23.072,13.2,-55.382,-24.212,12.9,-55.242,-25.612,12.5,-52.842,-25.612,12.5,-52.842,-25.612,12.4,-51.642,-24.994,12.3,-50.66,-24.994,12.3,-50.66,-24.375,12.5,-49.678,-23.072,13.2,-55.382,-24.375,12.5,-49.678,-18.812,15.3,-40.842,-18.411,15.6,-49.426,-18.812,15.3,-40.842,-12.612,18,-41.642,-18.411,15.6,-49.426,-12.612,18,-41.642,-12.72,17.9,-49.719,-18.411,15.6,-49.426,-18.411,15.6,-49.426,-23.072,13.2,-55.382,-24.375,12.5,-49.678,-23.072,13.2,-55.382,-25.612,12.5,-52.842,-24.994,12.3,-50.66,-25.012,12.2,-39.642,-24.812,12.3,-40.042,-25.498,12.2,-40.585,-24.812,12.3,-40.042,-25.612,12.2,-41.985,-25.498,12.2,-40.585,-25.612,12.2,-41.985,-28.412,12.7,-46.242,-25.498,12.2,-40.585,-25.612,12.2,-41.985,-27.612,12.6,-46.842,-28.412,12.7,-46.242,-24.212,12.9,-55.242,-23.072,13.2,-55.382,-23.812,13.1,-56.842,-23.072,13.2,-55.382,-22.712,13.4,-56.822,-23.812,13.1,-56.842,-23.072,13.2,-55.382,-12.812,17.9,-56.642,-22.712,13.4,-56.822,21.988,13.6,-56.842,17.188,15.4,-56.642,18.768,14.8,-55.462,17.188,15.4,-56.642,17.157,15.6,-54.273,18.768,14.8,-55.462,17.157,15.6,-54.273,16.988,16,-41.242,18.772,15,-48.227,16.988,16,-41.242,21.788,13.8,-40.642,18.772,15,-48.227,21.788,13.8,-40.642,21.96,13.8,-54.527,18.772,15,-48.227,21.96,13.8,-54.527,21.988,13.6,-56.842,20,14.4,-54.227,21.988,13.6,-56.842,18.768,14.8,-55.462,20,14.4,-54.227,18.768,14.8,-55.462,17.157,15.6,-54.273,20,14.4,-54.227,17.157,15.6,-54.273,18.772,15,-48.227,20,14.4,-54.227,18.772,15,-48.227,21.96,13.8,-54.527,20,14.4,-54.227,21.788,13.8,-40.642,23.388,13.1,-40.442,24.455,12.8,-42.468,24.455,12.8,-42.468,28.188,13.2,-49.562,24.796,12.8,-48.229,28.188,13.2,-49.562,30.321,13.2,-53.615,28.376,13.2,-53.041,30.321,13.2,-53.615,31.388,13.4,-55.642,29.588,13.1,-56.842,29.588,13.1,-56.842,23.074,13.2,-56.842,24.772,12.8,-53.027,23.074,13.2,-56.842,21.988,13.6,-56.842,21.96,13.8,-54.527,21.96,13.8,-54.527,21.788,13.8,-40.642,23.572,13,-47.052,21.788,13.8,-40.642,24.455,12.8,-42.468,23.572,13,-47.052,24.796,12.8,-48.229,28.188,13.2,-49.562,24.792,12.8,-51.824,24.455,12.8,-42.468,24.796,12.8,-48.229,23.572,13,-47.052,28.376,13.2,-53.041,30.321,13.2,-53.615,29.588,13.1,-56.842,28.188,13.2,-49.562,28.376,13.2,-53.041,24.792,12.8,-51.824,24.772,12.8,-53.027,23.074,13.2,-56.842,23.584,13,-53.036,29.588,13.1,-56.842,24.772,12.8,-53.027,28.376,13.2,-53.041,23.074,13.2,-56.842,21.96,13.8,-54.527,23.584,13,-53.036,21.96,13.8,-54.527,23.572,13,-47.052,23.584,13,-53.036,24.796,12.8,-48.229,24.792,12.8,-51.824,23.572,13,-47.052,24.792,12.8,-51.824,28.376,13.2,-53.041,24.772,12.8,-53.027,24.772,12.8,-53.027,23.584,13,-53.036,24.792,12.8,-51.824,23.584,13,-53.036,23.572,13,-47.052,24.792,12.8,-51.824,-4.612,20.9,-51.842,-4.012,20.9,-52.442,-3.612,20.9,-56.442,-12.612,18,-41.642,-10.412,19,-41.842,-11.203,18.7,-44.661,-10.412,19,-41.842,-7.512,20.9,-46.842,-10.015,19.5,-44.653,-7.512,20.9,-46.842,-4.612,20.9,-51.842,-7.62,21,-54.223,-4.612,20.9,-51.842,-3.612,20.9,-56.442,-7.62,21,-54.223,-3.612,20.9,-56.442,-8.212,21.1,-56.542,-7.62,21,-54.223,-8.212,21.1,-56.542,-12.812,17.9,-56.642,-8.808,20.6,-54.231,-12.812,17.9,-56.642,-12.72,17.9,-49.719,-8.808,20.6,-54.231,-12.72,17.9,-49.719,-12.612,18,-41.642,-11.203,18.7,-44.661,-11.203,18.7,-44.661,-10.412,19,-41.842,-10.015,19.5,-44.653,-10.015,19.5,-44.653,-7.512,20.9,-46.842,-9.995,19.6,-47.057,-7.512,20.9,-46.842,-7.62,21,-54.223,-8.808,20.6,-54.231,-7.62,21,-54.223,-8.212,21.1,-56.542,-8.808,20.6,-54.231,-8.808,20.6,-54.231,-12.72,17.9,-49.719,-9.995,19.6,-47.057,-12.72,17.9,-49.719,-11.203,18.7,-44.661,-9.995,19.6,-47.057,-11.203,18.7,-44.661,-10.015,19.5,-44.653,-9.995,19.6,-47.057,-9.995,19.6,-47.057,-7.Line truncated
+1
View File
@@ -0,0 +1 @@
{"nagrand-arena":[-24.812,12.3,-40.042,-18.812,15.3,-40.842,-25.037,12.2,-41.192,-18.812,15.3,-40.842,-24.375,12.5,-49.678,-24.424,12.5,-44.649,-24.375,12.5,-49.678,-24.994,12.3,-50.66,-25.632,12.2,-49.462,-24.994,12.3,-50.66,-25.612,12.4,-51.642,-26.412,12.4,-50.842,-26.412,12.4,-50.842,-26.612,12.4,-49.242,-25.632,12.2,-49.462,-26.612,12.4,-49.242,-25.487,12.2,-43.492,-25.604,12.2,-48.253,-25.487,12.2,-43.492,-25.037,12.2,-41.192,-24.424,12.5,-44.649,-25.037,12.2,-41.192,-18.812,15.3,-40.842,-24.424,12.5,-44.649,-24.424,12.5,-44.649,-24.375,12.5,-49.678,-25.604,12.2,-48.253,-25.632,12.2,-49.462,-24.994,12.3,-50.66,-26.412,12.4,-50.842,-24.375,12.5,-49.678,-25.632,12.2,-49.462,-25.604,12.2,-48.253,-25.632,12.2,-49.462,-26.612,12.4,-49.242,-25.604,12.2,-48.253,-25.604,12.2,-48.253,-25.487,12.2,-43.492,-24.424,12.5,-44.649,-24.812,12.3,-40.042,-25.037,12.2,-41.192,-25.612,12.2,-41.985,-25.037,12.2,-41.192,-25.487,12.2,-43.492,-25.612,12.2,-41.985,-25.487,12.2,-43.492,-27.612,12.6,-46.842,-25.612,12.2,-41.985,-25.487,12.2,-43.492,-27.412,12.6,-48.442,-27.612,12.6,-46.842,-25.487,12.2,-43.492,-26.612,12.4,-49.242,-27.412,12.6,-48.442,-25.612,12.5,-52.842,-24.212,12.9,-55.242,-24.812,12.9,-54.842,-12.72,17.9,-49.719,-12.812,17.9,-56.642,-18.411,15.6,-49.426,-12.812,17.9,-56.642,-23.072,13.2,-55.382,-18.411,15.6,-49.426,-23.072,13.2,-55.382,-24.212,12.9,-55.242,-25.612,12.5,-52.842,-25.612,12.5,-52.842,-25.612,12.4,-51.642,-24.994,12.3,-50.66,-24.994,12.3,-50.66,-24.375,12.5,-49.678,-23.072,13.2,-55.382,-24.375,12.5,-49.678,-18.812,15.3,-40.842,-18.411,15.6,-49.426,-18.812,15.3,-40.842,-12.612,18,-41.642,-18.411,15.6,-49.426,-12.612,18,-41.642,-12.72,17.9,-49.719,-18.411,15.6,-49.426,-18.411,15.6,-49.426,-23.072,13.2,-55.382,-24.375,12.5,-49.678,-23.072,13.2,-55.382,-25.612,12.5,-52.842,-24.994,12.3,-50.66,-25.012,12.2,-39.642,-24.812,12.3,-40.042,-25.498,12.2,-40.585,-24.812,12.3,-40.042,-25.612,12.2,-41.985,-25.498,12.2,-40.585,-25.612,12.2,-41.985,-28.412,12.7,-46.242,-25.498,12.2,-40.585,-25.612,12.2,-41.985,-27.612,12.6,-46.842,-28.412,12.7,-46.242,-24.212,12.9,-55.242,-23.072,13.2,-55.382,-23.812,13.1,-56.842,-23.072,13.2,-55.382,-22.712,13.4,-56.822,-23.812,13.1,-56.842,-23.072,13.2,-55.382,-12.812,17.9,-56.642,-22.712,13.4,-56.822,21.988,13.6,-56.842,17.188,15.4,-56.642,18.768,14.8,-55.462,17.188,15.4,-56.642,17.157,15.6,-54.273,18.768,14.8,-55.462,17.157,15.6,-54.273,16.988,16,-41.242,18.772,15,-48.227,16.988,16,-41.242,21.788,13.8,-40.642,18.772,15,-48.227,21.788,13.8,-40.642,21.96,13.8,-54.527,18.772,15,-48.227,21.96,13.8,-54.527,21.988,13.6,-56.842,20,14.4,-54.227,21.988,13.6,-56.842,18.768,14.8,-55.462,20,14.4,-54.227,18.768,14.8,-55.462,17.157,15.6,-54.273,20,14.4,-54.227,17.157,15.6,-54.273,18.772,15,-48.227,20,14.4,-54.227,18.772,15,-48.227,21.96,13.8,-54.527,20,14.4,-54.227,21.788,13.8,-40.642,23.388,13.1,-40.442,24.455,12.8,-42.468,24.455,12.8,-42.468,28.188,13.2,-49.562,24.796,12.8,-48.229,28.188,13.2,-49.562,30.321,13.2,-53.615,28.376,13.2,-53.041,30.321,13.2,-53.615,31.388,13.4,-55.642,29.588,13.1,-56.842,29.588,13.1,-56.842,23.074,13.2,-56.842,24.772,12.8,-53.027,23.074,13.2,-56.842,21.988,13.6,-56.842,21.96,13.8,-54.527,21.96,13.8,-54.527,21.788,13.8,-40.642,23.572,13,-47.052,21.788,13.8,-40.642,24.455,12.8,-42.468,23.572,13,-47.052,24.796,12.8,-48.229,28.188,13.2,-49.562,24.792,12.8,-51.824,24.455,12.8,-42.468,24.796,12.8,-48.229,23.572,13,-47.052,28.376,13.2,-53.041,30.321,13.2,-53.615,29.588,13.1,-56.842,28.188,13.2,-49.562,28.376,13.2,-53.041,24.792,12.8,-51.824,24.772,12.8,-53.027,23.074,13.2,-56.842,23.584,13,-53.036,29.588,13.1,-56.842,24.772,12.8,-53.027,28.376,13.2,-53.041,23.074,13.2,-56.842,21.96,13.8,-54.527,23.584,13,-53.036,21.96,13.8,-54.527,23.572,13,-47.052,23.584,13,-53.036,24.796,12.8,-48.229,24.792,12.8,-51.824,23.572,13,-47.052,24.792,12.8,-51.824,28.376,13.2,-53.041,24.772,12.8,-53.027,24.772,12.8,-53.027,23.584,13,-53.036,24.792,12.8,-51.824,23.584,13,-53.036,23.572,13,-47.052,24.792,12.8,-51.824,-4.612,20.9,-51.842,-4.012,20.9,-52.442,-3.612,20.9,-56.442,-12.612,18,-41.642,-10.412,19,-41.842,-11.203,18.7,-44.661,-10.412,19,-41.842,-7.512,20.9,-46.842,-10.015,19.5,-44.653,-7.512,20.9,-46.842,-4.612,20.9,-51.842,-7.62,21,-54.223,-4.612,20.9,-51.842,-3.612,20.9,-56.442,-7.62,21,-54.223,-3.612,20.9,-56.442,-8.212,21.1,-56.542,-7.62,21,-54.223,-8.212,21.1,-56.542,-12.812,17.9,-56.642,-8.808,20.6,-54.231,-12.812,17.9,-56.642,-12.72,17.9,-49.719,-8.808,20.6,-54.231,-12.72,17.9,-49.719,-12.612,18,-41.642,-11.203,18.7,-44.661,-11.203,18.7,-44.661,-10.412,19,-41.842,-10.015,19.5,-44.653,-10.015,19.5,-44.653,-7.512,20.9,-46.842,-9.995,19.6,-47.057,-7.512,20.9,-46.842,-7.62,21,-54.223,-8.808,20.6,-54.231,-7.62,21,-54.223,-8.212,21.1,-56.542,-8.808,20.6,-54.231,-8.808,20.6,-54.231,-12.72,17.9,-49.719,-9.995,19.6,-47.057,-12.72,17.9,-49.719,-11.203,18.7,-44.661,-9.995,19.6,-47.057,-11.203,18.7,-44.661,-10.015,19.5,-44.653,-9.995,19.6,-47.057,-9.995,19.6,-47.057,-7.512,20.9,-46.8Line truncated
+6
View File
@@ -7,6 +7,7 @@ import { createArenaService } from "./arena-service.mjs";
import { publicManastormAdminConfig } from "./manastorm-config.mjs";
import { createOnlineSessionManager } from "./online-session.mjs";
import { upgradeArenaWebSocket } from "./websocket.mjs";
import { createBundledContentHandler } from "./bundled-content.mjs";
const MIME_TYPES = new Map([
[".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"],
@@ -148,6 +149,7 @@ function createAuthLimiter() {
export function createGameServer(options = {}) {
const staticDir = path.resolve(options.staticDir ?? process.env.STATIC_DIR ?? "dist");
const contentDir = path.resolve(options.contentDir ?? process.env.CONTENT_DIR ?? "content");
const serveBundledContent = createBundledContentHandler(staticDir);
const allowedOrigins = parseAllowedOrigins(options.corsOrigins ?? process.env.CORS_ORIGINS);
const bodyLimit = Math.max(1024, Number(options.bodyLimit ?? process.env.MAX_JSON_BODY_BYTES ?? 5 * 1024 * 1024));
const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true";
@@ -344,6 +346,10 @@ export function createGameServer(options = {}) {
json(response, 404, { error: "API route not found.", code: "not_found" });
return;
}
if (serveBundledContent(request, response, url.pathname)) return;
if (url.pathname === "/content/bootstrap-manifest.json" || url.pathname.startsWith("/content/bundled/")) {
if (serveStatic(request, response, staticDir, url.pathname, { spaFallback: false, noCache: true })) return;
}
if (url.pathname === "/content" || url.pathname.startsWith("/content/")) {
const contentPath = url.pathname.slice("/content".length) || "/manifest.json";
const served = serveStatic(request, response, contentDir, contentPath, {