mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-03 02:20:01 +08:00
417 lines
18 KiB
JavaScript
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);
|
|
});
|