diff --git a/tools/test_bonds.mjs b/tools/test_bonds.mjs deleted file mode 100644 index ba2d67a..0000000 --- a/tools/test_bonds.mjs +++ /dev/null @@ -1,416 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { bondDefinitions, getBondCombat, getBondDeck, DEFAULT_BOND_DECK } - from "../mengsan/bonds/definitions.js"; -import { DEFAULT_BOND_INTENTS, getBondIntents, createBondIntentActions } - from "../mengsan/bonds/intents.js"; -import { applyStun, finishStunnedTurn, skipStunnedAction } - from "../mengsan/battle/stun-intent.js"; -import { bondProbability, ensureBonds, meetBond, selectBond, - prepareBondBattle, settleBondBattle, markBondDown, rescueBond } - from "../mengsan/bonds/state.js"; -import { initializeBondUnit, recordBondDeath } - from "../mengsan/bonds/battle.js"; -import { createRun } from "../mengsan/progression/state.js"; -import { applyStoryOutcome } from "../mengsan/progression/story.js"; -import { buildBattlePlan } from "../mengsan/battle/battle-director.js"; -import { createBattleSettlement } - from "../mengsan/battle/battle-settlement.js"; -import { createBattleFlow } from "../mengsan/battle/battle-flow.js"; -import { createMonsterIntentActions } - from "../mengsan/monsters/actions.js"; -import { finishDeathBlow } from "../mengsan/battle/death-blow.js"; -import config from "../mengsan/config.js"; - -const blank = () => ({ player: { character: "other" } }); -const withCombat = callback => { - const definition = bondDefinitions.guanyu; - const original = { deck: definition.deck, growth: definition.growth }; - definition.deck = ["sha", "shan", "tao"]; - definition.growth = Array.from({ length: 10 }, (_, index) => ({ - maxHp: 20 + index, hand: 2, draw: 1, handLimit: 4, energy: 3, - skills: index === 0 ? ["test_skill"] : [], - })); - try { return callback(); } - finally { Object.assign(definition, original); } -}; - -test("Liu Bei starts with two independent level-eight bonds; new runs reset", () => { - const run = createRun("mengsan_liubei_shuying"); - assert.equal(run.player.bonds.selected, null); - assert.deepEqual(run.player.bonds.npcs.guanyu, - { level: 8, progress: 0, down: false }); - assert.deepEqual(run.player.bonds.npcs.zhangfei, - { level: 8, progress: 0, down: false }); - run.player.bonds.npcs.guanyu.down = true; - assert.equal(createRun("mengsan_liubei_shuying") - .player.bonds.npcs.guanyu.down, false); - assert.equal(bondProbability(1), 0); - assert.equal(bondProbability(8), 7 / 9); - assert.equal(bondProbability(10), 1); -}); - -test("event meetings are idempotent and only one companion can be selected", () => { - const run = blank(); - applyStoryOutcome(run, { bonds: ["guanyu", { id: "zhangfei", level: 3 }] }); - assert.equal(meetBond(run, "guanyu", 8), false); - assert.equal(run.player.bonds.npcs.guanyu.level, 1); - selectBond(run, "guanyu"); - selectBond(run, "zhangfei"); - assert.equal(run.player.bonds.selected, "zhangfei"); - assert.throws(() => selectBond(run, "unknown")); - selectBond(run, null); - assert.equal(prepareBondBattle(run, () => 0), null); - assert.equal(settleBondBattle(run), null); -}); - -test("unconfigured or down NPCs still gain 25%; overflow and cap are exact", () => { - const run = blank(); - meetBond(run, "guanyu"); - selectBond(run, "guanyu"); - assert.equal(getBondCombat("guanyu", 1), null); - const npc = run.player.bonds.npcs.guanyu; - for (let index = 0; index < 4; index++) { - assert.equal(prepareBondBattle(run, () => 0), null); - assert.equal(settleBondBattle(run).gain, 25); - } - assert.equal(npc.level, 2); - assert.equal(npc.progress, 0); - npc.progress = 75; - run.bondBattle = { id: "guanyu", arrived: true }; - assert.equal(settleBondBattle(run).gain, 50); - assert.equal(npc.level, 3); - assert.equal(npc.progress, 25); - markBondDown(run, "guanyu"); - prepareBondBattle(run, () => { throw new Error("No draw when down"); }); - settleBondBattle(run); - assert.equal(npc.progress, 50); - assert.equal(rescueBond(run, "guanyu"), true); - assert.equal(rescueBond(run, "guanyu"), false); - npc.level = 9; - npc.progress = 75; - run.bondBattle = { id: "guanyu", arrived: true }; - settleBondBattle(run); - assert.deepEqual(npc, { level: 10, progress: 0, down: false }); - assert.equal(settleBondBattle(run), null); -}); - -test("one start draw, exclusive lower seat, own deck and native-death state", () => { - withCombat(() => { - const run = blank(); - meetBond(run, "guanyu", 8); - selectBond(run, "guanyu"); - let draws = 0; - const spec = prepareBondBattle(run, () => { draws++; return 0.1; }); - assert.equal(draws, 1); - assert.equal(run.bondBattle.arrived, false); - assert.deepEqual(spec.skills, ["test_skill"]); - assert.equal(spec.inheritSkills, false); - run.player.supports = [{ id: "item_helper", battles: -1, - unit: { character: "zhangfei", camp: "ally" } }]; - const plan = buildBattlePlan({ enemy: "enemy" }, run, spec); - assert.equal(plan.units[0].bondId, "guanyu"); - assert.equal(plan.units[0].after, "player"); - assert.equal(plan.units.length, 3); - const player = { storage: {}, node: { avatar: { style: {} } } }; - const combat = initializeBondUnit(player, spec, run); - assert.equal(run.bondBattle.arrived, true); - assert.equal(player.storage.mengsanMonster_shuying, undefined); - const firstDeck = combat.createDeck("battle_1"); - const nextDeck = combat.createDeck("battle_2"); - firstDeck.pop(); - assert.equal(nextDeck.length, 3); - assert.equal(nextDeck[0].id, "battle_2_0"); - const logs = []; - recordBondDeath(player, run, message => logs.push(message)); - assert.equal(run.player.bonds.npcs.guanyu.down, true); - assert.equal(logs.length, 1); - assert.equal(settleBondBattle(run).gain, 50); - assert.equal(prepareBondBattle(run, () => 0), null); - assert.equal(settleBondBattle(run).gain, 25); - rescueBond(run, "guanyu"); - assert.equal(prepareBondBattle(run, () => 0).hp, 27); - assert.equal(prepareBondBattle(run, () => 0.99), null); - }); -}); - -test("configured level one never arrives and level ten always arrives", () => { - withCombat(() => { - const run = blank(); - meetBond(run, "guanyu", 1); - selectBond(run, "guanyu"); - assert.equal(prepareBondBattle(run, () => 0), null); - assert.equal(settleBondBattle(run).gain, 25); - run.player.bonds.npcs.guanyu.level = 10; - assert.ok(prepareBondBattle(run, () => 0.999999)); - assert.throws(() => meetBond(run, "toString")); - assert.throws(() => selectBond(run, "toString")); - }); -}); - -test("omitted or empty decks and intent groups use independent defaults", () => { - withCombat(() => { - const definition = bondDefinitions.guanyu; - const original = { deck: definition.deck, intents: definition.intents }; - try { - for (const missing of [undefined, null, []]) { - definition.deck = missing; - definition.intents = missing; - const spec = getBondCombat("guanyu", 8); - assert.deepEqual(spec.deck, [...DEFAULT_BOND_DECK]); - assert.deepEqual(spec.intents, [...DEFAULT_BOND_INTENTS]); - const next = getBondCombat("guanyu", 8); - spec.deck.pop(); - spec.intents[0].damage = 99; - assert.equal(next.deck.length, 7); - assert.equal(next.intents[0].damage, 6); - const player = { storage: {} }; - const combat = initializeBondUnit(player, next, blank()); - assert.equal(combat.createDeck("default").length, 7); - } - assert.equal(getBondCombat("zhangfei", 8), null); - assert.deepEqual(getBondDeck({}), [...DEFAULT_BOND_DECK]); - assert.throws(() => getBondIntents({ intents: [{ damage: -1 }] })); - assert.throws(() => getBondIntents({ intents: [{ - id: "unknown", name: "错误效果", debuff: "unknown", stacks: 1, - }] })); - } finally { Object.assign(definition, original); } - }); -}); - -test("completed battle grows once across settlement reload and commit retries", async () => { - let storage = {}; - const run = createRun("mengsan_liubei_shuying"); - selectBond(run, "guanyu"); - run.bondBattle = { id: "guanyu", arrived: true }; - markBondDown(run, "guanyu"); - storage[config.saveKey] = structuredClone(run); - const store = { - read: () => structuredClone(storage), - update: async mutate => { - const draft = structuredClone(storage); - const result = mutate(draft); - storage = draft; - return structuredClone(result); - }, - }; - const make = () => createBattleSettlement({ store, config, - getRandomRewardChoices: () => [], applyReward() {}, - completeNode: () => true, enterNextAct: () => true }); - const input = { run, node: run.map.nodes[0], hp: 20, - encounter: { skipRandomReward: true } }; - const pending = await make().prepare(input); - assert.equal(pending.base.player.bonds.npcs.guanyu.progress, 50); - const restored = make(); - assert.equal((await restored.prepare(input)) - .base.player.bonds.npcs.guanyu.progress, 50); - await restored.choose(run.runId, pending.id, null); - await restored.commit(run.runId, pending.id); - await restored.commit(run.runId, pending.id); - const saved = storage[config.saveKey]; - assert.equal(saved.player.bonds.npcs.guanyu.progress, 50); - assert.equal(saved.player.bonds.npcs.guanyu.down, true); - assert.equal(saved.bondBattle, null); -}); - -test("companion changes on the post-battle map reach the next battle input", async () => { - const run = createRun("mengsan_liubei_shuying"); - const node = run.map.nodes[0]; - let saved = structuredClone(run); - const pending = { id: "battle", state: "chosen", outcome: "victory" }; - const flow = createBattleFlow({ - settlement: { - readRun: () => structuredClone(saved), - prepare: async () => pending, - commit: async () => ({ route: "map", run: structuredClone(saved) }), - }, - chooseReward: async () => null, quiesce: async () => {}, - showMap: async mapRun => { - selectBond(mapRun, "zhangfei"); - saved = structuredClone(mapRun); - return node; - }, - showEnding: async () => {}, - }); - const session = { requestStop() {}, drain: async () => {}, - dispose: async () => {} }; - await flow.enterBattle({ session, run, node, - encounter: {}, start() {} }); - assert.equal(flow.requestFinish({ session, hp: 20 }), true); - const next = await flow.wait(); - assert.equal(next.run.player.bonds.selected, "zhangfei"); - assert.equal(saved.player.bonds.selected, "zhangfei"); -}); - -const actor = (camp, armor = 0, vulnerable = 0) => { - const result = { alive: true, hp: 50, armor, hits: [], skills: [], - storage: { mengsanCamp_shuying: camp, - mengsanEnergy_shuying: 3, mengsanVulnerable_shuying: vulnerable }, - isAlive() { return this.alive; }, - damage(value) { - if (this.storage.mengsanVulnerable_shuying) value = Math.ceil(value * 1.5); - this.hits.push(value); - const blocked = Math.min(value, this.armor); - this.armor -= blocked; - this.hp -= value - blocked; - return Promise.resolve(); - }, - addSkill(skill) { this.skills.push(skill); }, markSkill() {}, - changeHujia(value) { this.armor += value; }, - die() { this.alive = false; }, - }; - return result; -}; - -test("NPC previews, pays once and cycles its intent only on its own actions", async () => { - const hero = actor("ally"); - const npc = actor("ally"); - const enemy = actor("enemy"); - const secondEnemy = actor("enemy", 3, 1); - const dead = actor("enemy"); - dead.alive = false; - npc.storage.mengsanBond_shuying = { - intents: DEFAULT_BOND_INTENTS, turnsTaken: 0, intent: null, - }; - const previews = [], logs = []; - let active = true; - const game = { players: [hero, npc, enemy, secondEnemy, dead], - mengsanSetEnemyIntent_shuying: (who, move) => previews.push(move), - log: (...values) => logs.push(values), - }; - const actions = createBondIntentActions(game, () => ({ - session: { active }, energyUI: new Map(), - })); - actions.planBondIntent(npc); - actions.planBondIntent(npc); - assert.equal(previews.length, 1); - assert.equal(previews[0].name, "援击"); - await actions.executeBondIntent(npc); - assert.deepEqual(enemy.hits, [6]); - assert.deepEqual(secondEnemy.hits, [9]); - assert.equal(hero.hits.length, 0); - assert.equal(dead.hits.length, 0); - assert.equal(npc.storage.mengsanEnergy_shuying, 2); - assert.equal(npc.storage.mengsanBond_shuying.turnsTaken, 1); - await actions.executeBondIntent(npc); - assert.equal(enemy.hits.length, 1); - actions.planBondIntent(npc); - assert.equal(npc.storage.mengsanBond_shuying.intent.name, "连击"); - await actions.executeBondIntent(npc); - assert.deepEqual(enemy.hits, [6, 3, 3]); - actions.planBondIntent(npc); - await actions.executeBondIntent(npc); - assert.equal(npc.armor, 5); - assert.equal(enemy.hits.length, 3); - actions.planBondIntent(npc); - assert.equal(npc.storage.mengsanBond_shuying.intent.name, "援击"); - await actions.executeBondIntent(npc); - assert.equal(enemy.hits.length, 3); - assert.match(logs.at(-1).join(""), /费用不足/); - actions.planBondIntent(npc); - npc.storage.mengsanEnergy_shuying = 3; - active = false; - await actions.executeBondIntent(npc); - assert.equal(npc.storage.mengsanEnergy_shuying, 3); -}); - -test("custom NPC intent supports multi-hit debuff and one self buff", async () => { - const npc = actor("ally"); - const enemy = actor("enemy"); - const otherEnemy = actor("enemy"); - const custom = [{ id: "custom", name: "定制意图", damage: 2, hits: 2, - debuff: "易伤", stacks: 2, block: 4, strength: 3 }]; - assert.equal(getBondIntents({ intents: custom }), custom); - npc.storage.mengsanBond_shuying = { - intents: custom, turnsTaken: 0, intent: null, - }; - const game = { players: [npc, enemy, otherEnemy], log() {}, - mengsanSetEnemyIntent_shuying() {} }; - const actions = createBondIntentActions(game, () => ({ - session: { active: true }, energyUI: new Map(), - })); - actions.planBondIntent(npc); - await actions.executeBondIntent(npc); - assert.deepEqual(enemy.hits, [2, 2]); - assert.deepEqual(otherEnemy.hits, [2, 2]); - assert.equal(enemy.storage.mengsanVulnerable_shuying, 2); - assert.equal(otherEnemy.storage.mengsanVulnerable_shuying, 2); - assert.equal(npc.armor, 4); - assert.equal(npc.storage.mengsanStrength_shuying, 3); -}); - -test("a stunned NPC cannot execute its preview or spend energy", async () => { - const npc = actor("ally"); - const enemy = actor("enemy"); - npc.storage.mengsanBond_shuying = { - intents: DEFAULT_BOND_INTENTS, turnsTaken: 0, intent: null, - }; - const game = { players: [npc, enemy], log() {}, - mengsanSetEnemyIntent_shuying() {} }; - const actions = createBondIntentActions(game, () => ({ - session: { active: true }, energyUI: new Map(), - })); - actions.planBondIntent(npc); - applyStun(npc, npc.storage.mengsanBond_shuying.intent, - { recover() {} }); - await actions.executeBondIntent(npc); - assert.equal(enemy.hits.length, 0); - assert.equal(npc.storage.mengsanEnergy_shuying, 3); - skipStunnedAction(npc, { cancel() {} }); - await finishStunnedTurn(npc); - await actions.executeBondIntent(npc); - assert.deepEqual(enemy.hits, [6]); -}); - -test("intent attacks hit all hostiles in full; armor and debuffs stay individual", async () => { - const enemy = actor("enemy"); - const hero = actor("ally", 3); - const helper = actor("ally", 0, 1); - const otherHelper = actor("ally"); - const dead = actor("ally"); - dead.alive = false; - const friendly = actor("enemy"); - enemy.storage.mengsanRaiderIntent_shuying = { - id: "test", damage: 4, hits: 2, debuff: "脆弱", stacks: 2, - block: 5, strength: 2, - }; - const game = { me: hero, - players: [hero, enemy, helper, otherHelper, friendly, dead], - log() {}, mengsanSetEnemyIntent_shuying() {}, - }; - const actions = createMonsterIntentActions(game, () => ({ - session: { active: true }, energyUI: new Map(), - })); - await actions.executeRaiderIntent(enemy); - assert.deepEqual(hero.hits, [4, 4]); - assert.equal(hero.hp, 45); - assert.deepEqual(helper.hits, [6, 6]); - assert.equal(helper.hp, 38); - assert.deepEqual(otherHelper.hits, [4, 4]); - assert.equal(hero.storage.mengsanFrail_shuying, 2); - assert.equal(helper.storage.mengsanFrail_shuying, 2); - assert.equal(friendly.hits.length, 0); - assert.equal(dead.hits.length, 0); - assert.equal(enemy.armor, 5); - assert.equal(enemy.storage.mengsanStrength_shuying, 2); - assert.equal(enemy.storage.mengsanEnergy_shuying, 2); -}); - -test("death blow hits every hostile then its source dies once", async () => { - const enemy = actor("enemy"); - const hero = actor("ally"); - const helper = actor("ally"); - enemy.storage.mengsanFlyconidIntent_shuying = { - id: "test", intentType: "death_blow", damage: 4, - }; - const game = { me: hero, players: [enemy, hero, helper], log() {}, - mengsanSetEnemyIntent_shuying() {} }; - const actions = createMonsterIntentActions(game, () => ({ - session: { active: true }, energyUI: new Map(), - })); - await actions.executeFlyconidIntent(enemy); - assert.deepEqual(hero.hits, [4]); - assert.deepEqual(helper.hits, [4]); - assert.equal(await finishDeathBlow(enemy), true); - assert.equal(await finishDeathBlow(enemy), false); -}); diff --git a/tools/test_mengsan_cards.mjs b/tools/test_mengsan_cards.mjs deleted file mode 100644 index 59af2da..0000000 --- a/tools/test_mengsan_cards.mjs +++ /dev/null @@ -1,230 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import { test } from "node:test"; -import { canAcquireCard } from "../mengsan/cards/card-definitions.js"; -import { createCardData } from "../mengsan/cards/card-data.js"; -import { rewards, rewardPools } from "../mengsan/content/shared/rewards.js"; -import * as upgrades from "../mengsan/cards/upgrades.js"; -import { canAcquireReward } from "../mengsan/progression/reward.js"; - -const load = source => import(`data:text/javascript;base64,${ - Buffer.from(source).toString("base64")}`); -const rulesSource = await readFile(new URL( - "../mengsan/battle/combat-rules.js", import.meta.url), "utf8"); -const rules = await load(rulesSource.replace( - /^import .*;\r?\n/gm, "const _status = {};\n")); -const cardsSource = await readFile(new URL( - "../mengsan/cards/mode-cards.js", import.meta.url), "utf8"); -const nativeCard = { - sha: { type: "basic", enable: true, range: () => true, - filterTarget: (card, player, target) => player !== target, - content: async event => { event.observedDamage = event.baseDamage; } }, - tao: {}, taoyuan: {}, nanman: {}, wanjian: {}, juedou: {}, - huogong: {}, shandian: {}, -}; -for (const name of ["nanman", "wanjian", "juedou"]) { - nativeCard[name].content = nativeCard.sha.content; -} -globalThis.mengsanCardTest = { rules, nativeCard, upgrades, logs: [] }; -const { createMengsanCards } = await load(` - const { rules, nativeCard, upgrades, logs } = globalThis.mengsanCardTest; - const { cardUpgradeLevel, cardUpgradeRule } = upgrades; - const { cardCost, canPayCard, isActiveCardUse, SHA_DAMAGE, - TRICK_DAMAGE } = rules; - const _status = {}; - const game = { log: (...args) => logs.push(args) }; - const standard = { card: nativeCard, translate: {}, - list: Object.keys(nativeCard).map(name => [null, null, name]) }; - const extra = { card: {}, translate: {}, list: [] }; - ${cardsSource.replace(/^import[\s\S]*?;\r?\n/gm, "")} -`); -const { card, translate, names } = createMengsanCards(); -globalThis.mengsanCardTest.card = card; - -test("sha damage changes without changing trick damage", async () => { - const event = {}; - await card.sha.content(event); - assert.equal(event.observedDamage, 6); - assert.equal(card.sha.baseDamage, 6); - assert.match(translate.sha_info, /6点伤害/); - for (const name of ["nanman", "wanjian", "juedou"]) { - const trickEvent = {}; - await card[name].content(trickEvent); - assert.equal(trickEvent.observedDamage, 4); - } - assert.equal(card.huogong.baseDamage, 4); -}); - -test("heavy strike costs two energy and cannot target its user", () => { - const used = { name: "mengsan_zhongsha" }; - const player = { storage: { mengsanEnergy_shuying: 1 } }; - const event = { name: "phaseUse", player }; - assert.equal(rules.cardCost(used), 2); - assert.equal(card.mengsan_zhongsha.enable(used, player, event), false); - player.storage.mengsanEnergy_shuying = 2; - assert.equal(card.mengsan_zhongsha.enable(used, player, event), true); - assert.equal(rules.payCard(player, used), true); - assert.equal(player.storage.mengsanEnergy_shuying, 0); - assert.equal(card.mengsan_zhongsha.filterTarget(used, player, player), false); -}); - -test("damage resolves before vulnerability; stacks add and dead targets skip", async () => { - for (const [alive, stacks] of [[true, 0], [true, 3], [false, 3]]) { - let settled = false; - const source = {}; - const target = { - storage: { mengsanVulnerable_shuying: stacks }, - damage(num, player) { - assert.equal(num, 8); - assert.equal(player, source); - assert.equal(this.storage.mengsanVulnerable_shuying, stacks); - const result = Promise.resolve().then(() => { - assert.equal(result.mengsanAttack_shuying, true); - settled = true; - }); - return result; - }, - isAlive: () => alive, - addSkill(skill) { - assert.equal(settled, true); - assert.equal(skill, "mengsan_vulnerable_shuying"); - }, - markSkill() {}, - }; - await card.mengsan_zhongsha.content({ target }, null, source); - assert.equal(target.storage.mengsanVulnerable_shuying, - alive ? stacks + 2 : stacks); - } -}); - -test("card library and rewards use the extension artwork", async () => { - for (const [file, helper] of [["card-library", "preview"], - ["reward-ui", "viewCard"]]) { - const source = await readFile(new URL( - `../mengsan/ui/${file}.js`, import.meta.url), "utf8"); - const module = await load(` - const lib = { assetURL: "/game/", translate: {}, - card: { mengsan_zhongsha: { - image: "${card.mengsan_zhongsha.image}" - } } }; - const get = { translation: name => name }; - lib.card.sha = globalThis.mengsanCardTest.card.sha; - lib.card.mengsan_zhongsha.cardPrompt = - globalThis.mengsanCardTest.card.mengsan_zhongsha.cardPrompt; - const ui = {}, AFFIX_INFO = {}, cardCost = () => 2; - const { cardUpgradeLevel, cardUpgradeRule } = - globalThis.mengsanCardTest.upgrades; - ${source.replace(/^import .*;\r?\n/gm, "")} - export { ${helper} }; - `); - const expected = "/game/extension/术樱包/mengsan/assets/cards/" + - "mengsan_zhongsha.png"; - const previous = globalThis.document; - globalThis.document = { createElement: () => ({ - children: [], appendChild(child) { this.children.push(child); }, - addEventListener() {}, - get content() { return { textContent: this.innerHTML || "" }; }, - }) }; - try { - if (helper === "viewCard") { - assert.equal(module.viewCard({ card: { - name: "mengsan_zhongsha" } }).image, expected); - continue; - } - const face = module.preview({ name: "mengsan_zhongsha" }, - { name: "重杀", suit: "黑桃", number: 7, - affixes: [] }, null); - const art = face.children[1]; - assert.equal(art.children[1].src, expected); - const details = module.describeLibraryCard({ - name: "mengsan_zhongsha", upgrade: 1 }); - assert.match(details.description, /10点伤害.*3层易伤/); - assert.equal(details.upgrade, 1); - assert.equal(details.upgradeLimit, 1); - } finally { - globalThis.document = previous; - } - } -}); - -test("heavy strike is registered, serializable and obtainable in rewards", () => { - assert.equal(canAcquireCard("mengsan_liubei_shuying", "mengsan_zhongsha"), true); - const data = createCardData({ id: "heavy", name: "mengsan_zhongsha", - suit: "spade", number: 7 }, { character: "mengsan_liubei_shuying" }); - assert.equal(data.name, "mengsan_zhongsha"); - assert.ok(names.includes(data.name)); - assert.ok(rewardPools["shared.pool.battle.normal"].includes( - "shared.reward.card.zhongsha")); - assert.equal(rewards["shared.reward.card.zhongsha"].card.name, data.name); -}); - -test("only configured cards can upgrade once, excluding eternal cards", () => { - const deck = [ - { name: "sha", upgrade: 0 }, - { name: "mengsan_zhongsha", upgrade: 0 }, - { name: "tao", upgrade: 0 }, - { name: "sha", upgrade: 1 }, - { name: "sha", upgrade: 0, affixes: ["eternal"] }, - ]; - assert.deepEqual(deck.map(upgrades.canUpgradeCard), - [true, true, false, false, false]); - assert.equal(upgrades.upgradeRandomCard(deck, () => 0.9), deck[1]); - assert.equal(deck[1].upgrade, 1); - assert.equal(upgrades.upgradeRandomCard(deck, () => 0), deck[0]); - assert.equal(deck[0].upgrade, 1); - assert.equal(upgrades.hasUpgradeableCard(deck), false); - assert.equal(upgrades.upgradeRandomCard(deck, - () => { throw new Error("empty candidates must not consume RNG"); }), null); - assert.equal(canAcquireReward({ player: { deck } }, - { effectId: "upgrade" }), false); - deck.push({ name: "sha", upgrade: 0 }); - assert.equal(canAcquireReward({ player: { deck } }, - { effectId: "upgrade" }), true); -}); - -test("new card data rejects upgrades on unsupported or full-level cards", () => { - const spec = { id: "upgrade", suit: "spade", number: 7, - name: "sha", upgrade: 1 }; - assert.equal(createCardData(spec).upgrade, 1); - assert.throws(() => createCardData({ ...spec, upgrade: 2 })); - assert.throws(() => createCardData({ ...spec, name: "tao" })); - assert.throws(() => createCardData({ ...spec, upgrade: -1 })); - assert.equal(upgrades.cardUpgradeLevel({ name: "sha", upgrade: 5 }), 1); - assert.equal(upgrades.cardUpgradeLevel({ name: "tao", upgrade: 5 }), 0); -}); - -test("upgraded sha applies nine damage through serialized and runtime data", async () => { - const stored = { name: "sha", upgrade: 1 }; - for (const used of [stored, - { name: "sha", storage: { mengsanCard_shuying: stored } }]) { - const event = { card: used, baseDamage: 6 }; - await card.sha.content(event); - assert.equal(event.observedDamage, 9); - assert.match(card.sha.cardPrompt(used), /9点伤害/); - } - const changed = { card: { name: "sha", storage: { - mengsanCard_shuying: { name: "tao", upgrade: 1 } } }, baseDamage: 6 }; - await card.sha.content(changed); - assert.equal(changed.observedDamage, 6); - const modified = { card: stored, baseDamage: 7 }; - await card.sha.content(modified); - assert.equal(modified.observedDamage, 10); -}); - -test("upgraded heavy strike deals ten before adding three vulnerability", async () => { - const used = { name: "mengsan_zhongsha", storage: { - mengsanCard_shuying: { name: "mengsan_zhongsha", upgrade: 1 } } }; - const target = { - storage: { mengsanVulnerable_shuying: 2 }, - damage(num) { - assert.equal(num, 10); - assert.equal(this.storage.mengsanVulnerable_shuying, 2); - return Promise.resolve(); - }, - isAlive: () => true, addSkill() {}, markSkill() {}, - }; - await card.mengsan_zhongsha.content({ card: used, target }, null, {}); - assert.equal(target.storage.mengsanVulnerable_shuying, 5); - assert.match(card.mengsan_zhongsha.cardPrompt(used), /10点伤害.*3层易伤/); - assert.equal(rules.cardCost(used), 2); -}); diff --git a/tools/test_relics.mjs b/tools/test_relics.mjs deleted file mode 100644 index 46e57fe..0000000 --- a/tools/test_relics.mjs +++ /dev/null @@ -1,291 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { grantRelic, canAcquireRelic, getRelic, heldRelics, - relicRewardIds, initializeRelicMaxHp } from "../mengsan/relics/definitions.js"; -import { applyRoomRelics, restRecovery } from "../mengsan/relics/rooms.js"; -import { createOpeningEffects, createRelicCombatSkills } - from "../mengsan/relics/combat.js"; -import { createRelicBattle, createRelicSkills, - applyBattleEndRelics } from "../mengsan/relics/battle.js"; -import { canAcquireReward, getRandomRewardChoices } - from "../mengsan/progression/reward.js"; -import { createBattleSettlement } - from "../mengsan/battle/battle-settlement.js"; -import config from "../mengsan/config.js"; - -const id = name => `mengsan_${name}_shuying`; -const newRun = () => ({ runId: "relic_run", actIndex: 0, revision: 1, - randomState: 123, - player: { character: "mengsan_liubei_shuying", hp: 20, - maxHp: 50, items: [], gold: 0, deck: [] }, - statistics: { defeatedEnemies: 0, goldEarned: 0 }, - map: { nodes: [{ id: "battle", completed: false }] }, -}); -const grant = (run, ...names) => { - for (const name of names) grantRelic(run, id(name)); -}; -const runtime = run => { - const draws = [], logs = []; - let running = true; - const battle = createRelicBattle(run, { - active: () => running, - draw: async amount => { draws.push(amount); }, - log: (relic, effect) => logs.push([relic.name, effect]), - }); - return { battle, draws, logs, stop: () => { running = false; } }; -}; - -test("strawberry raises max HP once without healing, including early grants", () => { - const run = newRun(); - grant(run, "strawberry", "strawberry"); - assert.equal(run.player.maxHp, 57); - assert.equal(run.player.hp, 20); - run.player.maxHp = null; - run.player.items = []; - grant(run, "strawberry"); - run.player.maxHp = 50; - initializeRelicMaxHp(run); - initializeRelicMaxHp(run); - assert.equal(run.player.maxHp, 57); -}); - -test("shop entrance and rest healing cap HP and survive reload without repeats", () => { - const run = newRun(); - grant(run, "meal_ticket", "regal_pillow"); - const shop = { id: "shop1", type: "shop" }; - assert.equal(applyRoomRelics(run, shop, "enter").recovered, 15); - const restored = structuredClone(run); - assert.equal(applyRoomRelics(restored, shop, "enter"), null); - const rest = { id: "rest1", type: "rest" }; - assert.equal(restRecovery(run), 23); - assert.equal(applyRoomRelics(run, rest, "upgrade"), null); - assert.equal(applyRoomRelics(run, rest, "heal").recovered, 15); - assert.equal(run.player.hp, 50); - assert.equal(applyRoomRelics(run, rest, "heal"), null); - assert.equal(restRecovery(newRun()), 8); -}); - -test("opening effects target enemies, apply buffs before damage, and lantern is temporary", async () => { - const run = newRun(); - grant(run, "festive_poppers", "bag_of_marbles", "red_mask", "vajra", "lantern"); - const hits = []; - const actor = camp => ({ storage: { mengsanCamp_shuying: camp }, - isAlive: () => true, addSkill() {}, markSkill() {}, - damage(amount, source, nature) { - hits.push({ amount, source, nature, - vulnerable: this.storage.mengsanVulnerable_shuying, - weak: this.storage.mengsanWeak_shuying }); - return Promise.resolve(); - } }); - const owner = actor("player"), enemy = actor("enemy"), ally = actor("ally"); - owner.storage.mengsanEnergy_shuying = 3; - owner.storage.mengsanMaxEnergy_shuying = 3; - let refreshes = 0; - const battle = createRelicBattle(run, { active: () => true, - draw: async () => {}, log: () => {}, - effect: createOpeningEffects({ players: [owner, enemy, ally] }, - owner, () => { refreshes++; }) }); - await battle.start(); - await battle.start(); - assert.equal(hits.length, 1); - assert.equal(hits[0].amount, 9); - assert.equal(hits[0].vulnerable, 1); - assert.equal(hits[0].weak, 1); - assert.equal(hits[0].nature, "nocard"); - assert.equal(owner.storage.mengsanStrength_shuying, 1); - assert.equal(ally.storage.mengsanWeak_shuying, undefined); - const first = {}; - await battle.turnStart(first); - await battle.turnStart(first); - assert.equal(owner.storage.mengsanEnergy_shuying, 4); - assert.equal(owner.storage.mengsanMaxEnergy_shuying, 3); - owner.storage.mengsanEnergy_shuying = owner.storage.mengsanMaxEnergy_shuying; - await battle.turnStart({}); - assert.equal(owner.storage.mengsanEnergy_shuying, 3); - assert.equal(refreshes, 1); -}); - -test("card attacks add owner strength before weakness and skip scripted damage", async () => { - const owner = { storage: { mengsanStrength_shuying: 1, - mengsanWeak_shuying: 1 } }; - const skills = createRelicCombatSkills(() => ({ session: { active: true } }), - () => owner); - const attack = skills.mengsan_relic_attack_shuying; - const event = { source: owner, card: {}, num: 6 }; - assert.equal(attack.filter(event), true); - await attack.content({}, event); - assert.equal(event.num, 5); - assert.equal(attack.filter({ ...event, card: null }), false); - assert.equal(attack.filter({ ...event, mengsanScriptedSkill_shuying: true }), false); - const enemy = { storage: { mengsanWeak_shuying: 1 }, - removeSkill(skill) { assert.equal(skill, "mengsan_weak_shuying"); } }; - const hit = { source: enemy, card: {}, num: 9 }; - await attack.content({}, hit); - assert.equal(hit.num, 6); - await skills.mengsan_weak_shuying.content({}, {}, enemy); - assert.equal(enemy.storage.mengsanWeak_shuying, 0); -}); - -test("relics use existing items, reject duplicates and preserve belt saves", () => { - const run = newRun(); - assert.equal(grantRelic(run, id("burning_blood")), true); - assert.equal(grantRelic(run, id("burning_blood")), false); - assert.equal(canAcquireRelic(run, id("burning_blood")), false); - assert.throws(() => grantRelic(run, "unknown")); - assert.equal(getRelic("unknown"), null); - run.player.items.push(id("hand_charm")); - run.player.handLimitBonus = 1; - assert.equal(grantRelic(run, id("hand_charm")), false); - assert.equal(run.player.handLimitBonus, 1); - const fresh = newRun(); - assert.equal(grantRelic(fresh, id("hand_charm")), true); - assert.equal(fresh.player.handLimitBonus, 1); - const restored = JSON.parse(JSON.stringify(run)); - assert.equal(heldRelics(restored).length, 2); - run.player.items.push(id("burning_blood")); - assert.equal(heldRelics(run).length, 2); -}); - -test("boss and chest relic rewards are available and owned ones disappear", () => { - const run = newRun(); - for (const pool of ["shared.pool.boss.premium", "act1.pool.chest"]) { - const choices = getRandomRewardChoices(run, pool, 100); - for (const rewardId of relicRewardIds) { - assert.ok(choices.some(choice => choice.id === rewardId)); - } - } - grant(run, "burning_blood"); - const rewardId = relicRewardIds.find(value => value.endsWith( - id("burning_blood"))); - assert.equal(canAcquireReward(run, config.rewards[rewardId]), false); - assert.equal(getRandomRewardChoices(run, - "shared.pool.boss.premium", 100).some(choice => - choice.id === rewardId), false); -}); - -test("opening and own-turn draws stack at exact turns and reset per battle", async () => { - const run = newRun(); - grant(run, "snake_ring", "long_snake_ring", "pendulum"); - const { battle, draws } = runtime(run); - assert.equal(battle.openingDraw, 2); - await battle.start(); - await battle.start(); - assert.deepEqual(draws, [2]); - assert.equal(battle.openingHandBonus, 2); - for (let turn = 1; turn <= 6; turn++) { - const event = {}; - await battle.turnStart(event); - await battle.turnStart(event); - } - assert.deepEqual(draws, [2, 2, 2, 2, 1, 1]); - const next = runtime(run); - await next.battle.start(); - await next.battle.turnStart({}); - assert.deepEqual(next.draws, [2, 2]); -}); - -test("puzzle ignores zero and healing, triggers once, and resets next battle", async () => { - const run = newRun(); - grant(run, "millennium_puzzle"); - const current = runtime(run); - await current.battle.start(); - await current.battle.loseHp({ num: 0 }); - await current.battle.loseHp({ num: 6 }); - assert.deepEqual(current.draws, []); - const damage = { num: -1 }; - await current.battle.loseHp(damage); - await current.battle.loseHp(damage); - await current.battle.loseHp({ num: -3 }); - assert.deepEqual(current.draws, [3]); - assert.equal(current.battle.status(getRelic(id("millennium_puzzle"))), - "本场已触发"); - const next = runtime(run); - await next.battle.start(); - await next.battle.loseHp({ num: -2 }); - assert.deepEqual(next.draws, [3]); -}); - -test("early HP loss waits for pile setup and preserves opening bonus draws", async () => { - const run = newRun(); - grant(run, "snake_ring", "millennium_puzzle"); - const current = runtime(run); - await current.battle.loseHp({ num: -1 }); - assert.deepEqual(current.draws, []); - await current.battle.start(); - assert.deepEqual(current.draws, [2, 3]); - assert.equal(current.battle.openingHandBonus, 5); - await current.battle.loseHp({ num: -1 }); - assert.deepEqual(current.draws, [2, 3]); - await current.battle.turnStart({}); - assert.equal(current.battle.openingHandBonus, 5); -}); - -test("engine adapter ignores other actors, armor-only loss and dead owner", async () => { - const run = newRun(); - grant(run, "millennium_puzzle", "long_snake_ring"); - const current = runtime(run); - const owner = { hp: 20 }, session = { active: true }; - const adapter = createRelicSkills(() => ({ - session, relics: current.battle }), () => owner).mengsan_relics_shuying; - await current.battle.start(); - assert.equal(adapter.filter({ player: {}, name: "phase" }), false); - assert.equal(adapter.filter({ player: owner, num: 0 }), false); - assert.equal(adapter.filter({ player: owner, num: -1 }), true); - await adapter.content({ triggername: "phaseBegin" }, - { player: owner, name: "phase" }); - await adapter.content({ triggername: "changeHp" }, - { player: owner, num: -1 }); - assert.deepEqual(current.draws, [2, 3]); - owner.hp = 0; - assert.equal(adapter.filter({ player: owner, num: -1 }), false); - owner.hp = 20; - session.active = false; - assert.equal(adapter.filter({ player: owner, name: "phase" }), false); - current.stop(); - await current.battle.turnStart({}); - assert.deepEqual(current.draws, [2, 3]); -}); - -test("blood healing caps at max HP and never revives a failed run", () => { - const run = newRun(); - grant(run, "burning_blood"); - assert.equal(applyBattleEndRelics(run, "victory"), 6); - assert.equal(run.player.hp, 26); - run.player.hp = 49; - assert.equal(applyBattleEndRelics(run, "victory"), 1); - assert.equal(run.player.hp, 50); - run.player.hp = 0; - assert.equal(applyBattleEndRelics(run, "defeat"), 0); - assert.equal(run.player.hp, 0); -}); - -test("settlement prepare, reload and repeated commit heal only once", async () => { - const run = newRun(); - grant(run, "burning_blood"); - const storage = { run: structuredClone(run) }; - const make = () => createBattleSettlement({ - store: { read: () => storage, - update: async change => change(storage) }, - config: { saveKey: "run", profileKey: "profile" }, - getRandomRewardChoices: () => [], applyReward: () => {}, - completeNode: result => { - result.map.nodes[0].completed = true; - return true; - }, - enterNextAct: () => false, - }); - const input = { run, node: run.map.nodes[0], hp: 20, - encounter: { skipRandomReward: true } }; - const pending = await make().prepare(input); - assert.equal(pending.base.player.hp, 26); - assert.equal(pending.relicRecovery, 6); - assert.equal((await make().prepare(input)).base.player.hp, 26); - const restored = make(); - const selected = await restored.choose(run.runId, pending.id, null); - assert.equal(selected.result.player.hp, 26); - await restored.choose(run.runId, pending.id, null); - await restored.commit(run.runId, pending.id); - assert.equal(storage.run.player.hp, 26); - await restored.commit(run.runId, pending.id); - assert.equal(storage.run.player.hp, 26); -}); diff --git a/tools/test_reward.mjs b/tools/test_reward.mjs deleted file mode 100644 index 9e6d54e..0000000 --- a/tools/test_reward.mjs +++ /dev/null @@ -1,153 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import { test } from "node:test"; -import { createBattleFlow } from "../mengsan/battle/battle-flow.js"; -import { createBattleSettlement } from "../mengsan/battle/battle-settlement.js"; - -const source = (await readFile(new URL( - "../mengsan/ui/reward-ui.js", import.meta.url), "utf8")) - .replace(/^import .*;\r?\n/gm, ""); -const rewardUI = await import(`data:text/javascript;base64,${Buffer.from( - `const lib = {}, get = {}, cardCost = () => 0;\n${source}` -).toString("base64")}`); - -class Element { - constructor(tag) { - this.tag = tag; - this.children = []; - this.listeners = {}; - this.textContent = ""; - this.isConnected = true; - } - appendChild(child) { this.children.push(child); } - setAttribute() {} - addEventListener(event, handler) { this.listeners[event] = handler; } - querySelector(tag) { - for (const child of this.children) { - if (child.tag === tag) return child; - const found = child.querySelector(tag); - if (found) return found; - } - return null; - } - focus() {} - showModal() { this.open = true; } - close() { this.open = false; } - remove() { this.isConnected = false; } - get content() { - return { textContent: (this.innerHTML || "").replace(/<[^>]*>/g, "") }; - } - get text() { - return [this.textContent, ...this.children.map(child => child.text)] - .join(" "); - } -} - -test("fixed rewards share the victory dialog and require confirmation", async () => { - const previous = globalThis.document; - const root = new Element("html"); - globalThis.document = { - documentElement: root, - createElement: tag => new Element(tag), - activeElement: new Element("button"), - }; - try { - const result = rewardUI.chooseBattleReward([], { - fixedRewards: [ - { id: "dilu", name: "的卢", description: "加入个人牌库。" }, - { id: "heal", name: "整顿伤势", description: "回复生命。" }, - ], - }); - const dialog = root.children[0]; - assert.equal(dialog.className, "mengsan-victory-dialog-shuying"); - assert.match(dialog.text, /固定战利品.*领取固定奖励.*的卢/); - assert.match(dialog.text, /加入个人牌库。.*整顿伤势:回复生命。/); - assert.doesNotMatch(dialog.text, /放弃本次选择/); - assert.equal(dialog.open, true); - await dialog.querySelector("button").listeners.click(); - assert.equal(await result, null); - assert.equal(dialog.isConnected, false); - - const choice = rewardUI.chooseBattleReward([ - { id: "gold", kind: "gold", amount: 20 }, - ], { fixedRewards: [{ id: "dilu", name: "的卢" }] }); - const selectable = root.children[1]; - assert.equal(selectable.className, dialog.className); - assert.match(selectable.text, /固定奖励:的卢/); - assert.match(selectable.text, /可选战利品.*金币 · 20.*放弃本次选择/); - await selectable.querySelector("button").listeners.click(); - assert.equal(await choice, "gold"); - - const skipped = rewardUI.chooseBattleReward([ - { id: "gold", kind: "gold", amount: 20 }, - ]); - const optional = root.children[2]; - assert.doesNotMatch(optional.text, /固定奖励/); - const skip = optional.children[0].children.at(-1); - assert.equal(skip.textContent, "放弃本次选择"); - skip.listeners.click(); - assert.equal(await skipped, null); - assert.equal(await rewardUI.chooseBattleReward([]), null); - } finally { - globalThis.document = previous; - } -}); - -test("fixed reward UI precedes settlement and retry does not award twice", async () => { - const run = { - runId: "run", actIndex: 0, revision: 1, - player: { hp: 4, maxHp: 4, gold: 0 }, - statistics: { defeatedEnemies: 0 }, - map: { nodes: [{ id: "opening", completed: false }] }, - }; - const storage = { run: structuredClone(run) }; - let awarded = 0, presented = 0, disposed = 0; - const settlement = createBattleSettlement({ - store: { - read: () => storage, - update: async change => change(storage), - }, - config: { saveKey: "run", profileKey: "profile" }, - getRandomRewardChoices: () => { throw Error("Unexpected random reward"); }, - applyReward: (result, id) => { - assert.equal(id, "dilu"); - awarded++; - result.reward = id; - }, - completeNode: result => { - result.map.nodes[0].completed = true; - return true; - }, - enterNextAct: () => false, - }); - const session = { - requestStop() {}, drain: async () => {}, - dispose: async () => { - if (++disposed === 1) throw Error("Cleanup failed"); - }, - }; - const flow = createBattleFlow({ - settlement, - chooseReward: async (choices, context) => { - assert.deepEqual(choices, []); - assert.deepEqual(context.fixedRewards, ["dilu"]); - assert.equal(awarded, 0); - presented++; - return null; - }, - showMap: async () => null, showEnding: async () => {}, - quiesce: async () => {}, - }); - await flow.enterBattle({ - session, run, node: run.map.nodes[0], start() {}, - encounter: { skipRandomReward: true, fixedRewards: ["dilu"] }, - }); - assert.equal(flow.requestFinish({ session, hp: 3 }), true); - await assert.rejects(flow.wait(), /Cleanup failed/); - await flow.retry(); - assert.equal(presented, 1); - assert.equal(awarded, 1); - assert.equal(storage.run.reward, "dilu"); - assert.equal(storage.run.map.nodes[0].completed, true); - assert.equal(flow.state, "map"); -});