Release Healer Man 0.1.6
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const projectRoot = path.resolve(import.meta.dirname, "..");
|
||||
const clientRoot = path.resolve(projectRoot, "..", "LadiksMPQEditor", "client-current");
|
||||
const spellPath = path.join(clientRoot, "area-52", "patch-D", "DBFilesClient", "Spell.dbc");
|
||||
const supportRoot = path.join(clientRoot, "patch-S", "DBFilesClient");
|
||||
const runtimePath = path.join(projectRoot, "src", "game", "generated", "coaAbilityRuntimeCatalog.json");
|
||||
const outputPath = path.join(projectRoot, "src", "game", "generated", "coaTriggeredSpellCatalog.json");
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function dbcNumberTable(filePath, valueField, floating = false) {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
assert(buffer.toString("ascii", 0, 4) === "WDBC", `${path.basename(filePath)} is not a WDBC file`);
|
||||
const recordCount = buffer.readUInt32LE(4);
|
||||
const recordSize = buffer.readUInt32LE(12);
|
||||
return new Map(Array.from({ length: recordCount }, (_, index) => {
|
||||
const base = 20 + index * recordSize;
|
||||
return [
|
||||
buffer.readUInt32LE(base),
|
||||
floating ? buffer.readFloatLE(base + valueField * 4) : buffer.readInt32LE(base + valueField * 4),
|
||||
];
|
||||
}));
|
||||
}
|
||||
|
||||
function dbcRangeTable(filePath) {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
assert(buffer.toString("ascii", 0, 4) === "WDBC", `${path.basename(filePath)} is not a WDBC file`);
|
||||
const recordCount = buffer.readUInt32LE(4);
|
||||
const recordSize = buffer.readUInt32LE(12);
|
||||
return new Map(Array.from({ length: recordCount }, (_, index) => {
|
||||
const base = 20 + index * recordSize;
|
||||
return [buffer.readUInt32LE(base), {
|
||||
min: Math.max(0, buffer.readFloatLE(base + 4), buffer.readFloatLE(base + 8)),
|
||||
max: Math.max(0, buffer.readFloatLE(base + 12), buffer.readFloatLE(base + 16)),
|
||||
}];
|
||||
}));
|
||||
}
|
||||
|
||||
function referencedSpellIds(runtime) {
|
||||
const ids = new Set();
|
||||
const inspectEffects = (effects) => {
|
||||
for (const effect of effects ?? []) {
|
||||
if (effect.kind === "trigger-spell" && effect.spellId > 0) ids.add(effect.spellId);
|
||||
if (effect.kind !== "apply-aura") continue;
|
||||
for (const proc of effect.aura?.procs ?? []) {
|
||||
const spellId = proc.action?.kind === "custom" ? proc.action.data?.spellId : 0;
|
||||
if (typeof spellId === "number" && spellId > 0) ids.add(spellId);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const abilities of Object.values(runtime.abilitiesByClass)) {
|
||||
for (const ability of abilities) {
|
||||
inspectEffects(ability.effects);
|
||||
for (const rank of ability.ranks ?? []) inspectEffects(rank.effects);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function cleanText(value) {
|
||||
return value.replace(/\r/g, "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function spellParser(buffer, castTimes, durations, radii, ranges) {
|
||||
assert(buffer.toString("ascii", 0, 4) === "WDBC", "Spell.dbc is not a WDBC file");
|
||||
const recordCount = buffer.readUInt32LE(4);
|
||||
const fieldCount = buffer.readUInt32LE(8);
|
||||
const recordSize = buffer.readUInt32LE(12);
|
||||
const stringSize = buffer.readUInt32LE(16);
|
||||
assert(fieldCount >= 234 && recordSize >= fieldCount * 4, "Unexpected Spell.dbc schema");
|
||||
const stringsOffset = 20 + recordCount * recordSize;
|
||||
const offsets = new Map(Array.from({ length: recordCount }, (_, index) => {
|
||||
const base = 20 + index * recordSize;
|
||||
return [buffer.readUInt32LE(base), base];
|
||||
}));
|
||||
const readString = (offset) => {
|
||||
if (!offset || offset >= stringSize) return "";
|
||||
const start = stringsOffset + offset;
|
||||
const end = buffer.indexOf(0, start);
|
||||
return buffer.toString("utf8", start, end < 0 ? buffer.length : end);
|
||||
};
|
||||
return (id) => {
|
||||
const base = offsets.get(id);
|
||||
if (base === undefined) return null;
|
||||
const unsigned = (field) => buffer.readUInt32LE(base + field * 4);
|
||||
const signed = (field) => buffer.readInt32LE(base + field * 4);
|
||||
const float = (field) => buffer.readFloatLE(base + field * 4);
|
||||
const localized = (firstField) => {
|
||||
for (let locale = 0; locale < 16; locale += 1) {
|
||||
const value = readString(unsigned(firstField + locale));
|
||||
if (value) return cleanText(value);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const effects = Array.from({ length: 3 }, (_, effectIndex) => ({
|
||||
index: effectIndex,
|
||||
effectType: unsigned(71 + effectIndex),
|
||||
auraType: unsigned(95 + effectIndex),
|
||||
basePoints: signed(80 + effectIndex) + 1,
|
||||
dieSides: signed(74 + effectIndex),
|
||||
pointsPerLevel: float(77 + effectIndex),
|
||||
mechanic: unsigned(83 + effectIndex),
|
||||
implicitTargetA: unsigned(86 + effectIndex),
|
||||
implicitTargetB: unsigned(89 + effectIndex),
|
||||
radiusIndex: unsigned(92 + effectIndex),
|
||||
radius: radii.get(unsigned(92 + effectIndex)) ?? 0,
|
||||
periodMs: unsigned(98 + effectIndex),
|
||||
valueMultiplier: float(101 + effectIndex),
|
||||
chainTargets: unsigned(104 + effectIndex),
|
||||
itemType: unsigned(107 + effectIndex),
|
||||
miscValue: signed(110 + effectIndex),
|
||||
miscValueB: signed(113 + effectIndex),
|
||||
triggerSpellId: unsigned(116 + effectIndex),
|
||||
pointsPerCombo: float(119 + effectIndex),
|
||||
coefficient: float(229 + effectIndex),
|
||||
})).filter((effect) => effect.effectType || effect.auraType || effect.basePoints !== 1 || effect.triggerSpellId);
|
||||
return {
|
||||
id,
|
||||
name: localized(136),
|
||||
rankText: localized(153),
|
||||
description: localized(170) || localized(187),
|
||||
categoryId: unsigned(1),
|
||||
dispelType: unsigned(2),
|
||||
mechanic: unsigned(3),
|
||||
attributes: Array.from({ length: 8 }, (_, index) => unsigned(4 + index)),
|
||||
castTimeIndex: unsigned(28),
|
||||
castTimeMs: Math.max(0, castTimes.get(unsigned(28)) ?? 0),
|
||||
recoveryTimeMs: unsigned(29),
|
||||
categoryRecoveryTimeMs: unsigned(30),
|
||||
interruptFlags: unsigned(31),
|
||||
auraInterruptFlags: unsigned(32),
|
||||
channelInterruptFlags: unsigned(33),
|
||||
procFlags: unsigned(34),
|
||||
procChance: unsigned(35),
|
||||
procCharges: unsigned(36),
|
||||
maxLevel: unsigned(37),
|
||||
baseLevel: unsigned(38),
|
||||
spellLevel: unsigned(39),
|
||||
durationIndex: unsigned(40),
|
||||
durationMs: durations.get(unsigned(40)) ?? 0,
|
||||
powerType: signed(41),
|
||||
powerCost: unsigned(42),
|
||||
powerCostPerLevel: unsigned(43),
|
||||
powerCostPerSecond: unsigned(44),
|
||||
powerCostPerSecondPerLevel: unsigned(45),
|
||||
rangeIndex: unsigned(46),
|
||||
rangeMin: ranges.get(unsigned(46))?.min ?? 0,
|
||||
rangeMax: ranges.get(unsigned(46))?.max ?? 0,
|
||||
projectileSpeed: float(47),
|
||||
stackAmount: unsigned(49),
|
||||
equippedItemClass: signed(67),
|
||||
equippedItemSubclassMask: signed(68),
|
||||
equippedItemInventoryMask: signed(69),
|
||||
iconId: unsigned(133),
|
||||
powerCostPercentage: unsigned(204),
|
||||
startRecoveryCategory: unsigned(205),
|
||||
startRecoveryTimeMs: unsigned(206),
|
||||
maximumTargets: unsigned(212),
|
||||
damageClass: unsigned(213),
|
||||
preventionType: unsigned(214),
|
||||
schoolMask: unsigned(225),
|
||||
runeCostId: unsigned(226),
|
||||
powerDisplayId: unsigned(228),
|
||||
effects,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
assert(fs.existsSync(spellPath), `CoA Spell.dbc was not found at ${spellPath}`);
|
||||
const runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8"));
|
||||
const castTimes = dbcNumberTable(path.join(supportRoot, "SpellCastTimes.dbc"), 1);
|
||||
const durations = dbcNumberTable(path.join(supportRoot, "SpellDuration.dbc"), 3);
|
||||
const radii = dbcNumberTable(path.join(supportRoot, "SpellRadius.dbc"), 3, true);
|
||||
const ranges = dbcRangeTable(path.join(supportRoot, "SpellRange.dbc"));
|
||||
const parseSpell = spellParser(fs.readFileSync(spellPath), castTimes, durations, radii, ranges);
|
||||
const pending = [...referencedSpellIds(runtime)];
|
||||
const spells = new Map();
|
||||
while (pending.length) {
|
||||
const id = pending.shift();
|
||||
if (spells.has(id)) continue;
|
||||
const spell = parseSpell(id);
|
||||
if (!spell) continue;
|
||||
spells.set(id, spell);
|
||||
for (const effect of spell.effects) {
|
||||
if (effect.triggerSpellId > 0 && !spells.has(effect.triggerSpellId)) pending.push(effect.triggerSpellId);
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify({ schemaVersion: 1, spells: [...spells.values()] })}\n`, "utf8");
|
||||
console.log(`Wrote ${outputPath} with ${spells.size} triggered spell records.`);
|
||||
@@ -4,6 +4,7 @@ import { CLASSES, type CoaClassId } from "../src/app/characterCatalog";
|
||||
import {
|
||||
COA_ABILITIES_BY_CLASS,
|
||||
COA_CLASS_RESOURCES,
|
||||
COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||
} from "../src/game/coaAbilityCatalog";
|
||||
import {
|
||||
COA_LIVE_SOURCE,
|
||||
@@ -25,6 +26,7 @@ writeJson("src/game/generated/coaAbilityRuntimeCatalog.json", {
|
||||
schemaVersion: 1,
|
||||
abilitiesByClass: COA_ABILITIES_BY_CLASS,
|
||||
resourcesByClass: COA_CLASS_RESOURCES,
|
||||
triggeredAbilitiesBySpellId: COA_TRIGGERED_ABILITIES_BY_SPELL_ID,
|
||||
});
|
||||
|
||||
const coaBudgetsByClass = Object.fromEntries(CLASSES
|
||||
|
||||
@@ -15,6 +15,7 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import webbrowser
|
||||
from dataclasses import dataclass
|
||||
@@ -226,6 +227,67 @@ def current_commit() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def validate_release_candidates(project_root: Path = PROJECT_ROOT) -> list[str]:
|
||||
"""Check tracked and untracked release files without changing the real index."""
|
||||
with tempfile.TemporaryDirectory(prefix="healer-man-release-index-") as temp_dir:
|
||||
environment = os.environ.copy()
|
||||
environment["GIT_INDEX_FILE"] = str(Path(temp_dir) / "index")
|
||||
|
||||
def run_temporary_index_command(*args: str) -> subprocess.CompletedProcess[bytes]:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=project_root,
|
||||
env=environment,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
output = result.stdout.decode("utf-8", errors="replace").strip()
|
||||
raise ReleaseError(
|
||||
f"Release-candidate Git check failed ({result.returncode}): "
|
||||
f"git {' '.join(args)}\n{output}"
|
||||
)
|
||||
return result
|
||||
|
||||
run_temporary_index_command("read-tree", "HEAD")
|
||||
run_temporary_index_command("add", "-A")
|
||||
raw_paths = run_temporary_index_command(
|
||||
"diff", "--cached", "--name-only", "-z"
|
||||
).stdout
|
||||
staged_paths = [
|
||||
path.decode("utf-8", errors="replace")
|
||||
for path in raw_paths.split(b"\0")
|
||||
if path
|
||||
]
|
||||
|
||||
whitespace_result = subprocess.run(
|
||||
["git", "diff", "--cached", "--check"],
|
||||
cwd=project_root,
|
||||
env=environment,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
check=False,
|
||||
)
|
||||
if whitespace_result.returncode != 0:
|
||||
detail = whitespace_result.stdout.strip()
|
||||
raise ReleaseError(
|
||||
"Release-candidate whitespace check failed. This check includes "
|
||||
f"tracked and untracked files:\n{detail}"
|
||||
)
|
||||
|
||||
dangerous = find_dangerous_staged_paths(staged_paths)
|
||||
if dangerous:
|
||||
raise ReleaseError(
|
||||
"Refusing to release forbidden output or a possible secret:\n"
|
||||
+ "\n".join(f" - {path}" for path in dangerous)
|
||||
)
|
||||
return staged_paths
|
||||
|
||||
|
||||
def windows_powershell_environment(powershell: str) -> dict[str, str]:
|
||||
environment = os.environ.copy()
|
||||
if Path(powershell).name.lower() == "powershell.exe":
|
||||
@@ -1003,6 +1065,15 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
credentials: dict[str, str],
|
||||
) -> None:
|
||||
self._preflight_git(require_clean=False)
|
||||
self._log_event(
|
||||
"\n--- Early release-candidate check ---\n"
|
||||
"$ npm run release:check"
|
||||
)
|
||||
candidate_paths = validate_release_candidates()
|
||||
self._log_event(
|
||||
f"✓ Early release-candidate check passed "
|
||||
f"({len(candidate_paths)} changed path(s), including untracked files)"
|
||||
)
|
||||
npm = shutil.which("npm")
|
||||
powershell = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
if not npm or not powershell:
|
||||
@@ -1034,7 +1105,6 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
)
|
||||
for command, label in checks:
|
||||
self._run_command(command, label)
|
||||
self._run_command(["git", "diff", "--check"], "Whitespace check")
|
||||
self._run_command(["git", "add", "-A"], "Stage release")
|
||||
staged = True
|
||||
|
||||
@@ -1371,12 +1441,28 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
action="store_true",
|
||||
help="print release/version/Git readiness without opening the GUI",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check-release",
|
||||
action="store_true",
|
||||
help="check all release candidates, including untracked files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--smoke-test",
|
||||
action="store_true",
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
if args.check_release:
|
||||
try:
|
||||
paths = validate_release_candidates()
|
||||
print(
|
||||
f"Release candidate check passed: {len(paths)} changed path(s), "
|
||||
"including untracked files."
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"Release candidate check failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
if args.check:
|
||||
try:
|
||||
print(json.dumps(check_summary(), indent=2))
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import release_manager
|
||||
|
||||
@@ -45,5 +48,57 @@ class SafetyTests(unittest.TestCase):
|
||||
self.assertIn('test "$ACTUAL_COMMIT" = "$EXPECTED_COMMIT"', block)
|
||||
|
||||
|
||||
class ReleaseCandidateTests(unittest.TestCase):
|
||||
def test_checks_untracked_files_without_changing_the_real_index(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
project_root = Path(temp_dir)
|
||||
|
||||
def git(*args):
|
||||
subprocess.run(
|
||||
["git", *args],
|
||||
cwd=project_root,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
|
||||
git("init", "--quiet")
|
||||
(project_root / "tracked.ts").write_text("export const tracked = true;\n")
|
||||
git("add", "tracked.ts")
|
||||
git(
|
||||
"-c",
|
||||
"user.name=Healer Man Tests",
|
||||
"-c",
|
||||
"user.email=tests@example.invalid",
|
||||
"commit",
|
||||
"--quiet",
|
||||
"-m",
|
||||
"Initial commit",
|
||||
)
|
||||
|
||||
untracked = project_root / "untracked.ts"
|
||||
untracked.write_text("export const untracked = true;\n\n")
|
||||
with self.assertRaisesRegex(
|
||||
release_manager.ReleaseError, "untracked.ts.*new blank line at EOF"
|
||||
):
|
||||
release_manager.validate_release_candidates(project_root)
|
||||
|
||||
untracked.write_text("export const untracked = true;\n")
|
||||
self.assertEqual(
|
||||
release_manager.validate_release_candidates(project_root),
|
||||
["untracked.ts"],
|
||||
)
|
||||
self.assertEqual(
|
||||
subprocess.run(
|
||||
["git", "diff", "--cached", "--name-only"],
|
||||
cwd=project_root,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
check=True,
|
||||
).stdout,
|
||||
"",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -162,6 +162,7 @@ function componentFor(id) {
|
||||
};
|
||||
const attack = {
|
||||
type: int32(buffers.magicObject, offset + 0x194),
|
||||
calculationType: int32(buffers.magicObject, offset + 0x2d4),
|
||||
damagePower: cleanFloat(float32(buffers.magicObject, offset + 0x198)),
|
||||
damagePowerSkillLevelArg: cleanFloat(float32(buffers.magicObject, offset + 0x19c)),
|
||||
fixedValue: cleanFloat(float32(buffers.magicObject, offset + 0x1a0)),
|
||||
@@ -297,7 +298,9 @@ function sourceSpell(spellId, classIndex) {
|
||||
})).filter((cost) => cost.type > 0 && cost.value !== 0 && ![9, 10, 11, 12, 13, 14, 15].includes(cost.type));
|
||||
const costs = costRows.map((cost) => ({
|
||||
resource: resourceForCostType(cost.type, classIndex),
|
||||
amount: Math.max(0, Math.min(100, Math.abs(cost.value))),
|
||||
amount: Math.max(0, cost.type === 3 || cost.type === 4
|
||||
? Math.min(100, Math.abs(cost.value))
|
||||
: Math.abs(cost.value)),
|
||||
percentage: cost.type === 3 || cost.type === 4,
|
||||
}));
|
||||
const attackDistance = int32(buffers.magicCollect, offset + 0xbc);
|
||||
|
||||
Reference in New Issue
Block a user