Update Healer Man server
This commit is contained in:
1 parent
ab1434a3b8
commit
e284f4fc76
60 files changed
+2704
-118
No files matched your search
@@ -0,0 +1,31 @@
|
||||
# Bite-Sized balance sweep
|
||||
|
||||
Run the fixed Ready Discipline Priest party against every selectable boss in the
|
||||
12 authored Bite-Sized dungeons at each supported level:
|
||||
|
||||
```powershell
|
||||
npx vite-node --mode balance-lab scripts/bite-sized-balance/run.ts --all-levels --output artifacts/bite-sized-balance/final-measured-levels-1-80.json
|
||||
```
|
||||
|
||||
Add `--resume` to continue a partially completed output file. Use `--levels 60`
|
||||
or `--dungeons bite-sized-hoto-teeth` for a focused check. Add `--trash` to also
|
||||
measure one compact 4–6 mob pull per selected dungeon; `--trash-only` skips
|
||||
bosses.
|
||||
|
||||
The report records every boss's kill time, HP, outcome, ability damage, and
|
||||
five-seat damage totals. It uses the live companion AI and a deterministic
|
||||
baseline Priest rotation with a fixed party seed. Boss attacks, travel, and
|
||||
real scene navigation are excluded. The trash diagnostic clusters imported
|
||||
mobs into an ideal visible pull, so it cannot validate live pathing.
|
||||
|
||||
Render the report with the Codex bundled Python, which includes Pillow:
|
||||
|
||||
```powershell
|
||||
$python = Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe'
|
||||
& $python scripts/bite-sized-balance/plot.py artifacts/bite-sized-balance/final-measured-levels-1-80.json --output artifacts/bite-sized-balance/final
|
||||
```
|
||||
|
||||
The two PNGs show measured boss times across levels 1–80 and the five-seat
|
||||
effective damage share for every boss at level 60. Pass `--seat-level 40` (or
|
||||
any other measured level) to render that level's per-boss five-seat chart. The
|
||||
source JSON preserves the five-seat breakdown at every measured level.
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Render measured Bite-Sized boss fight times and five-seat damage as PNGs."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
SEAT_COLORS = ("#5a9bd5", "#8c76c7", "#e2a34f", "#d96276", "#68b596")
|
||||
|
||||
|
||||
def font(size):
|
||||
for name in ("arial.ttf", "DejaVuSans.ttf"):
|
||||
try:
|
||||
return ImageFont.truetype(name, size)
|
||||
except OSError:
|
||||
pass
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def label(boss):
|
||||
dungeon = boss["dungeonId"].replace("bite-sized-", "").replace("-", " ").title()
|
||||
return f"{dungeon} · {boss['name']}"
|
||||
|
||||
|
||||
def time_color(seconds):
|
||||
if seconds is None:
|
||||
return "#353c48"
|
||||
if seconds < 25:
|
||||
return "#5388bf"
|
||||
if seconds <= 45:
|
||||
return "#54aa79"
|
||||
if seconds <= 60:
|
||||
return "#e2b34f"
|
||||
return "#d96460"
|
||||
|
||||
|
||||
def render_times(data, output):
|
||||
bosses = data["bosses"]
|
||||
levels = data["levels"]
|
||||
rows = {(row["dungeonId"], row["bossId"], row["level"]): row for row in data["results"]}
|
||||
x0, y0, cell_w, cell_h = 370, 105, 14, 19
|
||||
width = x0 + len(levels) * cell_w + 130
|
||||
height = y0 + len(bosses) * cell_h + 90
|
||||
image = Image.new("RGB", (width, height), "#f7f7f5")
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.text((20, 12), "Bite-Sized boss kill times by level", font=font(24), fill="#17212b")
|
||||
draw.text((20, 46), "Measured 5-seat Discipline Priest party · normal difficulty · isolated boss, no travel or boss attacks",
|
||||
font=font(13), fill="#4a5562")
|
||||
for x in levels:
|
||||
if x == 1 or x % 10 == 0 or x == 80:
|
||||
cx = x0 + levels.index(x) * cell_w
|
||||
draw.text((cx - 4, y0 - 25), str(x), font=font(11), fill="#334155")
|
||||
draw.line((cx, y0 - 3, cx, y0 + len(bosses) * cell_h), fill="#d8dce0", width=1)
|
||||
for index, boss in enumerate(bosses):
|
||||
y = y0 + index * cell_h
|
||||
if index and bosses[index - 1]["dungeonId"] != boss["dungeonId"]:
|
||||
draw.line((15, y - 1, width - 20, y - 1), fill="#9ca9b2", width=1)
|
||||
draw.text((20, y + 2), label(boss)[:47], font=font(11), fill="#24303a")
|
||||
times = []
|
||||
for level_index, level in enumerate(levels):
|
||||
row = rows.get((boss["dungeonId"], boss["id"], level))
|
||||
x = x0 + level_index * cell_w
|
||||
seconds = row["seconds"] if row else None
|
||||
draw.rectangle((x + 1, y + 1, x + cell_w - 1, y + cell_h - 2),
|
||||
fill=time_color(seconds) if row else "#dce0e4")
|
||||
if row and seconds is not None:
|
||||
times.append(seconds)
|
||||
if times:
|
||||
draw.text((x0 + len(levels) * cell_w + 12, y + 2), f"{min(times):.0f}–{max(times):.0f}s",
|
||||
font=font(11), fill="#334155")
|
||||
y = y0 + len(bosses) * cell_h + 18
|
||||
for index, (color, caption) in enumerate((
|
||||
("#5388bf", "<25s"), ("#54aa79", "25–45s target"),
|
||||
("#e2b34f", "45–60s"), ("#d96460", ">60s"),
|
||||
("#353c48", "timeout"), ("#dce0e4", "unmeasured"),
|
||||
)):
|
||||
x = 20 + index * 175
|
||||
draw.rectangle((x, y, x + 18, y + 15), fill=color)
|
||||
draw.text((x + 25, y), caption, font=font(12), fill="#334155")
|
||||
image.save(output)
|
||||
|
||||
|
||||
def render_seats(data, output, level=60):
|
||||
bosses = data["bosses"]
|
||||
rows = {(row["dungeonId"], row["bossId"]): row for row in data["results"] if row["level"] == level}
|
||||
x0, y0, bar_w, cell_h = 370, 125, 820, 21
|
||||
width, height = x0 + bar_w + 150, y0 + len(bosses) * cell_h + 70
|
||||
image = Image.new("RGB", (width, height), "#f7f7f5")
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.text((20, 12), f"Bite-Sized boss damage by party seat · level {level}", font=font(24), fill="#17212b")
|
||||
draw.text((20, 46), "Measured effective boss damage · fixed Ready Discipline Priest party · each row totals 100%",
|
||||
font=font(13), fill="#4a5562")
|
||||
for index, name in enumerate(("Priest player", "Prot Warrior", "MM Hunter", "Fire Mage", "Assassination Rogue")):
|
||||
x = 20 + index * 225
|
||||
draw.rectangle((x, 80, x + 16, 95), fill=SEAT_COLORS[index])
|
||||
draw.text((x + 22, 79), name, font=font(12), fill="#334155")
|
||||
for index, boss in enumerate(bosses):
|
||||
y = y0 + index * cell_h
|
||||
if index and bosses[index - 1]["dungeonId"] != boss["dungeonId"]:
|
||||
draw.line((15, y - 1, width - 20, y - 1), fill="#9ca9b2", width=1)
|
||||
draw.text((20, y + 2), label(boss)[:47], font=font(11), fill="#24303a")
|
||||
row = rows.get((boss["dungeonId"], boss["id"]))
|
||||
if not row:
|
||||
continue
|
||||
damages = [row["damageByActor"].get(seat["id"], 0) for seat in row["seats"]]
|
||||
total = max(sum(damages), 1)
|
||||
offset = x0
|
||||
for seat_index, damage in enumerate(damages):
|
||||
segment = round(bar_w * damage / total)
|
||||
if segment:
|
||||
draw.rectangle((offset, y + 2, offset + segment - 1, y + cell_h - 3),
|
||||
fill=SEAT_COLORS[seat_index])
|
||||
offset += segment
|
||||
draw.text((x0 + bar_w + 12, y + 2),
|
||||
f"{row['seconds']:.1f}s" if row["seconds"] is not None else "timeout",
|
||||
font=font(11), fill="#334155")
|
||||
image.save(output)
|
||||
|
||||
|
||||
def render_summary(data, output):
|
||||
rows = data["results"]
|
||||
levels = data["levels"]
|
||||
bosses = data["bosses"]
|
||||
measured = [row for row in rows if row["seconds"] is not None]
|
||||
timeouts = [row for row in rows if row["seconds"] is None]
|
||||
low = [row for row in measured if row["seconds"] < 25]
|
||||
high = [row for row in measured if row["seconds"] > 45]
|
||||
middle = [row for row in measured if 25 <= row["seconds"] <= 45]
|
||||
lines = [
|
||||
"# Bite-Sized boss balance sweep",
|
||||
"",
|
||||
f"- Measured fights: {len(rows)} of {len(levels) * len(bosses)} ({len(bosses)} bosses × {len(levels)} levels).",
|
||||
f"- Target window (25–45 s): {len(middle)}; under 25 s: {len(low)}; over 45 s: {len(high)}; timeouts: {len(timeouts)}.",
|
||||
"- Method: fixed Ready Discipline Priest party, normal difficulty, live companion AI and deterministic player rotation.",
|
||||
"- Limits: isolated stationary boss, no boss attacks, no travel, and one party seed. These are measured simulations, not live clear times.",
|
||||
"",
|
||||
"## Outlier levels",
|
||||
"",
|
||||
"| Level | Under 25 s | Over 45 s | Timeouts | Fastest | Slowest |",
|
||||
"| ---: | ---: | ---: | ---: | ---: | ---: |",
|
||||
]
|
||||
for level in levels:
|
||||
level_rows = [row for row in rows if row["level"] == level]
|
||||
level_times = [row["seconds"] for row in level_rows if row["seconds"] is not None]
|
||||
under = sum(row["seconds"] is not None and row["seconds"] < 25 for row in level_rows)
|
||||
over = sum(row["seconds"] is not None and row["seconds"] > 45 for row in level_rows)
|
||||
timed_out = sum(row["seconds"] is None for row in level_rows)
|
||||
if under or over or timed_out:
|
||||
lines.append(f"| {level} | {under} | {over} | {timed_out} | "
|
||||
f"{min(level_times):.1f}s | {max(level_times):.1f}s |")
|
||||
level_60 = [row for row in measured if row["level"] == 60]
|
||||
if level_60:
|
||||
shares = [[] for _ in range(5)]
|
||||
for row in level_60:
|
||||
amounts = [row["damageByActor"].get(seat["id"], 0) for seat in row["seats"]]
|
||||
total = max(sum(amounts), 1)
|
||||
for index, amount in enumerate(amounts):
|
||||
shares[index].append(amount / total * 100)
|
||||
lines += [
|
||||
"",
|
||||
"## Level 60",
|
||||
"",
|
||||
f"- Kill-time range: {min(row['seconds'] for row in level_60):.1f}–{max(row['seconds'] for row in level_60):.1f} s; median {statistics.median(row['seconds'] for row in level_60):.1f} s.",
|
||||
"- Mean per-boss effective damage share: "
|
||||
+ ", ".join(f"{name} {statistics.mean(share):.1f}%" for name, share in zip(
|
||||
("Priest player", "Prot Warrior", "MM Hunter", "Fire Mage", "Assassination Rogue"), shares)) + ".",
|
||||
]
|
||||
output.write_text("\n".join(lines).rstrip() + "\n", encoding="utf8")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("input", type=Path)
|
||||
parser.add_argument("--output", type=Path, default=None)
|
||||
parser.add_argument("--seat-level", type=int, default=60)
|
||||
arguments = parser.parse_args()
|
||||
data = json.loads(arguments.input.read_text(encoding="utf8"))
|
||||
if arguments.seat_level not in data["levels"]:
|
||||
parser.error("--seat-level must be one of the measured levels in the report")
|
||||
output = arguments.output or arguments.input.parent
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
render_times(data, output / "boss-kill-times-levels-1-80.png")
|
||||
render_seats(data, output / f"boss-damage-shares-level-{arguments.seat_level}.png",
|
||||
arguments.seat_level)
|
||||
render_summary(data, output / "summary.md")
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,141 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { loadCanonicalBenchmarkBuilds } from "../../src/game/benchmark/benchmarkBuilds";
|
||||
import { BENCHMARK_POLICY_ID } from "../../src/game/benchmark/benchmarkController";
|
||||
import { runBiteSizedTrashBenchmark, runDungeonBossBenchmark } from "../../src/game/benchmark/dungeonBossBenchmark";
|
||||
import { PLAYER_AGGRO_ID } from "../../src/game/aggro";
|
||||
import {
|
||||
BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
|
||||
BITE_SIZED_DEADMINES_DUNGEON_ID,
|
||||
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
|
||||
BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
|
||||
BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
|
||||
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
|
||||
BITE_SIZED_GRAFU_CASTLE_DUNGEON_ID,
|
||||
BITE_SIZED_BARREN_CAVES_DUNGEON_ID,
|
||||
BITE_SIZED_HOTO_DUNGEON_ID,
|
||||
BITE_SIZED_KAWAKS_TOMB_DUNGEON_ID,
|
||||
BITE_SIZED_HALL_OF_SURVIVORS_DUNGEON_ID,
|
||||
dungeonContentSourceId,
|
||||
} from "../../src/game/dungeonContentSource";
|
||||
import { requireDungeonDefinition } from "../../src/game/dungeonRegistry";
|
||||
import { BITE_SIZED_STOCKADES_BOSS_CANDIDATES } from "../../src/game/dungeons/biteSizedStockades";
|
||||
import { seededBossIndex } from "../../src/game/dungeons/seededBossSelection";
|
||||
|
||||
const ids = [
|
||||
BITE_SIZED_STOCKADES_DUNGEON_ID, BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
|
||||
BITE_SIZED_DEADMINES_DUNGEON_ID, BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
|
||||
BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID, BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
|
||||
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID, BITE_SIZED_GRAFU_CASTLE_DUNGEON_ID,
|
||||
BITE_SIZED_BARREN_CAVES_DUNGEON_ID, BITE_SIZED_HOTO_DUNGEON_ID,
|
||||
BITE_SIZED_KAWAKS_TOMB_DUNGEON_ID, BITE_SIZED_HALL_OF_SURVIVORS_DUNGEON_ID,
|
||||
] as const;
|
||||
|
||||
function valueAfter(flag: string): string | null {
|
||||
const index = process.argv.indexOf(flag);
|
||||
return index < 0 ? null : process.argv[index + 1] ?? null;
|
||||
}
|
||||
|
||||
function selectedLevels(): number[] {
|
||||
if (process.argv.includes("--all-levels")) return Array.from({ length: 80 }, (_, index) => index + 1);
|
||||
const requested = valueAfter("--levels");
|
||||
const levels = requested ? requested.split(",").map(Number) : [1, 10, 20, 30, 40, 50, 60, 70, 80];
|
||||
if (levels.some((level) => !Number.isInteger(level) || level < 1 || level > 80)) {
|
||||
throw new Error("Levels must be integers from 1 through 80.");
|
||||
}
|
||||
return [...new Set(levels)].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function bossCatalog(dungeonId: string) {
|
||||
const candidates = dungeonId === BITE_SIZED_STOCKADES_DUNGEON_ID
|
||||
? BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((boss) => ({ id: boss.sourceSpawnId, name: boss.name }))
|
||||
: requireDungeonDefinition(dungeonContentSourceId(dungeonId)).bosses;
|
||||
const found = new Map<number, string>();
|
||||
for (let index = 0; index < 2_000 && found.size < candidates.length; index += 1) {
|
||||
const seed = `bite-sized-balance-${index}`;
|
||||
const candidateIndex = seededBossIndex(seed, candidates.length);
|
||||
if (!found.has(candidateIndex)) found.set(candidateIndex, seed);
|
||||
}
|
||||
if (found.size !== candidates.length) {
|
||||
throw new Error(`${dungeonId}: ${candidates.length - found.size} source bosses could not be selected.`);
|
||||
}
|
||||
return candidates.map((boss, index) => ({ dungeonId, id: boss.id, name: boss.name, seed: found.get(index)! }));
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const selectedDungeons = (valueAfter("--dungeons")?.split(",") ?? ids).filter((id) => ids.includes(id as typeof ids[number]));
|
||||
const bosses = selectedDungeons.flatMap(bossCatalog);
|
||||
if (process.argv.includes("--catalog")) {
|
||||
console.log(JSON.stringify(bosses, null, 2));
|
||||
return;
|
||||
}
|
||||
const levels = selectedLevels();
|
||||
const output = resolve(valueAfter("--output") ?? "artifacts/bite-sized-balance/ready-priest-boss-sweep.json");
|
||||
const previous = process.argv.includes("--resume") && existsSync(output)
|
||||
? JSON.parse(readFileSync(output, "utf8")) as {
|
||||
results?: Record<string, unknown>[]; trashResults?: Record<string, unknown>[];
|
||||
}
|
||||
: null;
|
||||
const results: Record<string, unknown>[] = previous?.results ?? [];
|
||||
const trashResults: Record<string, unknown>[] = previous?.trashResults ?? [];
|
||||
const includeBosses = !process.argv.includes("--trash-only");
|
||||
const includeTrash = process.argv.includes("--trash") || process.argv.includes("--trash-only");
|
||||
const writeReport = () => {
|
||||
mkdirSync(resolve(output, ".."), { recursive: true });
|
||||
writeFileSync(output, `${JSON.stringify({
|
||||
method: "Deterministic isolated boss simulation with live stats, companion AI, and baseline Discipline Priest rotation; no boss attacks or travel time.",
|
||||
scope: "All authored Bite-Sized dungeon boss choices; one fixed party seed; normal difficulty; 150-second timeout.",
|
||||
policy: BENCHMARK_POLICY_ID, levels, bosses, results, trashResults,
|
||||
}, null, 2)}\n`);
|
||||
};
|
||||
for (const level of levels) {
|
||||
if (results.filter((result) => result.level === level).length >= (includeBosses ? bosses.length : 0)
|
||||
&& trashResults.filter((result) => result.level === level).length >= (includeTrash ? selectedDungeons.length : 0)) {
|
||||
console.log(`Level ${level}: resumed from checkpoint.`);
|
||||
continue;
|
||||
}
|
||||
const build = (await loadCanonicalBenchmarkBuilds(level)).find((candidate) => (
|
||||
candidate.classId === "priest" && candidate.role === "healer"
|
||||
));
|
||||
if (!build) throw new Error(`No Ready discipline priest build exists at level ${level}.`);
|
||||
for (const boss of includeBosses ? bosses : []) {
|
||||
if (results.some((result) => result.level === level && result.bossId === boss.id
|
||||
&& result.dungeonId === boss.dungeonId)) continue;
|
||||
const result = await runDungeonBossBenchmark(build, boss.id, boss.seed, false, {
|
||||
dungeonId: boss.dungeonId, durationMs: 150_000, partySeed: "bite-sized-balance-party",
|
||||
});
|
||||
const seats = [
|
||||
{ id: PLAYER_AGGRO_ID, label: "Player · Discipline Priest" },
|
||||
...result.party.map((label, index) => ({
|
||||
id: result.partyActorIds[index],
|
||||
label: `Companion ${index + 1} · ${label}`,
|
||||
})),
|
||||
];
|
||||
results.push({ level, dungeonId: boss.dungeonId, bossId: boss.id, bossName: boss.name,
|
||||
seconds: result.seconds, outcome: result.outcome, elapsedSeconds: result.elapsedSeconds,
|
||||
maxHealth: result.maxHealth, remainingHealth: "remainingHealth" in result ? result.remainingHealth : 0,
|
||||
damageByActor: result.damageByActor, seats, damageByAbility: result.damageByAbility,
|
||||
party: result.party });
|
||||
}
|
||||
if (includeTrash) {
|
||||
for (const dungeonId of selectedDungeons) {
|
||||
if (trashResults.some((result) => result.level === level && result.dungeonId === dungeonId)) continue;
|
||||
const sample = bosses.find((boss) => boss.dungeonId === dungeonId)!;
|
||||
const result = await runBiteSizedTrashBenchmark(build, dungeonId, sample.seed, {
|
||||
partySeed: "bite-sized-balance-party", durationMs: 120_000,
|
||||
});
|
||||
trashResults.push({ level, dungeonId, ...result,
|
||||
seats: [{ id: PLAYER_AGGRO_ID, label: "Player · Discipline Priest" },
|
||||
...result.party.map((label, index) => ({ id: result.partyActorIds[index],
|
||||
label: `Companion ${index + 1} · ${label}` }))] });
|
||||
}
|
||||
}
|
||||
writeReport();
|
||||
console.log(`Level ${level}: ${includeBosses ? bosses.length : 0} bosses and ${includeTrash ? selectedDungeons.length : 0} trash pulls measured.`);
|
||||
}
|
||||
writeReport();
|
||||
console.log(output);
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -327,6 +327,10 @@ function collectAbilitySpellIds() {
|
||||
for (const chains of Object.values(wow.classes ?? {})) {
|
||||
for (const chain of chains) for (const rank of chain.ranks ?? []) add("wow335", Number(rank.spellId));
|
||||
}
|
||||
// Penance's player spell delegates visible healing and damage bolts to
|
||||
// child spells that are absent from the learnable progression catalog.
|
||||
add("wow335", 47750);
|
||||
add("wow335", 47666);
|
||||
|
||||
const coa = JSON.parse(fs.readFileSync(path.join(projectRoot, "src", "game", "generated", "coaAbilityRuntimeCatalog.json"), "utf8"));
|
||||
for (const abilities of Object.values(coa.abilitiesByClass ?? {})) {
|
||||
@@ -474,10 +478,21 @@ function buildWowEntries(source, requestedIds, tables) {
|
||||
}
|
||||
sounds.push(...soundEntry(soundTable, row.missileSound), ...soundEntry(soundTable, row.animationSound));
|
||||
}
|
||||
// Creature 19668 uses display 19110: the FleshBeast model with its black skin.
|
||||
// The summon is a persistent actor, so retain its model beside the cast kit.
|
||||
if (source === "wow335" && spellId === 34433) {
|
||||
addAssets("summon", [{ model: "Creature/FleshBeast/FleshBeast.mdx" }]);
|
||||
}
|
||||
for (const [phase, assets] of Object.entries(phases)) {
|
||||
phases[phase] = [...new Map(assets.map((asset) => [`${asset.model}:${asset.attachment ?? ""}`, asset])).values()];
|
||||
}
|
||||
const uniqueSounds = [...new Map(sounds.map((sound) => [sound.sourcePath, sound])).values()];
|
||||
const uniqueSounds = [...new Map(sounds.map((sound) => [sound.sourcePath, sound])).values()]
|
||||
.map((sound) => {
|
||||
if (source !== "wow335" || ![47540, 47666, 47750].includes(spellId)) return sound;
|
||||
if (spellId === 47540) return { ...sound, phase: "cast-start" };
|
||||
const phase = /ShadowCast|Pennance_MissileLoop/i.test(sound.sourcePath) ? "tick" : "impact";
|
||||
return { ...sound, phase };
|
||||
});
|
||||
if (!visualIds.length && !uniqueSounds.length) continue;
|
||||
entries.push({
|
||||
key: `${source}:${spellId}`,
|
||||
|
||||
@@ -116,7 +116,11 @@ export function packageWowOriginalEffects(manifest, options = {}) {
|
||||
const skin = original.buffer.readUInt32LE(60) ? source.read(skinName) : null;
|
||||
if (original.buffer.readUInt32LE(60) && !skin) throw new Error(`Missing skin: ${skinName}`);
|
||||
const mesh = skin ? decodeWotlkM2Mesh(original.buffer, skin.buffer, name) : null;
|
||||
const packagedTextures = effects.textures.map(packageTexture);
|
||||
const packagedTextures = effects.textures.map((texture) => packageTexture(
|
||||
name === "creature/fleshbeast/fleshbeast.m2" && texture.type === 11 && !texture.filename
|
||||
? { ...texture, filename: "Creature/FleshBeast/FleshBeastBlack.blp" }
|
||||
: texture,
|
||||
));
|
||||
const readyParticleIndices = effects.particles.flatMap((particle, index) => (
|
||||
packagedTextures[particle.textureIndex]?.url
|
||||
&& particle.samples.emissionRate.some((sample) => (sample.value?.value ?? sample.value) > 0)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Shadowfiend summon preview</title></head>
|
||||
<body style="margin:0;background:#0b121b;color:#e8eef8;font:14px system-ui">
|
||||
<div id="root"></div><script type="module" src="/src/dev/ShadowfiendPreview.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,123 @@
|
||||
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const out = path.resolve('playtest-artifacts/dungeon-race-affixes');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const base = process.env.RACE_TEST_URL || 'http://127.0.0.1:5173';
|
||||
const cases = [
|
||||
['volatile-remains', 'falling-stars', 'volatile', 'star'],
|
||||
['last-stand', 'stormcall', 'last-stand', 'storm'],
|
||||
['lingering-ash', 'arcane-pulse', 'ash', 'ring'],
|
||||
['unstable-orb', 'predator-mark', 'orb', 'predator'],
|
||||
['risky-boon', 'falling-stars', 'boon', 'star'],
|
||||
];
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const errors = [];
|
||||
try {
|
||||
for (const [role, className, specialization] of [
|
||||
['Tank', 'Warrior', 'Protection'], ['Healer', 'Priest', 'Discipline'],
|
||||
['Damage', 'Hunter', 'Marksmanship'],
|
||||
].filter(([role]) => !process.env.RACE_TEST_ROLE || role === process.env.RACE_TEST_ROLE)) {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
try {
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 120_000 });
|
||||
await page.getByRole('button', { name: /Play offline/ }).click();
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.locator('.class-grid .class-option').filter({ hasText: className }).first().click();
|
||||
await page.locator('.creator-specializations button').filter({ hasText: specialization }).first().click();
|
||||
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||||
await page.getByLabel('Character name').fill(`Affix${role}`);
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.getByRole('button', { name: /Continue|Enter world/ }).first().click({ force: true });
|
||||
await page.getByRole('button', { name: 'Continue', exact: true }).click();
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
for (const pool of ['authored', 'randomized']) {
|
||||
await page.getByRole('button', { name: /Dungeon Race/ }).click();
|
||||
await page.locator('.dungeon-race-screen').waitFor();
|
||||
if (pool === 'randomized') {
|
||||
await page.getByRole('button', { name: 'Randomized Bite-Sized' }).click();
|
||||
await page.locator('.dungeon-race-screen__setup select').first()
|
||||
.selectOption('randomized-bite-sized-stormwind-stockade-available');
|
||||
} else {
|
||||
await page.locator('.dungeon-race-screen__setup select').first()
|
||||
.selectOption('bite-sized-stormwind-stockade');
|
||||
}
|
||||
await page.getByRole('group', { name: 'Your role' })
|
||||
.getByRole('button', { name: role === 'Damage' ? 'DPS' : role, exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Start practice' }).click();
|
||||
await page.locator('.dungeon-race-hud').waitFor({ timeout: 120_000 });
|
||||
await page.locator('.dungeon-boss-intro').waitFor({ state: 'visible', timeout: 120_000 });
|
||||
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden', timeout: 120_000 });
|
||||
await page.locator('.dungeon-race-countdown').waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await page.locator('.dungeon-race-countdown').waitFor({ state: 'hidden', timeout: 30_000 });
|
||||
const boot = await page.evaluate(async () => {
|
||||
const { useDungeonRaceStore } = await import('/src/app/dungeonRaceStore.ts');
|
||||
const { dungeonRaceAffix } = await import('/src/game/dungeonRaceAffixes.ts');
|
||||
const race = useDungeonRaceStore.getState();
|
||||
return { dungeonId: race.dungeonId, pair: race.affixIds, status: race.status,
|
||||
names: race.affixIds.map((id) => dungeonRaceAffix(id).name) };
|
||||
});
|
||||
assert.equal(boot.status, 'active');
|
||||
assert.equal(boot.dungeonId.startsWith(pool === 'authored' ? 'bite-sized-' : 'randomized-bite-sized-'), true);
|
||||
assert.equal(boot.pair.length, 2);
|
||||
const timerText = await page.locator('.dungeon-race-hud').innerText();
|
||||
assert.ok(timerText.includes('Mechanics:'));
|
||||
for (const name of boot.names) assert.ok(timerText.includes(name), `${pool}/${role}: timer omits ${name}`);
|
||||
for (const [killId, movementId, killKind, movementKind] of cases) {
|
||||
const observed = await page.evaluate(async ({ killId, movementId }) => {
|
||||
const { useDungeonRaceStore } = await import('/src/app/dungeonRaceStore.ts');
|
||||
const { useDungeonRaceAffixStore, clearDungeonRaceAffixes, advanceDungeonRaceAffixes } =
|
||||
await import('/src/game/dungeonRaceAffixRuntime.ts');
|
||||
const { useCombatStore } = await import('/src/game/combatStore.ts');
|
||||
const { dungeonDefinitionById } = await import('/src/game/dungeonRegistry.ts');
|
||||
const { setMobPosition } = await import('/src/game/mobRuntimeRegistry.ts');
|
||||
const race = useDungeonRaceStore.getState();
|
||||
const definition = dungeonDefinitionById(race.dungeonId);
|
||||
const trash = definition.staticSpawns.find((spawn) => !definition.bosses.some((boss) => boss.id === spawn.id));
|
||||
if (!trash) throw new Error('No original trash spawn for affix playtest');
|
||||
const now = Date.now();
|
||||
const player = useCombatStore.getState().playerPosition;
|
||||
clearDungeonRaceAffixes();
|
||||
useDungeonRaceStore.setState({ affixIds: [killId, movementId] });
|
||||
advanceDungeonRaceAffixes(now, []);
|
||||
setMobPosition(trash.id, player);
|
||||
useCombatStore.getState().registerMob(trash.id, { name: 'Affix trigger', maxHealth: 100, boss: false });
|
||||
const original = useCombatStore.getState().mobs[trash.id];
|
||||
useCombatStore.setState((state) => ({ mobs: { ...state.mobs,
|
||||
[trash.id]: { ...original, dead: true, health: 0 } } }));
|
||||
advanceDungeonRaceAffixes(now + 100, [trash.id]);
|
||||
useCombatStore.setState((state) => ({ mobs: { ...state.mobs,
|
||||
[trash.id]: { ...state.mobs[trash.id], dead: false, health: 100 } } }));
|
||||
useDungeonRaceAffixStore.setState({ nextMovementAt: now + 101 });
|
||||
advanceDungeonRaceAffixes(now + 101, []);
|
||||
return { kinds: useDungeonRaceAffixStore.getState().effects.map((effect) => effect.kind),
|
||||
pair: useDungeonRaceStore.getState().affixIds };
|
||||
}, { killId, movementId });
|
||||
if (killKind !== 'last-stand') assert.ok(observed.kinds.includes(killKind), `${pool}/${role}: ${killId}`);
|
||||
assert.ok(observed.kinds.includes(movementKind), `${pool}/${role}: ${movementId}`);
|
||||
assert.deepEqual(observed.pair, [killId, movementId]);
|
||||
await page.waitForTimeout(150);
|
||||
if (role === 'Healer' || (role === 'Damage' && pool === 'randomized')) {
|
||||
await page.screenshot({ path: path.join(out, `${pool}-${role.toLowerCase()}-${killId}-${movementId}.png`) });
|
||||
}
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('dialog', { name: 'Paused' }).getByRole('button', { name: 'Main menu' }).click();
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
}
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: path.join(out, `failure-${role.toLowerCase()}.png`) });
|
||||
console.error((await page.locator('body').innerText()).slice(0, 4000));
|
||||
throw error;
|
||||
} finally { await page.close(); }
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(JSON.stringify({ ok: true, runs: process.env.RACE_TEST_ROLE ? 2 : 6,
|
||||
mechanicsPerRun: 9, screenshots: out }));
|
||||
} finally { await browser.close(); }
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
Reference in new issue
Block a user