fixed # 新增UI子模块,服务梦三

This commit is contained in:
2026-09-30 16:58:13 +08:00
parent 0ccb14cecd
commit 12e9ac181f
4 changed files with 0 additions and 1090 deletions
-416
View File
@@ -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);
});
-230
View File
@@ -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);
});
-291
View File
@@ -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);
});
-153
View File
@@ -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");
});