Update class mechanics, stat rulesets, and CoA reference catalogs
This commit is contained in:
1 parent
b16821e92e
commit
39458d5de6
221 files changed
+2584962
-568
No files matched your search
@@ -42,6 +42,22 @@ const DRUID_WARRIOR_BRIAR_CHILD = {
|
||||
passiveSourceSkillId: 499955,
|
||||
childSourceSkillId: 499956,
|
||||
};
|
||||
const ROGUE_WARDEN_REVIVAL_STATE = {
|
||||
mainClass: 3,
|
||||
secondaryClass: 7,
|
||||
level: 60,
|
||||
keyItemId: 546772,
|
||||
parentSourceSkillId: 499542,
|
||||
childSourceSkillId: 499544,
|
||||
};
|
||||
const KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT = {
|
||||
mainClass: 6,
|
||||
secondaryClass: 3,
|
||||
level: 50,
|
||||
keyItemId: 542123,
|
||||
parentSourceSkillId: 499934,
|
||||
childSourceSkillId: 499909,
|
||||
};
|
||||
const RACE_MATRIX = {
|
||||
"rom-human": [1, 2, 3, 4, 5, 6],
|
||||
"rom-elf": [1, 2, 3, 4, 7, 8],
|
||||
@@ -597,6 +613,75 @@ for (const key of [...eligiblePairs].sort()) {
|
||||
iconResolution: "native-interface-fdb",
|
||||
});
|
||||
}
|
||||
|
||||
// RuneWaker's Rogue/Warden 499544 state has its own level-60 row behind
|
||||
// 499542, sharing the exact elite key item. Preserve that second learned
|
||||
// candidate instead of dropping it at the one-row milestone selector.
|
||||
if (mapping.mainClass === ROGUE_WARDEN_REVIVAL_STATE.mainClass
|
||||
&& mapping.secondaryClass === ROGUE_WARDEN_REVIVAL_STATE.secondaryClass
|
||||
&& milestone.level === ROGUE_WARDEN_REVIVAL_STATE.level
|
||||
&& milestone.keyItemId === ROGUE_WARDEN_REVIVAL_STATE.keyItemId
|
||||
&& candidate.sourceSkillId === ROGUE_WARDEN_REVIVAL_STATE.parentSourceSkillId) {
|
||||
const child = candidates.find((entry) => entry.classIndex === ROGUE_WARDEN_REVIVAL_STATE.mainClass
|
||||
&& entry.sourceSkillId === ROGUE_WARDEN_REVIVAL_STATE.childSourceSkillId
|
||||
&& entry.learnLevel === milestone.level
|
||||
&& entry.keyItemId === milestone.keyItemId
|
||||
&& entry.prerequisites.includes(ROGUE_WARDEN_REVIVAL_STATE.parentSourceSkillId));
|
||||
if (!child) throw new Error("Rogue/Warden Revival state 499544 lost its source unlock row.");
|
||||
const childSpell = sourceSpell(child.sourceSkillId, mapping.mainClass);
|
||||
if (!childSpell) throw new Error("Rogue/Warden Revival state 499544 has no MagicCollect row.");
|
||||
skills.push({
|
||||
id: `${CLASS_IDS[mapping.mainClass - 1]}-${CLASS_IDS[mapping.secondaryClass - 1]}-elite-${milestone.level}-${child.sourceSkillId}`,
|
||||
classId: CLASS_IDS[mapping.mainClass - 1],
|
||||
secondaryClassId: CLASS_IDS[mapping.secondaryClass - 1],
|
||||
group: childSpell.passive ? "passive" : "elite",
|
||||
unlockLevel: child.learnLevel,
|
||||
keyItemId: child.keyItemId,
|
||||
...childSpell,
|
||||
ranks: [{
|
||||
rank: 1,
|
||||
level: child.learnLevel,
|
||||
prerequisites: child.prerequisites,
|
||||
...nestedRankSpell(childSpell, childSpell.sourceSkillId),
|
||||
}],
|
||||
icon: `/assets/ui/rom-spells/${childSpell.source.imageId}.png`,
|
||||
iconResolution: "native-interface-fdb",
|
||||
});
|
||||
}
|
||||
|
||||
// Knight/Rogue God's Vengeance explicitly unlocks its linked 499909
|
||||
// Punishment as a second learned row on the same level-50 key item.
|
||||
if (mapping.mainClass === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.mainClass
|
||||
&& mapping.secondaryClass === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.secondaryClass
|
||||
&& milestone.level === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.level
|
||||
&& milestone.keyItemId === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.keyItemId
|
||||
&& candidate.sourceSkillId === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.parentSourceSkillId) {
|
||||
const child = candidates.find((entry) => entry.classIndex === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.mainClass
|
||||
&& entry.sourceSkillId === KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.childSourceSkillId
|
||||
&& entry.learnLevel === milestone.level
|
||||
&& entry.keyItemId === milestone.keyItemId
|
||||
&& entry.prerequisites.includes(KNIGHT_ROGUE_GODS_VENGEANCE_PUNISHMENT.parentSourceSkillId));
|
||||
if (!child) throw new Error("Knight/Rogue God's Vengeance Punishment 499909 lost its learned source row.");
|
||||
const childSpell = sourceSpell(child.sourceSkillId, mapping.mainClass);
|
||||
if (!childSpell) throw new Error("Knight/Rogue God's Vengeance Punishment 499909 has no MagicCollect row.");
|
||||
skills.push({
|
||||
id: `${CLASS_IDS[mapping.mainClass - 1]}-${CLASS_IDS[mapping.secondaryClass - 1]}-elite-${milestone.level}-${childSpell.sourceSkillId}`,
|
||||
classId: CLASS_IDS[mapping.mainClass - 1],
|
||||
secondaryClassId: CLASS_IDS[mapping.secondaryClass - 1],
|
||||
group: childSpell.passive ? "passive" : "elite",
|
||||
unlockLevel: child.learnLevel,
|
||||
keyItemId: child.keyItemId,
|
||||
...childSpell,
|
||||
ranks: [{
|
||||
rank: 1,
|
||||
level: child.learnLevel,
|
||||
prerequisites: child.prerequisites,
|
||||
...nestedRankSpell(childSpell, childSpell.sourceSkillId),
|
||||
}],
|
||||
icon: `/assets/ui/rom-spells/${childSpell.source.imageId}.png`,
|
||||
iconResolution: "native-interface-fdb",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,9 +724,10 @@ const audit = {
|
||||
},
|
||||
adaptations,
|
||||
};
|
||||
// In addition to the 660 ordinary milestones, this source emits the Druid/Warrior
|
||||
// Cross of Thorns Attacks row behind its explicit learned prerequisite.
|
||||
if (eliteMilestoneCount !== 660 || supplementalEliteCount !== 1 || unresolvedVisible.length !== 0
|
||||
// In addition to the 660 ordinary milestones, the source emits the Druid/Warrior
|
||||
// Cross of Thorns Attacks row, Rogue/Warden Revival state, and linked Knight/Rogue
|
||||
// Punishment row behind their learned prerequisites.
|
||||
if (eliteMilestoneCount !== 660 || supplementalEliteCount !== 3 || unresolvedVisible.length !== 0
|
||||
|| audit.coverage.iconResolved !== skills.length || iconCoverage.requested !== iconCoverage.resolved) {
|
||||
throw new Error(`Coverage failed: ${JSON.stringify(audit.coverage)}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(scriptDirectory, "../..");
|
||||
const sourceRoot = path.resolve(process.env.RUNEWAKER_ROOT ?? path.join(projectRoot, "..", "Runewaker"));
|
||||
const dataRoot = path.join(sourceRoot, "Resource", "data");
|
||||
const ruleOutput = path.join(projectRoot, "src", "game", "generated", "romNativeStatRules.generated.json");
|
||||
const gearOutput = path.join(projectRoot, "src", "game", "generated", "romNativeGearCurves.generated.json");
|
||||
const referencePath = path.join(projectRoot, "src", "game", "generated", "romHeirloomReference.json");
|
||||
const baselinePath = path.join(projectRoot, "src", "game", "romBaseStatGrowth.generated.json");
|
||||
const DATA_START = 140;
|
||||
const RECORD_SIZE = 408;
|
||||
const CLASS_ROWS = [
|
||||
["rom-warrior", 1], ["rom-scout", 2], ["rom-rogue", 3], ["rom-mage", 4], ["rom-priest", 5],
|
||||
["rom-knight", 6], ["rom-warden", 7], ["rom-druid", 8], ["rom-warlock", 9], ["rom-champion", 10],
|
||||
];
|
||||
const RACE_ROWS = [["rom-human", 0], ["rom-elf", 1], ["rom-dwarf", 2]];
|
||||
const GENDER_ROWS = [["male", 0], ["female", 1]];
|
||||
const STAT_KEYS = ["strength", "stamina", "intellect", "wisdom", "agility", "health", "mana"];
|
||||
const ARG_FIELDS = [
|
||||
"baseStrengthLevelOne", "baseStaminaLevelOne", "baseIntellectLevelOne", "baseWisdomLevelOne", "baseAgilityLevelOne", "baseHealthLevelOne", "baseManaLevelOne",
|
||||
"baseStrengthGrowth", "baseStaminaGrowth", "baseIntellectGrowth", "baseWisdomGrowth", "baseAgilityGrowth", "baseHealthGrowth", "baseManaGrowth",
|
||||
"strengthGrowthRate", "staminaGrowthRate", "intellectGrowthRate", "wisdomGrowthRate", "agilityGrowthRate", "healthGrowthRate", "manaGrowthRate",
|
||||
"hpStrength", "hpStamina", "mpIntellect", "mpWisdom", "attackStrength", "attackIntellect", "attackAgility", "defenseStamina",
|
||||
"baseHealthRecovery", "baseManaRecovery", "healthRecoveryStamina", "manaRecoveryWisdom", "magicalAttackIntellect", "magicalDefenseWisdom",
|
||||
"baseMoveSpeed", "recoverySpMove", "recoverySpStop", "recoverySpAttack", "recoverySpUnderAttack", "recoverySpAttackTimeMove", "recoverySpAttackTimeStop",
|
||||
"maxSP", "damageRate",
|
||||
];
|
||||
const FIX_FIELDS = [
|
||||
"damageAttackCoefficient", "magicalDamageAttackCoefficient", "criticalLevelCoefficient", "damageLevelCoefficient", "missLevelCoefficient",
|
||||
"secondHandMissCoefficient", "secondHandDamageCoefficient", "basePhysicalCritical", "basePhysicalCriticalPower", "baseMagicalCritical", "baseMagicalCriticalPower",
|
||||
"magicDamageCoefficient", "magicMagicalDamageCoefficient", "dotMagicalDamageCoefficient", "healingMagicalDamageCoefficient", "healingDotMagicalDamageCoefficient",
|
||||
"baseRateCoefficient", "baseResistCoefficient", "levelRateCoefficient", "levelResistCoefficient", "physicalHitBase", "physicalHitRange",
|
||||
"physicalHitAgility", "physicalDodgeAgility", "magicalHitBase", "magicalHitRange", "magicalHitWisdom", "magicalDodgeAgility", "magicalDodgeWisdom",
|
||||
"phantomRankCoefficient", "phantomLevelCoefficient",
|
||||
];
|
||||
|
||||
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
||||
function cleanFloat(value) {
|
||||
if (!Number.isFinite(value)) throw new Error("Native source table contains a non-finite float.");
|
||||
return Number(value.toFixed(5));
|
||||
}
|
||||
function database(buffer, name, recordSize) {
|
||||
const count = buffer.readInt32LE(132);
|
||||
if (count <= 0 || DATA_START + count * recordSize > buffer.length) throw new Error(`${name} fixed-record layout changed.`);
|
||||
return count;
|
||||
}
|
||||
function row(buffer, index, expectedId, label) {
|
||||
const offset = DATA_START + index * RECORD_SIZE;
|
||||
if (offset + RECORD_SIZE > buffer.length
|
||||
|| buffer.readInt32LE(offset) !== expectedId
|
||||
|| buffer.readInt32LE(offset + 4) !== expectedId) {
|
||||
throw new Error(`${label} row sentinel drifted (expected ${expectedId}).`);
|
||||
}
|
||||
return Array.from({ length: 100 }, (_, field) => cleanFloat(buffer.readFloatLE(offset + 8 + field * 4)));
|
||||
}
|
||||
function named(values, names) {
|
||||
return Object.fromEntries(names.map((name, index) => [name, values[index]]));
|
||||
}
|
||||
function validateGrowthMirror(variants, baseline) {
|
||||
for (const [classId] of CLASS_ROWS) for (const [raceId] of RACE_ROWS) for (const [gender] of GENDER_ROWS) {
|
||||
const row = variants[classId][raceId][gender];
|
||||
for (const [statIndex, stat] of STAT_KEYS.entries()) {
|
||||
if (raceId !== "rom-human" || gender !== "male") continue;
|
||||
const expected = baseline[classId][stat];
|
||||
const actual = row.growth[stat];
|
||||
for (const key of ["levelOne", "flatGrowth", "percentGrowth"]) {
|
||||
if (Math.abs(actual[key] - expected[key]) > 0.00001) {
|
||||
throw new Error(`${classId}.${stat}.${key} no longer matches romBaseStatGrowth.generated.json.`);
|
||||
}
|
||||
}
|
||||
if (statIndex < 0) throw new Error("Unreachable stat index.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filenames = ["racetable.db", "sextable.db", "voctable.db", "fixtable.db"];
|
||||
const buffers = Object.fromEntries(await Promise.all(filenames.map(async (name) => [
|
||||
name, await readFile(path.join(dataRoot, name)),
|
||||
])));
|
||||
database(buffers["racetable.db"], "racetable.db", RECORD_SIZE);
|
||||
database(buffers["sextable.db"], "sextable.db", RECORD_SIZE);
|
||||
database(buffers["voctable.db"], "voctable.db", RECORD_SIZE * 2);
|
||||
database(buffers["fixtable.db"], "fixtable.db", RECORD_SIZE);
|
||||
|
||||
const classRows = Object.fromEntries(CLASS_ROWS.map(([classId, sourceClassId]) => {
|
||||
const args = row(buffers["voctable.db"], sourceClassId * 2 + 1, 301000 + sourceClassId * 2 + 1, `vocation ${classId}`);
|
||||
return [classId, { sourceClassId, args: named(args.slice(0, ARG_FIELDS.length), ARG_FIELDS) }];
|
||||
}));
|
||||
const raceRows = Object.fromEntries(RACE_ROWS.map(([raceId, sourceRaceId]) => [
|
||||
raceId, { sourceRaceId, args: named(row(buffers["racetable.db"], sourceRaceId, 300000 + sourceRaceId, `race ${raceId}`).slice(0, ARG_FIELDS.length), ARG_FIELDS) },
|
||||
]));
|
||||
const genderRows = Object.fromEntries(GENDER_ROWS.map(([gender, sourceGenderId]) => [
|
||||
gender, { sourceGenderId, args: named(row(buffers["sextable.db"], sourceGenderId, 302000 + sourceGenderId, `gender ${gender}`).slice(0, ARG_FIELDS.length), ARG_FIELDS) },
|
||||
]));
|
||||
const fixedRow = row(buffers["fixtable.db"], 1, 303001, "player fixed formula");
|
||||
const playerFix = named(fixedRow.slice(0, FIX_FIELDS.length), FIX_FIELDS);
|
||||
const variants = {};
|
||||
for (const [classId] of CLASS_ROWS) {
|
||||
variants[classId] = {};
|
||||
for (const [raceId] of RACE_ROWS) {
|
||||
variants[classId][raceId] = {};
|
||||
for (const [gender] of GENDER_ROWS) {
|
||||
const classArgs = classRows[classId].args;
|
||||
const raceArgs = raceRows[raceId].args;
|
||||
const genderArgs = genderRows[gender].args;
|
||||
const growth = Object.fromEntries(STAT_KEYS.map((stat, index) => [stat, {
|
||||
levelOne: cleanFloat(classArgs[ARG_FIELDS[index]] + raceArgs[ARG_FIELDS[index]] + genderArgs[ARG_FIELDS[index]]),
|
||||
flatGrowth: cleanFloat(classArgs[ARG_FIELDS[index + 7]] + raceArgs[ARG_FIELDS[index + 7]] + genderArgs[ARG_FIELDS[index + 7]]),
|
||||
percentGrowth: cleanFloat(classArgs[ARG_FIELDS[index + 14]] + raceArgs[ARG_FIELDS[index + 14]] + genderArgs[ARG_FIELDS[index + 14]]),
|
||||
}]));
|
||||
const coefficients = Object.fromEntries(ARG_FIELDS.slice(21, 42).map((field) => [
|
||||
field,
|
||||
cleanFloat(classArgs[field] + raceArgs[field] + genderArgs[field]),
|
||||
]));
|
||||
coefficients.damageRate = cleanFloat(classArgs.damageRate * raceArgs.damageRate * genderArgs.damageRate);
|
||||
variants[classId][raceId][gender] = { growth, coefficients };
|
||||
}
|
||||
}
|
||||
}
|
||||
validateGrowthMirror(variants, JSON.parse(await readFile(baselinePath, "utf8")));
|
||||
if (Math.abs(playerFix.damageAttackCoefficient) > 1e-8
|
||||
|| Math.abs(playerFix.magicalDamageAttackCoefficient) > 1e-8
|
||||
|| Math.abs(playerFix.healingMagicalDamageCoefficient - 0.5) > 1e-8) {
|
||||
throw new Error("Player formula row changed: DMG/MDMG/heal MDMG mapping needs a source review.");
|
||||
}
|
||||
const ruleData = {
|
||||
schemaVersion: 1,
|
||||
source: {
|
||||
project: "Runewaker client/server source data",
|
||||
formulaFiles: [
|
||||
"SourceCodeVS2019/Libraries/Union/RoleData/RoleAttr.cpp",
|
||||
"SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h",
|
||||
"SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp",
|
||||
],
|
||||
recordFiles: Object.fromEntries(filenames.map((name) => [name, sha256(buffers[name])])),
|
||||
recordLayout: { dataStart: DATA_START, recordSize: RECORD_SIZE, vocationSubrecordSize: RECORD_SIZE },
|
||||
playerFixRecordId: 303001,
|
||||
raceRecordIds: Object.fromEntries(RACE_ROWS.map(([key, id]) => [key, 300000 + id])),
|
||||
genderRecordIds: Object.fromEntries(GENDER_ROWS.map(([key, id]) => [key, 302000 + id])),
|
||||
vocationRecordIds: Object.fromEntries(CLASS_ROWS.map(([key, id]) => [key, 301000 + id * 2 + 1])),
|
||||
fieldOrder: "ObjectStruct.h FightArgumentStruct fields 0-43; player FIX fields 0-30",
|
||||
},
|
||||
classRows,
|
||||
raceRows,
|
||||
genderRows,
|
||||
playerFix,
|
||||
variants,
|
||||
manaClassIds: ["rom-mage", "rom-priest", "rom-knight", "rom-warden", "rom-druid", "rom-warlock", "rom-champion"],
|
||||
formulaNotes: {
|
||||
secondaryBase: "Main base at Level + secondary base at Level_Sub divided by 10; runtime supplies the source secondary level.",
|
||||
baseProgression: "For each level after 1: value = value * (1 + combined percent growth) + combined flat growth.",
|
||||
maxHealth: "(base HP + STR*HpStrArg + STA*HpStaArg + flat MaxHP) * MaxHP rate; minimum 1.",
|
||||
maxMana: "MP classes only: (base MP + INT*MPIntArg + WIS*MpMndArg + flat MaxMP) * MaxMP rate; minimum 0.",
|
||||
physicalAttack: "(STR*AtkStrArg + INT*AtkIntArg + AGI*AtkAgiArg + flat ATK) * ATK rate; minimum 1.",
|
||||
physicalDefense: "(STA*DefArg + flat DEF) * DEF rate; source also adds armor-category base-defense contributions not represented by this curve adapter.",
|
||||
magicalDefense: "(WIS*MDefArg + flat MDEF) * MDEF rate; minimum 1.",
|
||||
magicalAttack: "(INT*MAtkArg + flat MATK) * MATK rate; minimum 1.",
|
||||
physicalDamage: "(ATK / DmgAtkArg when nonzero + flat DMG) * merged class/race/sex DmgRate * DMG rate; player FIX DmgAtkArg is zero.",
|
||||
magicalDamage: "(MATK / MDmgMAtkArg when nonzero + flat MDMG) * MDMG rate; player FIX MDmgMAtkArg is zero.",
|
||||
magicPower: "MPow is the native all-magic BodyRate, neutral 1; it is not MATK or MDMG.",
|
||||
healer: "HolyHeal uses skill OrgPower, capped MND contribution, optional MDMG*Dmg_HealArg (player FIX Dmg_HealArg is 0.5), MPow*HealPowerRate, and HealPoint*SpellTime. Target HealAbsorbRate and HealAbsorb apply after the caster amount. Skill OrgPower/timing and other skill flags are outside this stat helper.",
|
||||
secondaryGate: "The source formula assumes a present secondary vocation and uses Level_Sub; the level-10 unlock gate and same-level fallback are simulator policy.",
|
||||
},
|
||||
};
|
||||
|
||||
const reference = JSON.parse(await readFile(referencePath, "utf8"));
|
||||
const legacyToNative = (stats) => {
|
||||
const native = {};
|
||||
const direct = [
|
||||
"strength", "stamina", "intellect", "wisdom", "agility", "health", "mana", "healthRecovery", "manaRecovery",
|
||||
"physicalAttack", "physicalDefense", "magicalDefense", "magicalAttack", "physicalHit", "magicalHit",
|
||||
"physicalCritical", "magicalCritical", "parry",
|
||||
];
|
||||
for (const key of direct) if (stats[key] > 0) native[key] = stats[key];
|
||||
if (stats.dodge > 0) native.physicalDodge = stats.dodge;
|
||||
if (stats.weaponDamage > 0) native.physicalDamage = stats.weaponDamage;
|
||||
if (stats.magicalDamage > 0) native.magicalDamagePoint = stats.magicalDamage;
|
||||
if (stats.magicalWeaponDamage > 0) native.magicalDamage = stats.magicalWeaponDamage;
|
||||
if (stats.healingPower > 0) native.healPoint = stats.healingPower;
|
||||
return native;
|
||||
};
|
||||
const compactProfiles = Object.fromEntries(Object.entries(reference.profiles).map(([classId, slots]) => [
|
||||
classId,
|
||||
Object.fromEntries(Object.entries(slots).map(([slot, rows]) => [slot, rows.map((entry) => legacyToNative(entry.stats))])),
|
||||
]));
|
||||
const gearData = {
|
||||
schemaVersion: 1,
|
||||
status: "source-item stat references with synthetic class/slot selection and level interpolation",
|
||||
sourceRecordSha256: reference.sourceFiles,
|
||||
statTypeMap: {
|
||||
"12": "physicalAttack", "13": "physicalDefense", "14": "magicalDefense", "15": "magicalAttack",
|
||||
"16": "physicalHit", "17": "physicalDodge", "18": "physicalCritical", "20": "magicalCritical",
|
||||
"22": "parry", "25": "physicalDamage", "148": "magicalDamagePoint", "150": "healPoint", "191": "magicalDamage", "195": "magicalHit",
|
||||
},
|
||||
profiles: compactProfiles,
|
||||
};
|
||||
await writeFile(ruleOutput, `${JSON.stringify(ruleData, null, 2)}\n`);
|
||||
await writeFile(gearOutput, `${JSON.stringify(gearData)}\n`);
|
||||
console.log(`Exported native formula rules for ${CLASS_ROWS.length} classes, ${RACE_ROWS.length} races, and ${GENDER_ROWS.length} genders; wrote compact gear curves for ${Object.keys(compactProfiles).length} reference classes.`);
|
||||
@@ -25,10 +25,10 @@
|
||||
"eliteMilestones": 660,
|
||||
"ordinaryFamilies": 269,
|
||||
"sourceRankRows": 274,
|
||||
"iconResolved": 930,
|
||||
"iconResolved": 932,
|
||||
"uniqueNativeIcons": 918,
|
||||
"interfaceFdbEntries": 34954,
|
||||
"executableOrNonCombat": 930,
|
||||
"executableOrNonCombat": 932,
|
||||
"unresolvedVisible": 0
|
||||
},
|
||||
"adaptations": [
|
||||
|
||||
Reference in new issue
Block a user