Initial import of HEROLL source and design documents

This commit is contained in:
toshiocn
2026-10-26 16:05:39 +08:00
commit fdb55672c1
1846 changed files with 290692 additions and 0 deletions
+423
View File
@@ -0,0 +1,423 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { GameEngine, QUALITY, QUALITY_WEIGHTS } from "../dist/core.js";
import { parseCsv } from "../dist/data.js";
import { SeededRng } from "../dist/rng.js";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SPECS = path.resolve(HERE, "../../design/gameplay-validation/specs");
const number = (value, fallback = 0) => Number.isFinite(Number(value)) ? Number(value) : fallback;
const bool = (value) => String(value).toLowerCase() === "true";
const csv = async (name) => parseCsv(await readFile(path.join(SPECS, name), "utf8"));
async function loadTables() {
const [floors, gear, statuses, blessings, potions, shop, events] = await Promise.all([
csv("floor-balance.csv"), csv("gear-catalog.csv"), csv("gear-status.csv"), csv("blessings.csv"),
csv("potions.csv"), csv("shop-offers.csv"), csv("events.csv"),
]);
return {
floors: floors.map((r) => ({ floor: number(r.floor), attackPower: number(r.attack_power), health: number(r.health), defense: number(r.defense), attackSpeed: number(r.attack_speed, 1), evasion: number(r.evasion), aim: number(r.aim) })),
gear: gear.map((r) => ({ catalogId: number(r.catalog_id), stringId: r.string_id, slot: r.slot, iconName: r.icon_name, indexInSlot: number(r.index_in_slot) })),
gearStatuses: statuses.map((r) => ({ statusId: number(r.status_id), stringId: r.string_id, baseMin: number(r.base_min), baseMax: number(r.base_max), plusValue: number(r.plus_value), isPercent: bool(r.is_percent) })),
blessings: blessings.map((r) => ({ blessingId: r.blessing_id, stringId: r.string_id, enabled: bool(r.enabled), weight: number(r.prob_weight, 1), linearValue: number(r.linear_value), values: r.values ? r.values.split("|").map(Number) : [], stackable: bool(r.stackable), effectContract: r.effect_contract })),
potions: potions.map((r) => ({ recordId: r.record_id, recordType: r.record_type, level: number(r.level), value: number(r.value), valueA: number(r.value_a), valueB: number(r.value_b) })),
shop: shop.map((r) => ({ offerId: number(r.offer_id), stringId: r.string_id, itemType: r.item_type, itemId: r.item_id, fixedKey: r.fixed_key, fixedSort: number(r.fixed_sort), fixedSlot: number(r.fixed_slot, -1), price: number(r.price), buyCount: number(r.buy_count, 1) })),
events: events.map((r) => ({ eventId: r.event_id, tileId: r.tile_id, stringId: r.string_id, naturalCore: bool(r.natural_core), boostPlacement: r.boost_placement, baseRule: r.base_rule })),
};
}
const tests = [];
const test = (name, fn) => tests.push([name, fn]);
const data = await loadTables();
test("GDD 配置规模精确", () => {
assert.equal(data.floors.length, 10);
assert.equal(data.gear.length, 40);
assert.equal(data.gearStatuses.length, 13);
assert.equal(data.blessings.filter((item) => item.enabled).length, 32);
assert.equal(data.shop.length, 97);
assert.equal(data.events.length, 13);
});
test("百万次公平双骰的 36 个有序结果通过卡方检查", () => {
const rng = new SeededRng("HEROLL-FAIRNESS-1M");
const bins = Array(36).fill(0);
for (let i = 0; i < 1_000_000; i += 1) {
const a = rng.int(1, 6);
const b = rng.int(1, 6);
bins[(a - 1) * 6 + b - 1] += 1;
}
const expected = 1_000_000 / 36;
const chi2 = bins.reduce((sum, observed) => sum + ((observed - expected) ** 2) / expected, 0);
assert.ok(chi2 < 66.62, `chi²=${chi2.toFixed(2)} 未通过 df=35, p=0.001 门槛`);
assert.ok(bins.every((count) => Math.abs(count - expected) / expected < 0.02));
});
test("品质权重遵循 210:60:28:4:1", () => {
const rng = new SeededRng("HEROLL-QUALITY");
const bins = Object.fromEntries(QUALITY.map((name) => [name, 0]));
for (let i = 0; i < 200_000; i += 1) bins[rng.weighted(QUALITY, (item) => QUALITY_WEIGHTS[QUALITY.indexOf(item)])] += 1;
const totalWeight = QUALITY_WEIGHTS.reduce((a, b) => a + b, 0);
QUALITY.forEach((name, index) => {
const actual = bins[name] / 200_000;
const expected = QUALITY_WEIGHTS[index] / totalWeight;
assert.ok(Math.abs(actual - expected) < 0.004, `${name} ${actual} != ${expected}`);
});
});
test("同 seed 生成同棋盘、同骰子与同 RNG 索引", () => {
const a = new GameEngine(data, "DETERMINISTIC-SEED");
const b = new GameEngine(data, "DETERMINISTIC-SEED");
assert.deepEqual(a.state.tiles, b.state.tiles);
assert.deepEqual(a.state.coreEventSchedule, b.state.coreEventSchedule);
assert.deepEqual(a.rollDice(), b.rollDice());
assert.deepEqual(a.state.rng, b.state.rng);
});
test("40格棋盘越过起点进入下一层并恢复30%", () => {
const game = new GameEngine(data, "FLOOR-CROSSING");
game.state.currentHp = 50;
game.state.tileIndex = 39;
game.state.reviveUsedThisFloor = true;
game.rollDice();
assert.ok(game.state.floor >= 2);
assert.ok(game.state.currentHp >= 95);
assert.equal(game.state.reviveUsedThisFloor, false);
assert.equal(game.state.tiles.filter((tile) => tile.kind === "EVENT").length >= 2, true);
});
test("ROLL点数严格等于顺时针移动的实际格数", () => {
const game = new GameEngine(data, "ONE-POINT-ONE-TILE");
assert.equal(game.state.tiles.length, 40);
const roll = game.rollDice();
assert.equal(game.state.tileIndex, roll.sum, `ROLL ${roll.sum} 应从0移动到${roll.sum}`);
assert.ok(roll.sum >= 2 && roll.sum <= 12);
});
test("1至4骷髅地块严格生成对应数量怪物并在胜利后变荒地", () => {
for (const count of [1, 2, 3, 4]) {
const seed = `SKULL-TILE-${count}`;
const probe = new GameEngine(data, seed);
const target = probe.rollDice().sum;
const game = new GameEngine(data, seed);
game.state.tiles[target] = { index: target, kind: "BATTLE", consumed: false, enemyCount: count, pollutionLevel: 0 };
const roll = game.rollDice();
assert.equal(game.state.tileIndex, roll.sum);
assert.equal(game.state.battle.enemies.length, count);
game.debugWinBattle();
assert.equal(game.state.tiles[target].kind, "EMPTY");
}
});
test("小Boss与污染Boss地块进入战斗且胜利后变荒地", () => {
for (const kind of ["MINIBOSS", "INFECTED_BOSS"]) {
const seed = `SPECIAL-BOSS-${kind}`;
const probe = new GameEngine(data, seed);
const target = probe.rollDice().sum;
const game = new GameEngine(data, seed);
game.state.tiles[target] = { index: target, kind, consumed: false, enemyCount: 3, infectionTurnsRemaining: kind === "INFECTED_BOSS" ? 3 : undefined };
game.rollDice();
assert.equal(game.state.battle.isMiniBoss, true);
assert.equal(game.state.battle.enemies.length, 3);
game.debugWinBattle();
assert.equal(game.state.tiles[target].kind, "EMPTY");
}
});
test("未消灭的污染Boss在过层时连续3次令普通怪物地块污染加1", () => {
const game = new GameEngine(data, "INFECTED-BOSS-THREE-FLOORS");
game.state.tiles = Array.from({ length: 40 }, (_, index) => ({ index, kind: index === 0 ? "START" : "EMPTY", consumed: false }));
game.state.tiles[20] = { index: 20, kind: "BATTLE", consumed: false, enemyCount: 1, pollutionLevel: 0 };
game.state.tiles[25] = { index: 25, kind: "INFECTED_BOSS", consumed: false, enemyCount: 1, infectionTurnsRemaining: 3 };
for (let level = 1; level <= 3; level += 1) {
game.state.tileIndex = 39;
game.state.phase = "READY";
game.state.event = undefined;
game.state.battle = undefined;
game.rollDice();
assert.equal(game.state.tiles[20].enemyCount, level + 1);
assert.equal(game.state.tiles[20].pollutionLevel, level);
assert.equal(game.state.tiles[25].infectionTurnsRemaining, 3 - level);
}
});
test("事件结算后变为荒地且未被覆盖的荒地跨层保留", () => {
const seed = "EVENT-TO-WASTELAND";
const probe = new GameEngine(data, seed);
const target = probe.rollDice().sum;
const game = new GameEngine(data, seed);
game.state.tiles[target] = { index: target, kind: "EVENT", eventId: "EV-138", consumed: false };
game.rollDice();
assert.equal(game.state.phase, "EVENT");
game.resolveSimpleEvent();
assert.equal(game.state.tiles[target].kind, "EMPTY");
const emptyBefore = new Set(game.state.tiles.filter((tile) => tile.kind === "EMPTY").map((tile) => tile.index));
game.state.tileIndex = 39;
game.state.phase = "READY";
game.rollDice();
const retained = game.state.tiles.filter((tile) => tile.kind === "EMPTY" && emptyBefore.has(tile.index));
assert.ok(retained.length >= emptyBefore.size - 3, "除本层新增地块覆盖外,荒地必须跨层保留");
});
test("Boss 按 2 次 ROLL 蓄势、4 次 ROLL 倒计时并转换 4 个非祝福地块", () => {
let seed = "";
for (let attempt = 0; attempt < 100 && !seed; attempt += 1) {
const probe = new GameEngine(data, `BOSS-CYCLE-${attempt}`);
let sixth;
for (let roll = 0; roll < 6; roll += 1) {
probe.state.phase = "READY";
probe.state.battle = undefined;
probe.state.event = undefined;
probe.state.shop = undefined;
sixth = probe.rollDice();
}
if (sixth.sum < 10) seed = `BOSS-CYCLE-${attempt}`;
}
assert.ok(seed);
const game = new GameEngine(data, seed);
const rollAgain = () => {
game.state.phase = "READY";
game.state.battle = undefined;
game.state.event = undefined;
game.state.shop = undefined;
game.rollDice();
return game.drainEvents();
};
const firstEvents = rollAgain();
assert.equal(game.state.bossGauge.mode, "BUILDUP");
assert.equal(game.state.bossGauge.playerRolls, 1);
assert.ok(firstEvents.some((event) => event.type === "BOSS_GAUGE_CHANGED" && event.payload.playerRolls === 1));
assert.ok(!firstEvents.some((event) => event.type === "BOSS_ACTION"));
const secondEvents = rollAgain();
assert.equal(game.state.bossGauge.mode, "COUNTDOWN");
assert.equal(game.state.bossGauge.countdownRemaining, 4);
assert.ok(secondEvents.some((event) => event.type === "BOSS_COUNTDOWN_STARTED" && event.payload.remaining === 4));
for (const expected of [3, 2, 1]) {
const events = rollAgain();
assert.equal(game.state.bossGauge.countdownRemaining, expected);
assert.ok(events.some((event) => event.type === "BOSS_COUNTDOWN_TICK" && event.payload.remaining === expected));
assert.ok(!events.some((event) => event.type === "BOSS_ACTION"));
}
game.state.tileIndex = 0;
game.state.tiles = Array.from({ length: 40 }, (_, index) => ({
index,
kind: index === 0 ? "START" : index === 38 ? "EVENT" : "EMPTY",
eventId: index === 38 ? "EV-144" : undefined,
consumed: false,
}));
const sixthEvents = rollAgain();
const action = sixthEvents.find((event) => event.type === "BOSS_ACTION");
assert.ok(action);
assert.equal(action.payload.tiles.length, 4);
assert.equal(new Set(action.payload.tiles).size, 4);
assert.ok(!action.payload.tiles.includes(38), "祝福地块不得被 Boss 转换");
assert.equal(game.state.bossGauge.mode, "BUILDUP");
assert.equal(game.state.bossGauge.playerRolls, 0);
assert.equal(game.state.bossGauge.countdownRemaining, 0);
assert.equal(game.state.bossGauge.bossActions, 1);
assert.equal(game.state.tiles.filter((tile) => tile.kind === "BATTLE").length, 4);
assert.equal(game.state.tiles[38].eventId, "EV-144");
});
test("十层自然调度覆盖六类核心事件且商人仅保证在 3/6/9 层", () => {
const game = new GameEngine(data, "CORE-EVENT-COVERAGE");
const seen = new Set();
for (let floor = 1; floor <= 10; floor += 1) {
game.debugSet(floor, 0);
for (const tile of game.state.tiles) if (tile.eventId) seen.add(tile.eventId);
assert.equal(game.state.tiles.some((tile) => tile.kind === "MERCHANT"), [3, 6, 9].includes(floor));
}
for (const id of ["EV-MERCHANT", "EV-137", "EV-138", "EV-140", "EV-141", "EV-144"]) assert.ok(seen.has(id), `missing ${id}`);
});
test("第十层越过 Start 必定进入 Boss", () => {
const game = new GameEngine(data, "BOSS-TRIGGER");
game.debugSet(10, 999);
game.state.tileIndex = 39;
game.rollDice();
assert.equal(game.state.phase, "BOSS");
assert.equal(game.state.battle.isBoss, true);
assert.equal(game.state.battle.enemies[0].role, "BOSS");
});
test("第三骰只在基础双骰后提供替换决策", () => {
const game = new GameEngine(data, "THIRD-DIE-FLOW");
game.state.blessings.push({ blessingId: "AUG-THIRD-DIE", stringId: "ThirdDieReplace", stack: 1, value: 1 });
const roll = game.rollDice();
assert.equal(game.state.phase, "THIRD_DIE");
assert.ok(roll.c >= 1 && roll.c <= 6);
const resolved = game.chooseThirdDie("A");
assert.equal(resolved.chosenA, roll.c);
assert.equal(resolved.chosenB, roll.b);
assert.equal(game.state.pendingThirdDie, undefined);
});
test("商人固定显示三个升级槽和三个随机槽", () => {
const game = new GameEngine(data, "SHOP-SIX-SLOTS");
game.debugForceEvent("EV-MERCHANT");
const offers = game.getShopOffers();
assert.equal(offers.length, 6);
assert.equal(offers.slice(0, 3).every((offer) => offer.itemType === "FIXED"), true);
assert.equal(offers.slice(3).every((offer) => offer.itemType !== "FIXED"), true);
});
test("事件增强 transactionId 幂等且失败重入不重复发奖", () => {
const game = new GameEngine(data, "IDEMPOTENCY");
game.debugForceEvent("EV-140");
assert.equal(game.state.event.state.attempts, 5);
assert.equal(game.useEventBoost("BOOST_DIG_EXTRA"), true);
assert.equal(game.useEventBoost("BOOST_DIG_EXTRA"), false);
assert.equal(game.state.event.state.attempts, 10);
assert.equal(game.state.committedTransactions.length, 1);
});
test("广告复活入口已禁用,致死后直接进入本局失败", () => {
const game = new GameEngine(data, "NO-AD-REVIVE");
game.startBattle(false);
game.debugForceDeath();
assert.equal(game.state.phase, "DEFEAT");
assert.equal(game.instantRevive(), false);
});
test("玩家面板反击属性保留且受击后能触发反击事件", () => {
const game = new GameEngine(data, "COUNTER-ATTACK");
game.state.baseStats.counterAttack = 101;
game.state.baseStats.attackSpeed = 0.1;
game.startBattle(false);
game.drainEvents();
const events = [];
for (let i = 0; i < 40 && !events.some((event) => event.type === "COUNTER"); i += 1) {
game.tickBattle(250);
events.push(...game.drainEvents());
}
assert.equal(game.effectiveStats().counterAttack, 101);
assert.ok(events.some((event) => event.type === "COUNTER"));
});
test("暴击恢复为 criticalRate 大于 0..100 整数掷值", () => {
const collect = (rate) => {
const game = new GameEngine(data, `CRITICAL-${rate}`);
game.state.baseStats.attack = 1;
game.state.baseStats.attackSpeed = 1000;
game.state.baseStats.critRate = rate;
game.startBattle(false);
game.drainEvents();
const damageEvents = [];
for (let i = 0; i < 20 && damageEvents.length < 5; i += 1) {
game.tickBattle(250);
damageEvents.push(...game.drainEvents().filter((event) => event.type === "DAMAGE" && event.payload.actor === "player"));
}
return damageEvents;
};
const zero = collect(0);
const full = collect(101);
assert.ok(zero.length > 0 && zero.every((event) => event.payload.critical === false));
assert.ok(full.length > 0 && full.every((event) => event.payload.critical === true));
assert.ok(full.every((event) => event.payload.criticalRoll >= 0 && event.payload.criticalRoll <= 100));
});
test("唯一丹药仅由三个固定商品补充、加上限和增加恢复量", () => {
const game = new GameEngine(data, "POTION-SHOP");
game.debugForceEvent("EV-MERCHANT");
game.state.points = 999;
const offers = game.getShopOffers();
const fixed = Object.fromEntries(offers.filter((offer) => offer.itemType === "FIXED").map((offer) => [offer.fixedKey, offer]));
assert.deepEqual(Object.keys(fixed).sort(), ["potionAdd", "potionCount", "potionHeal"]);
assert.ok(offers.slice(3).every((offer) => offer.itemType !== "POTION"));
assert.equal(game.buyShopOffer(fixed.potionAdd.offerId), false);
assert.equal(game.state.potion.charges, 1);
game.state.potion.charges = 0;
assert.equal(game.buyShopOffer(fixed.potionAdd.offerId), true);
assert.equal(game.state.potion.charges, 1);
assert.equal(game.buyShopOffer(fixed.potionCount.offerId), true);
assert.equal(game.state.potion.maxCharges, 2);
assert.equal(game.state.potion.charges, 1);
assert.equal(game.buyShopOffer(fixed.potionHeal.offerId), true);
assert.equal(game.state.potion.healAmount, 80);
game.closeEvent();
game.startBattle(false);
game.state.currentHp = 1;
game.state.battle.playerHp = 1;
assert.equal(game.usePotion(), true);
assert.equal(game.state.currentHp, 81, "原作 HealLevel 80 是固定恢复 80 HP");
});
test("旧安全点可迁移固定治疗、Boss循环和40格棋盘", () => {
const original = new GameEngine(data, "LEGACY-SAVE").snapshot();
original.saveVersion = 2;
original.tiles = Array.from({ length: 10 }, (_, index) => ({ index, kind: index === 0 ? "START" : index === 3 ? "BATTLE" : "EMPTY", consumed: false }));
original.tileIndex = 3;
original.potion.healPercent = 80;
delete original.potion.healAmount;
original.bossGauge = { current: 50, maximum: 100, perPlayerAction: 50, bossActions: 2 };
const restored = new GameEngine(data, original);
assert.equal(restored.state.potion.healAmount, 80);
assert.equal(restored.state.potion.healPercent, undefined);
assert.deepEqual(restored.state.bossGauge, { mode: "BUILDUP", playerRolls: 1, buildupRollsRequired: 2, countdownRemaining: 0, countdownRolls: 4, bossActions: 2 });
assert.equal(restored.state.saveVersion, 3);
assert.equal(restored.state.tiles.length, 40);
assert.equal(restored.state.tileIndex, 12);
assert.equal(restored.state.tiles[12].kind, "BATTLE");
});
test("对子机缘永久叠加属性并只把先攻带入下一场战斗", () => {
let game;
let fortuneEvent;
for (let i = 0; i < 200 && !fortuneEvent; i += 1) {
game = new GameEngine(data, `FORTUNE-${i}`);
game.rollDice();
fortuneEvent = game.drainEvents().find((event) => event.type === "FORTUNE_TRIGGERED");
}
assert.ok(game && fortuneEvent);
assert.equal(game.state.fortune.damagePercent + game.state.fortune.maxHpPercent + game.state.fortune.defensePercent, 10);
game.state.phase = "READY";
game.state.battle = undefined;
game.state.fortune.pendingOpeningStrike = true;
game.startBattle(false);
assert.equal(game.state.fortune.pendingOpeningStrike, false);
assert.equal(game.state.battle.fortuneOpeningStrike, true);
game.drainEvents();
game.tickBattle(1);
assert.ok(game.drainEvents().some((event) => event.type === "FORTUNE_STRIKE"));
});
test("第一层自动战斗可在基准数值下完成", () => {
const game = new GameEngine(data, "BATTLE-BASELINE");
game.startBattle(false);
for (let i = 0; i < 500 && ["BATTLE", "BOSS"].includes(game.state.phase); i += 1) game.tickBattle(100);
assert.ok(["READY", "GEAR_REWARD"].includes(game.state.phase), `unexpected ${game.state.phase}`);
});
test("初始配置无需广告复活即可连续击败前三组怪物", () => {
const game = new GameEngine(data, "THREE-EARLY-GROUPS");
for (let encounter = 0; encounter < 3; encounter += 1) {
game.startBattle(false);
for (let i = 0; i < 600 && ["BATTLE", "BOSS"].includes(game.state.phase); i += 1) game.tickBattle(100);
assert.notEqual(game.state.phase, "DEFEAT", `第 ${encounter + 1} 组怪物导致失败`);
if (game.state.phase === "GEAR_REWARD") game.salvagePendingGear();
assert.equal(game.state.phase, "READY");
}
assert.ok(game.state.currentHp > 0);
assert.ok(game.state.dda.threatScale <= 1);
});
let failed = 0;
const started = performance.now();
for (const [name, fn] of tests) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
failed += 1;
console.error(`FAIL ${name}`);
console.error(error);
}
}
console.log(`\n${tests.length - failed}/${tests.length} passed in ${Math.round(performance.now() - started)}ms`);
if (failed) process.exitCode = 1;