Files
noname-shuying/tools/test_bonds.mjs
T
Diuse 82d80397b6 fixed # 新增羁绊系统
fixed # 新增遗物系统
fixed # 梦三模式下移除护甲上限限制
2026-09-30 14:33:17 +08:00

417 lines
18 KiB
JavaScript

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);
});