Initial import of HEROLL source and design documents
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user