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;