mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-03 02:20:01 +08:00
fixed # 新增羁绊系统
fixed # 新增遗物系统 fixed # 梦三模式下移除护甲上限限制
This commit is contained in:
@@ -7,11 +7,28 @@
|
||||
| `battle/` | 战斗会话、回合、结算、费用与通用战斗效果 |
|
||||
| `monsters/` | 怪物阶级、牌堆默认值和各怪物意图选择 |
|
||||
| `cards/` | 卡牌定义、词缀、生成和个人牌堆 |
|
||||
| `relics/` | 遗物(道具)定义、持有规则、战斗触发及资源待补清单 |
|
||||
| `bonds/` | NPC 羁绊、助战选择、成长与意图配置、默认牌库及濒死状态 |
|
||||
| `progression/` | 征程状态、存档、剧情选择和奖励逻辑 |
|
||||
| `content/` | 章节、遭遇、剧本、角色与内容注册表 |
|
||||
| `ui/` | 地图、对话、意图、奖励、图鉴及样式 |
|
||||
| `assets/intent/` | 意图图标 |
|
||||
| `assets/cards/` | 梦三专属卡面 |
|
||||
| `assets/portraits/` | 角色立绘 |
|
||||
| `docs/` | 战斗和关卡配置说明 |
|
||||
|
||||
新增功能优先放入所属领域;界面代码放在 `ui/`,怪物行动逻辑放在 `monsters/`。跨领域引用使用相对路径,避免新增一个涵盖所有功能的扁平 `runtime/` 目录。模块资源文件随 `tools/generate_manifest.py` 自动收录,发布入口仍为 `mengsan/register.js`。
|
||||
|
||||
梦三【杀】消耗 1 费用,基础伤害为 6;其他锦囊的基础伤害仍为 4。
|
||||
【重杀】(`mengsan_zhongsha`)是普通战斗奖励池中的通用基本牌:
|
||||
消耗 2 费用,对攻击范围内的一名其他角色直接造成 8 点伤害,
|
||||
然后给予存活目标 2 层易伤,每回合使用次数不限。
|
||||
易伤沿用既有规则:受到攻击伤害增加 50%(向上取整),
|
||||
目标每个自身回合结束减少 1 层;本次新施加的易伤不放大本次伤害。
|
||||
|
||||
强化规则集中在 `cards/upgrades.js`,未配置的牌不可强化。
|
||||
【杀】最多强化一次,伤害由 6 变为 9;【重杀】最多强化一次,
|
||||
伤害由 8 变为 10,易伤由 2 层变为 3 层,费用均不变。
|
||||
随机强化只选尚未满级、可强化且没有“永恒”词缀的牌。
|
||||
没有候选牌时禁用休息节点的磨砺,并从随机奖励中排除强化。
|
||||
旧存档不重写:历史超限等级按当前上限结算,未配置牌的历史等级不生效。
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
@@ -87,7 +87,7 @@ export function grantSupport(run, rewardId, spec) {
|
||||
supports.push({ id: `support_${number}`, rewardId, category: spec.category || "item", unit: copy(spec.unit), battles });
|
||||
}
|
||||
|
||||
export function buildBattlePlan(encounter, run) {
|
||||
export function buildBattlePlan(encounter, run, bondUnit = null) {
|
||||
const plan = copy(encounter.battlePlan || {
|
||||
units: [{ id: "enemy_1", character: encounter.enemy, camp: "enemy", tier: encounter.tier || "normal", hand: 4 }], rules: [],
|
||||
});
|
||||
@@ -98,6 +98,7 @@ export function buildBattlePlan(encounter, run) {
|
||||
assert(support.battles === -1 || (Number.isInteger(support.battles) && support.battles > 0), "支援持续场数无效");
|
||||
plan.units.push({ ...copy(support.unit), id: support.id });
|
||||
}
|
||||
if (bondUnit) plan.units.unshift(copy(bondUnit));
|
||||
validateBattlePlan(plan);
|
||||
return plan;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@ export function createBattleFlow({settlement, chooseReward, showMap, showEnding,
|
||||
if (job.pending.state !== "chosen") {
|
||||
state = "choosing";
|
||||
if (!job.hasChoice) {
|
||||
job.choiceId = job.pending.outcome === "defeat" || !job.pending.choices.length ? null : await chooseReward(copy(job.pending.choices), { boss: job.pending.boss });
|
||||
job.choiceId = job.pending.outcome === "defeat" ? null
|
||||
: await chooseReward(copy(job.pending.choices), {
|
||||
boss: job.pending.boss,
|
||||
fixedRewards: copy(job.pending.fixedRewards || []),
|
||||
});
|
||||
if (job.pending.outcome !== "defeat" && job.pending.choices.length && job.choiceId !== null && !job.pending.choices.some(c => c.id === job.choiceId)) throw new Error("Invalid reward selection");
|
||||
job.hasChoice = true;
|
||||
}
|
||||
@@ -37,7 +41,11 @@ export function createBattleFlow({settlement, chooseReward, showMap, showEnding,
|
||||
await quiesce();
|
||||
if (!job.delivered) {
|
||||
state = "presenting";
|
||||
if (job.committed.route === "map") job.nextNode = await showMap(copy(job.committed.run));
|
||||
if (job.committed.route === "map") {
|
||||
const mapRun = copy(job.committed.run);
|
||||
job.nextNode = await showMap(mapRun);
|
||||
job.committed.run = mapRun;
|
||||
}
|
||||
else await showEnding(job.committed.route, copy(job.committed.run));
|
||||
job.delivered = true;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
// Candidate settlement adapter. Dependencies are the current mode/state reward functions.
|
||||
import { applyBattleEndRelics } from "../relics/battle.js";
|
||||
import { settleBondBattle } from "../bonds/state.js";
|
||||
|
||||
const copy = value => JSON.parse(JSON.stringify(value));
|
||||
const idFor = (run, node) => JSON.stringify([run.runId, run.actIndex, node.id]);
|
||||
export function createBattleSettlement({store, config, getRandomRewardChoices, applyReward, completeNode, enterNextAct, now = Date.now}) {
|
||||
@@ -34,16 +37,19 @@ export function createBattleSettlement({store, config, getRandomRewardChoices, a
|
||||
}
|
||||
// Battle may have changed RNG/card state since the last checkpoint.
|
||||
const base = copy(snapshot); delete base.battleFlow;
|
||||
const bondGrowth = settleBondBattle(base);
|
||||
if (outcome === "victory") {
|
||||
base.player.hp = Math.max(1, Math.min(hp, base.player.maxHp ?? hp));
|
||||
base.statistics.defeatedEnemies += encounter.defeatedEnemies ?? 1;
|
||||
}
|
||||
const relicRecovery = hp > 0
|
||||
? applyBattleEndRelics(base, outcome) : 0;
|
||||
const rewards = outcome === "victory" && !encounter.skipRandomReward ? getRandomRewardChoices(base, encounter.rewardPool).filter(c => c.name) : [];
|
||||
if (outcome === "victory" && !encounter.skipRandomReward && !rewards.length) throw new Error("Empty battle reward pool");
|
||||
// Persist the configured amount as a candidate, never as automatic victory income.
|
||||
const choices = outcome === "victory" && !encounter.skipRandomReward
|
||||
? [{id:"mengsan.reward.gold.shuying", kind:"gold", name:"金币", amount:encounter.gold}, ...rewards] : [];
|
||||
run.battleFlow.pending = {id, state:"awaitingChoice", nodeId:node.id, base, choices:copy(choices), fixedRewards:copy(encounter.fixedRewards || []), victoryDialogue:copy(encounter.victoryDialogue || []), boss:!!encounter.boss, outcome};
|
||||
run.battleFlow.pending = {id, state:"awaitingChoice", nodeId:node.id, base, choices:copy(choices), fixedRewards:copy(encounter.fixedRewards || []), victoryDialogue:copy(encounter.victoryDialogue || []), boss:!!encounter.boss, outcome, relicRecovery, bondGrowth};
|
||||
return run.battleFlow.pending;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import { _status } from "../../../../noname.js";
|
||||
export const PLAYER_ENERGY = 3;
|
||||
export const PLAYER_HAND_LIMIT = 3;
|
||||
export const SHA_DAMAGE = 4;
|
||||
export const SHA_DAMAGE = 6;
|
||||
export const TRICK_DAMAGE = 4;
|
||||
|
||||
const COSTS = Object.freeze({ sha: 1, tao: 1, jiu: 1, shan: 0, wuxie: 0,
|
||||
const COSTS = Object.freeze({ sha: 1, mengsan_zhongsha: 2,
|
||||
tao: 1, jiu: 1, shan: 0, wuxie: 0,
|
||||
juedou: 2, nanman: 2, wanjian: 2, taoyuan: 2 });
|
||||
|
||||
export const cardCost = card => COSTS[card?.name] ?? 1;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { attackHitCount, outgoingAttackDamage } from "./intent-damage.js";
|
||||
import { beginDeathBlow, isDeathBlowIntent } from "./death-blow.js";
|
||||
|
||||
export const getIntentHostiles = (game, source) =>
|
||||
game.players.filter(target => target !== source && target.isAlive() &&
|
||||
target.storage?.mengsanCamp_shuying !==
|
||||
source.storage?.mengsanCamp_shuying);
|
||||
|
||||
export function createIntentExecutor(game, getActiveBattle) {
|
||||
const active = source => source.isAlive() &&
|
||||
Boolean(getActiveBattle()?.session.active);
|
||||
const applyDebuff = (target, move) => {
|
||||
if (!move.debuff) return;
|
||||
const frail = move.debuff === "脆弱";
|
||||
const key = frail ? "mengsanFrail_shuying" :
|
||||
"mengsanVulnerable_shuying";
|
||||
target.storage[key] = (target.storage[key] || 0) + move.stacks;
|
||||
target.addSkill(frail ? "mengsan_frail_shuying" :
|
||||
"mengsan_vulnerable_shuying");
|
||||
target.markSkill(frail ? "mengsan_frail_shuying" :
|
||||
"mengsan_vulnerable_shuying");
|
||||
};
|
||||
return async (source, move) => {
|
||||
for (const target of getIntentHostiles(game, source)) {
|
||||
if (!active(source)) break;
|
||||
if (!target.isAlive()) continue;
|
||||
if (isDeathBlowIntent(move)) {
|
||||
await beginDeathBlow(source, target, move);
|
||||
continue;
|
||||
}
|
||||
if (move.damage != null) {
|
||||
for (let hitIndex = 0; hitIndex < attackHitCount(move); hitIndex++) {
|
||||
if (!target.isAlive() || !active(source)) break;
|
||||
const hit = target.damage(
|
||||
outgoingAttackDamage(move, source), source);
|
||||
hit.mengsanAttack_shuying = true;
|
||||
hit.mengsanScriptedSkill_shuying = true;
|
||||
await hit;
|
||||
}
|
||||
}
|
||||
if (target.isAlive() && active(source)) applyDebuff(target, move);
|
||||
}
|
||||
if (!active(source)) return;
|
||||
if (move.block) await source.changeHujia(move.block);
|
||||
if (!active(source)) return;
|
||||
if (move.strength) {
|
||||
source.storage.mengsanStrength_shuying =
|
||||
(source.storage.mengsanStrength_shuying || 0) + move.strength;
|
||||
source.addSkill("mengsan_raider_strength_shuying");
|
||||
source.markSkill("mengsan_raider_strength_shuying");
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createCardData } from "../cards/card-data.js";
|
||||
import { bondDefinitions } from "./definitions.js";
|
||||
import { ensureBonds, markBondDown } from "./state.js";
|
||||
import { getBondIntents } from "./intents.js";
|
||||
|
||||
export function initializeBondUnit(player, spec, run) {
|
||||
if (!spec.bondId) return null;
|
||||
const portrait = bondDefinitions[spec.bondId].portrait;
|
||||
if (player.node?.avatar) {
|
||||
player.node.avatar.style.backgroundImage = portrait ?
|
||||
`url("${portrait}")` : "none";
|
||||
}
|
||||
player.storage.mengsanBond_shuying = {
|
||||
id: spec.bondId, draw: spec.draw, handLimit: spec.handLimit,
|
||||
intents: getBondIntents(spec).map(move => ({ ...move })),
|
||||
turnsTaken: 0, intent: null,
|
||||
};
|
||||
player.storage.mengsanMaxEnergy_shuying = spec.energy;
|
||||
player.storage.mengsanEnergy_shuying = spec.energy;
|
||||
if (run.bondBattle?.id === spec.bondId) run.bondBattle.arrived = true;
|
||||
return {
|
||||
createDeck(prefix) {
|
||||
return spec.deck.map((entry, index) => {
|
||||
const card = typeof entry === "string" ? { name: entry } : entry;
|
||||
return createCardData({
|
||||
...card, id: `${prefix}_${index}`,
|
||||
suit: card.suit ||
|
||||
["spade", "heart", "club", "diamond"][index % 4],
|
||||
number: card.number || index % 13 + 1,
|
||||
}, { enemy: true });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function recordBondDeath(player, run, log) {
|
||||
const id = player.storage?.mengsanBond_shuying?.id;
|
||||
if (!id || !run) return;
|
||||
if (ensureBonds(run).npcs[id]?.down) return;
|
||||
markBondDown(run, id);
|
||||
log(`${bondDefinitions[id].name}已退出战斗,羁绊状态:濒死`);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { getBondIntents } from "./intents.js";
|
||||
|
||||
export const DEFAULT_BOND_DECK = Object.freeze([
|
||||
"sha", "sha", "sha", "shan", "shan", "tao", "wuzhong",
|
||||
]);
|
||||
|
||||
export function getBondDeck(definition) {
|
||||
const deck = definition.deck;
|
||||
return deck == null || Array.isArray(deck) && !deck.length ?
|
||||
[...DEFAULT_BOND_DECK] : deck;
|
||||
}
|
||||
|
||||
const emptyGrowth = () => Array.from({ length: 10 }, () => ({
|
||||
maxHp: null,
|
||||
hand: null,
|
||||
draw: null,
|
||||
handLimit: null,
|
||||
energy: null,
|
||||
skills: null,
|
||||
}));
|
||||
|
||||
export const bondDefinitions = {
|
||||
guanyu: {
|
||||
name: "关羽", character: "guanyu", portrait: null,
|
||||
deck: null, intents: null, growth: emptyGrowth(),
|
||||
},
|
||||
zhangfei: {
|
||||
name: "张飞", character: "zhangfei", portrait: null,
|
||||
deck: null, intents: null, growth: emptyGrowth(),
|
||||
},
|
||||
};
|
||||
|
||||
export function getBondCombat(id, level) {
|
||||
const definition = bondDefinitions[id];
|
||||
if (!definition || !Number.isInteger(level) || level < 1 || level > 10) {
|
||||
return null;
|
||||
}
|
||||
const stats = definition.growth[level - 1];
|
||||
if (!stats || !["maxHp", "hand", "draw", "handLimit", "energy"]
|
||||
.every(key => Number.isInteger(stats[key]) && stats[key] >= 0)
|
||||
|| stats.maxHp < 1 || stats.energy < 1
|
||||
|| !Array.isArray(stats.skills)
|
||||
|| !stats.skills.every(skill => typeof skill === "string" && skill)) {
|
||||
return null;
|
||||
}
|
||||
const skills = new Set();
|
||||
for (const growth of definition.growth.slice(0, level)) {
|
||||
for (const skill of growth.skills || []) skills.add(skill);
|
||||
}
|
||||
return {
|
||||
id: `bond_${id}`, bondId: id, character: definition.character,
|
||||
camp: "ally", after: "player", inheritSkills: false,
|
||||
hp: stats.maxHp, maxHp: stats.maxHp, hand: stats.hand,
|
||||
draw: stats.draw, handLimit: stats.handLimit, energy: stats.energy,
|
||||
skills: [...skills],
|
||||
deck: getBondDeck(definition),
|
||||
intents: getBondIntents(definition).map(move => ({ ...move })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createIntentExecutor } from "../battle/intent-effects.js";
|
||||
import { isStunned } from "../battle/stun-intent.js";
|
||||
|
||||
export const DEFAULT_BOND_INTENTS = Object.freeze([
|
||||
Object.freeze({ id: "assist_strike", name: "援击", damage: 6 }),
|
||||
Object.freeze({ id: "assist_combo", name: "连击", damage: 3, hits: 2 }),
|
||||
Object.freeze({ id: "assist_guard", name: "整备", block: 5 }),
|
||||
]);
|
||||
|
||||
export function getBondIntents(definition) {
|
||||
const moves = definition.intents;
|
||||
if (moves == null || Array.isArray(moves) && !moves.length) {
|
||||
return DEFAULT_BOND_INTENTS;
|
||||
}
|
||||
if (!Array.isArray(moves) || moves.some(move =>
|
||||
!move || typeof move.id !== "string" || !move.id ||
|
||||
typeof move.name !== "string" || !move.name ||
|
||||
!["damage", "block", "strength"].every(key => move[key] == null ||
|
||||
Number.isInteger(move[key]) && move[key] >= 0) ||
|
||||
move.hits != null && (!Number.isInteger(move.hits) || move.hits < 1) ||
|
||||
move.debuff != null && (!["脆弱", "易伤"].includes(move.debuff) ||
|
||||
!Number.isInteger(move.stacks) || move.stacks < 1) ||
|
||||
move.intentType != null && move.intentType !== "death_blow" ||
|
||||
move.intentType === "death_blow" &&
|
||||
(move.damage == null || (move.hits ?? 1) !== 1) ||
|
||||
move.damage == null && !move.block && !move.strength && !move.debuff)) {
|
||||
throw new Error("羁绊 NPC 意图组配置无效");
|
||||
}
|
||||
return moves;
|
||||
}
|
||||
|
||||
export function selectBondIntent(combat) {
|
||||
return combat.intents[combat.turnsTaken % combat.intents.length];
|
||||
}
|
||||
|
||||
export function createBondIntentActions(game, getActiveBattle) {
|
||||
const executeEffects = createIntentExecutor(game, getActiveBattle);
|
||||
const planBondIntent = player => {
|
||||
const combat = player.storage?.mengsanBond_shuying;
|
||||
if (!combat || !player.isAlive() || isStunned(player) || combat.intent) {
|
||||
return;
|
||||
}
|
||||
combat.intent = selectBondIntent(combat);
|
||||
game.mengsanSetEnemyIntent_shuying(player, combat.intent);
|
||||
};
|
||||
const executeBondIntent = async player => {
|
||||
const combat = player.storage?.mengsanBond_shuying;
|
||||
if (!combat?.intent || !player.isAlive() || isStunned(player) ||
|
||||
!getActiveBattle()?.session.active) return;
|
||||
const move = combat.intent;
|
||||
combat.intent = null;
|
||||
combat.turnsTaken++;
|
||||
game.mengsanSetEnemyIntent_shuying(player, null);
|
||||
if (player.storage.mengsanEnergy_shuying < 1) {
|
||||
game.log(player, "费用不足,未发动羁绊意图", move.name);
|
||||
return;
|
||||
}
|
||||
player.storage.mengsanEnergy_shuying--;
|
||||
getActiveBattle()?.energyUI.get(player)?.();
|
||||
game.log(player, "消耗1费用发动羁绊意图", move.name);
|
||||
await executeEffects(player, move);
|
||||
};
|
||||
return { planBondIntent, executeBondIntent };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { bondDefinitions, getBondCombat } from "./definitions.js";
|
||||
|
||||
export const bondProbability = level => (level - 1) / 9;
|
||||
|
||||
export function ensureBonds(run) {
|
||||
return run.player.bonds ||= { selected: null, npcs: {} };
|
||||
}
|
||||
|
||||
export function meetBond(run, id, level = 1) {
|
||||
if (!Object.hasOwn(bondDefinitions, id) || !Number.isInteger(level)
|
||||
|| level < 1 || level > 10) throw new Error("羁绊 NPC 或等级无效");
|
||||
const bonds = ensureBonds(run);
|
||||
if (bonds.npcs[id]) return false;
|
||||
bonds.npcs[id] = { level, progress: 0, down: false };
|
||||
return true;
|
||||
}
|
||||
|
||||
export function initializeInnateBonds(run) {
|
||||
ensureBonds(run);
|
||||
if (run.player.character === "mengsan_liubei_shuying") {
|
||||
meetBond(run, "guanyu", 8);
|
||||
meetBond(run, "zhangfei", 8);
|
||||
}
|
||||
}
|
||||
|
||||
export function selectBond(run, id) {
|
||||
const bonds = ensureBonds(run);
|
||||
if (id !== null && !Object.hasOwn(bonds.npcs, id)) {
|
||||
throw new Error("尚未结识该 NPC");
|
||||
}
|
||||
bonds.selected = id;
|
||||
}
|
||||
|
||||
export function rescueBond(run, id) {
|
||||
const npc = ensureBonds(run).npcs[id];
|
||||
if (!npc?.down) return false;
|
||||
npc.down = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function markBondDown(run, id) {
|
||||
const npc = ensureBonds(run).npcs[id];
|
||||
if (npc) npc.down = true;
|
||||
}
|
||||
|
||||
export function prepareBondBattle(run, random) {
|
||||
const bonds = ensureBonds(run);
|
||||
const id = bonds.selected;
|
||||
const npc = bonds.npcs[id];
|
||||
run.bondBattle = npc ? { id, arrived: false } : null;
|
||||
if (!npc || npc.down) return null;
|
||||
const combat = getBondCombat(id, npc.level);
|
||||
if (!combat || random() >= bondProbability(npc.level)) return null;
|
||||
return combat;
|
||||
}
|
||||
|
||||
export function settleBondBattle(run) {
|
||||
const battle = run.bondBattle;
|
||||
if (!battle) return null;
|
||||
const npc = ensureBonds(run).npcs[battle.id];
|
||||
if (!npc) throw new Error("助战羁绊记录丢失");
|
||||
const gain = battle.arrived ? 50 : 25;
|
||||
if (npc.level < 10) {
|
||||
npc.progress += gain;
|
||||
while (npc.progress >= 100 && npc.level < 10) {
|
||||
npc.progress -= 100;
|
||||
npc.level++;
|
||||
}
|
||||
}
|
||||
if (npc.level === 10) npc.progress = 0;
|
||||
run.bondBattle = null;
|
||||
return { id: battle.id, gain, level: npc.level, progress: npc.progress };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cardDefinitions, canAcquireCard } from "./card-definitions.js";
|
||||
import { cardUpgradeRule } from "./upgrades.js";
|
||||
|
||||
const suits = ["spade", "heart", "club", "diamond"];
|
||||
|
||||
@@ -14,6 +15,7 @@ export function createCardData(spec, { character, enemy = false, registry = card
|
||||
!Number.isInteger(spec.number) || spec.number < 1 || spec.number > 13 ||
|
||||
(spec.nature != null && typeof spec.nature !== "string") ||
|
||||
!Number.isInteger(spec.upgrade ?? 0) || (spec.upgrade ?? 0) < 0 ||
|
||||
(spec.upgrade ?? 0) > (cardUpgradeRule(spec.name)?.maxLevel || 0) ||
|
||||
!Array.isArray(spec.affixes ?? []) || !(spec.affixes ?? []).every(value => typeof value === "string")) {
|
||||
throw new Error(`梦三牌数据无效:${spec.name}`);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 新增武将专属牌时填写 owner 为该武将 ID;通用牌填写 owner: null。
|
||||
const definitions = [
|
||||
{ name: "sha", category: "damage", owner: null },
|
||||
{ name: "mengsan_zhongsha", category: "damage", owner: null },
|
||||
{ name: "shan", category: "utility", owner: null },
|
||||
{ name: "tao", category: "recovery", owner: null },
|
||||
{ name: "jiu", category: "recovery", owner: null },
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
// 保留原牌名,以兼容原有牌的响应、装备技能及转化牌引用。
|
||||
import standard from "../../../../card/standard.js";
|
||||
import extra from "../../../../card/extra.js";
|
||||
import { _status } from "../../../../noname.js";
|
||||
import { cardCost, canPayCard, isActiveCardUse, SHA_DAMAGE } from "../battle/combat-rules.js";
|
||||
import { _status, game } from "../../../../noname.js";
|
||||
import { cardCost, canPayCard, isActiveCardUse, SHA_DAMAGE,
|
||||
TRICK_DAMAGE } from "../battle/combat-rules.js";
|
||||
import { cardUpgradeLevel, cardUpgradeRule } from "./upgrades.js";
|
||||
|
||||
const packs = [standard, extra];
|
||||
const names = [...new Set(packs.flatMap(pack => pack.list.map(entry => entry[2])))];
|
||||
@@ -40,11 +42,71 @@ export function createMengsanCards() {
|
||||
card.sha.baseDamage = SHA_DAMAGE;
|
||||
card.sha.content = async function (event, trigger, player) {
|
||||
if (typeof event.baseDamage !== "number") event.baseDamage = SHA_DAMAGE;
|
||||
if (cardUpgradeLevel(event.card)) {
|
||||
event.baseDamage += cardUpgradeRule("sha").damage - SHA_DAMAGE;
|
||||
}
|
||||
return nativeSha.content.call(this, event, trigger, player);
|
||||
};
|
||||
card.sha.usable = Infinity;
|
||||
card.sha.cardPrompt = () => `梦三:主动使用消耗1费用;目标须使用【闪】,否则受到${SHA_DAMAGE}点伤害。`;
|
||||
card.sha.cardPrompt = used => {
|
||||
const damage = cardUpgradeLevel(used)
|
||||
? cardUpgradeRule("sha").damage : SHA_DAMAGE;
|
||||
return `梦三:主动使用消耗1费用;目标须使用【闪】,` +
|
||||
`否则受到${damage}点伤害。每回合使用次数不限。` +
|
||||
(cardUpgradeLevel(used) ? "已强化(上限1次)。" :
|
||||
"可强化1次:伤害变为9点。");
|
||||
};
|
||||
translate.sha_info = `梦三:主动使用消耗1费用。出牌阶段,对攻击范围内的一名角色使用;其须使用【闪】,否则受到${SHA_DAMAGE}点伤害。每回合使用次数不限。`;
|
||||
card.mengsan_zhongsha = {
|
||||
type: "basic",
|
||||
fullskin: true,
|
||||
image: "ext:术樱包/mengsan/assets/cards/mengsan_zhongsha.png",
|
||||
mengsanCost_shuying: cardCost({ name: "mengsan_zhongsha" }),
|
||||
enable(used, player, event) {
|
||||
return !isActiveCardUse(event || _status.event, player) ||
|
||||
canPayCard(player, used);
|
||||
},
|
||||
usable: Infinity,
|
||||
selectTarget: 1,
|
||||
range: nativeSha.range,
|
||||
filterTarget: nativeSha.filterTarget,
|
||||
async content(event, trigger, player) {
|
||||
const target = event.target;
|
||||
const effect = cardUpgradeLevel(event.card)
|
||||
? cardUpgradeRule("mengsan_zhongsha")
|
||||
: { damage: 8, vulnerable: 2 };
|
||||
const damage = target.damage(effect.damage, player);
|
||||
damage.mengsanAttack_shuying = true;
|
||||
await damage;
|
||||
if (!target.isAlive()) return;
|
||||
target.storage.mengsanVulnerable_shuying =
|
||||
(target.storage.mengsanVulnerable_shuying || 0) +
|
||||
effect.vulnerable;
|
||||
target.addSkill("mengsan_vulnerable_shuying");
|
||||
target.markSkill("mengsan_vulnerable_shuying");
|
||||
game.log(target, `获得${effect.vulnerable}层易伤`);
|
||||
},
|
||||
ai: { order: 4, result: { target: -1.5 },
|
||||
tag: { damage: 1 } },
|
||||
cardPrompt(used) {
|
||||
const effect = cardUpgradeLevel(used)
|
||||
? cardUpgradeRule("mengsan_zhongsha")
|
||||
: { damage: 8, vulnerable: 2 };
|
||||
return "梦三:主动使用消耗2费用。对攻击范围内的" +
|
||||
`一名其他角色造成${effect.damage}点伤害,然后给予其` +
|
||||
`${effect.vulnerable}层易伤。受到攻击伤害增加50%` +
|
||||
"(向上取整),每个自身回合结束减少1层。" +
|
||||
"每回合使用次数不限。" + (cardUpgradeLevel(used)
|
||||
? "已强化(上限1次)。" :
|
||||
"可强化1次:10点伤害、3层易伤。");
|
||||
},
|
||||
};
|
||||
translate.mengsan_zhongsha = "重杀";
|
||||
translate.mengsan_zhongsha_info =
|
||||
"梦三:主动使用消耗2费用。出牌阶段,对攻击范围内的" +
|
||||
"一名其他角色造成8点伤害,然后给予其2层易伤。" +
|
||||
"易伤使受到的攻击伤害增加50%(向上取整)," +
|
||||
"每个自身回合结束减少1层。每回合使用次数不限。";
|
||||
// 【桃】的回复量也由梦三牌本体提供;救援仍不消耗费用。
|
||||
card.tao.content = async function (event) { await event.target.recover(8); };
|
||||
translate.tao_info = "梦三:主动使用消耗1费用,回复8点生命;濒死救援不消耗费用,同样回复8点生命。";
|
||||
@@ -53,9 +115,9 @@ export function createMengsanCards() {
|
||||
card.taoyuan.cardPrompt = () => translate.taoyuan_info;
|
||||
for (const name of ["nanman", "wanjian", "juedou"]) {
|
||||
const nativeContent = sourceCard(name).content;
|
||||
card[name].baseDamage = SHA_DAMAGE;
|
||||
card[name].baseDamage = TRICK_DAMAGE;
|
||||
card[name].content = async function (event, trigger, player) {
|
||||
if (typeof event.baseDamage !== "number") event.baseDamage = SHA_DAMAGE;
|
||||
if (typeof event.baseDamage !== "number") event.baseDamage = TRICK_DAMAGE;
|
||||
return nativeContent.call(this, event, trigger, player);
|
||||
};
|
||||
const detail = {
|
||||
@@ -67,7 +129,7 @@ export function createMengsanCards() {
|
||||
card[name].cardPrompt = () => translate[`${name}_info`];
|
||||
}
|
||||
// 火攻通过当前牌事件的 baseDamage 传入 damage("fire")。
|
||||
card.huogong.baseDamage = SHA_DAMAGE;
|
||||
card.huogong.baseDamage = TRICK_DAMAGE;
|
||||
translate.huogong_info = "梦三:主动使用消耗1费用。令一名有手牌的角色展示一张手牌;若你弃置同花色牌,对其造成4点火焰伤害。";
|
||||
card.huogong.cardPrompt = () => translate.huogong_info;
|
||||
// 闪电属于延时锦囊,其命中伤害在 effect 中显式写死为3,需要独立覆写。
|
||||
@@ -77,5 +139,5 @@ export function createMengsanCards() {
|
||||
};
|
||||
translate.shandian_info = "梦三:主动使用消耗1费用。若判定为黑桃2~9,目标受到12点雷电伤害;否则移至下家的判定区。";
|
||||
card.shandian.cardPrompt = () => translate.shandian_info;
|
||||
return { card, translate, names };
|
||||
return { card, translate, names: [...names, "mengsan_zhongsha"] };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// DIY must access ui piles inside the acting player's engine event, never cache global nodes.
|
||||
import { cardCost } from "../battle/combat-rules.js";
|
||||
import { AFFIX_INFO } from "./affixes.js";
|
||||
import { createHandUpgradeBadges } from "../ui/hand-upgrade.js";
|
||||
|
||||
|
||||
export function installPersonalPiles(session, owner, battle, run, resources, env) {
|
||||
@@ -14,6 +15,7 @@ export function installPersonalPiles(session, owner, battle, run, resources, env
|
||||
// Detached nodes: engine get.position recognises these IDs; no duplicate document IDs.
|
||||
draw.id = "cardPile"; discard.id = "discardPile";
|
||||
const owned = new Set(), restores = [], costBadges = new Map();
|
||||
const upgradeBadges = createHandUpgradeBadges(document);
|
||||
const strict = env.strict !== false;
|
||||
let released = false, forcedOwner = false;
|
||||
const personal = () => {
|
||||
@@ -47,6 +49,10 @@ export function installPersonalPiles(session, owner, battle, run, resources, env
|
||||
badge.textContent = "";
|
||||
badge.dataset.mengsanCost = cost;
|
||||
}
|
||||
function showHandBadges(card) {
|
||||
showCost(card);
|
||||
if (env.showCosts) upgradeBadges.refresh(card);
|
||||
}
|
||||
function adopt(card, data) {
|
||||
if (owned.has(card)) return;
|
||||
owned.add(card); resources.card(card);
|
||||
@@ -57,7 +63,7 @@ export function installPersonalPiles(session, owner, battle, run, resources, env
|
||||
// Retired affixes from old saves must not acquire new battle behaviour.
|
||||
card.storage.mengsanCard_shuying.affixes = (card.storage.mengsanCard_shuying.affixes || []).filter(key => key !== "annihilate");
|
||||
}
|
||||
if (data) showCost(card);
|
||||
if (data) showHandBadges(card);
|
||||
// Existing foreign/generated cards retain their original destruction semantics.
|
||||
if (!data) return;
|
||||
card.destroyLog = false;
|
||||
@@ -90,11 +96,13 @@ export function installPersonalPiles(session, owner, battle, run, resources, env
|
||||
const handObserver = new MutationObserver(mutations => {
|
||||
if (released) return;
|
||||
for (const mutation of mutations) for (const card of mutation.addedNodes) {
|
||||
if (card.classList?.contains("card")) showCost(card);
|
||||
if (card.classList?.contains("card")) showHandBadges(card);
|
||||
}
|
||||
});
|
||||
for (const zone of handZones) {
|
||||
for (const card of zone.childNodes) if (card.classList?.contains("card")) showCost(card);
|
||||
for (const card of zone.childNodes) {
|
||||
if (card.classList?.contains("card")) showHandBadges(card);
|
||||
}
|
||||
handObserver.observe(zone, { childList: true });
|
||||
}
|
||||
session.ownResource(handObserver, () => {
|
||||
@@ -104,6 +112,7 @@ export function installPersonalPiles(session, owner, battle, run, resources, env
|
||||
delete card.dataset.mengsanCost;
|
||||
}
|
||||
costBadges.clear();
|
||||
upgradeBadges.dispose();
|
||||
});
|
||||
const publicDiscard = ui.discardPile;
|
||||
for (const [key, node] of [["cardPile", draw], ["discardPile", discard]]) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
const upgrades = Object.freeze({
|
||||
sha: Object.freeze({ maxLevel: 1, damage: 9 }),
|
||||
mengsan_zhongsha: Object.freeze({ maxLevel: 1, damage: 10,
|
||||
vulnerable: 3 }),
|
||||
});
|
||||
|
||||
export const cardUpgradeRule = name =>
|
||||
Object.hasOwn(upgrades, name) ? upgrades[name] : null;
|
||||
|
||||
export function cardUpgradeLevel(card) {
|
||||
const data = card?.storage?.mengsanCard_shuying || card;
|
||||
if (!data || data.name !== card?.name) return 0;
|
||||
const limit = cardUpgradeRule(card.name)?.maxLevel || 0;
|
||||
return Number.isInteger(data.upgrade) && data.upgrade > 0
|
||||
? Math.min(data.upgrade, limit) : 0;
|
||||
}
|
||||
|
||||
export function canUpgradeCard(card) {
|
||||
const rule = cardUpgradeRule(card?.name);
|
||||
const level = card?.upgrade ?? 0;
|
||||
return Boolean(rule && Number.isInteger(level) && level >= 0 &&
|
||||
level < rule.maxLevel && !card.affixes?.includes("eternal"));
|
||||
}
|
||||
|
||||
export const hasUpgradeableCard = deck =>
|
||||
Array.isArray(deck) && deck.some(canUpgradeCard);
|
||||
|
||||
export function upgradeRandomCard(deck, random) {
|
||||
const eligible = Array.isArray(deck) ? deck.filter(canUpgradeCard) : [];
|
||||
if (!eligible.length) return null;
|
||||
const value = random();
|
||||
if (!Number.isFinite(value) || value < 0 || value >= 1) {
|
||||
throw new RangeError("梦三强化随机数必须位于[0, 1)");
|
||||
}
|
||||
const card = eligible[Math.floor(value * eligible.length)];
|
||||
card.upgrade = (card.upgrade || 0) + 1;
|
||||
return card;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { relicRewardIds } from "../../../relics/definitions.js";
|
||||
|
||||
export const rewardPools = {
|
||||
"act1.pool.chest": [
|
||||
...relicRewardIds,
|
||||
"shared.reward.item.handCharm",
|
||||
"shared.reward.maxHp",
|
||||
"shared.reward.support.scout",
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
export function createScenarioCharacters(Character) {
|
||||
return {
|
||||
mengsan_liubei_shuying: new Character({ sex: "male", group: "han", hp: 50, maxHp: 50,
|
||||
skills: [], img: "image/character/re_liubei.jpg" }),
|
||||
skills: ["mengsan_taoyuan_bond_shuying"],
|
||||
img: "image/character/re_liubei.jpg" }),
|
||||
// 国战男性普通小兵使用原生 shibing1 立绘;实际战斗生命由怪物类或关卡配置决定。
|
||||
mengsan_soldier_shuying: new Character({ sex: "male", group: "qun", hp: 4, maxHp: 4,
|
||||
skills: [], img: "image/character/shibing1.jpg" }),
|
||||
@@ -23,7 +24,10 @@ export function createScenarioCharacters(Character) {
|
||||
};
|
||||
}
|
||||
export const scenarioTranslations = {
|
||||
mengsan_liubei_shuying: "界刘备", mengsan_liubei_shuying_title: "50/50生命 · 无初始技能",
|
||||
mengsan_liubei_shuying: "界刘备",
|
||||
mengsan_liubei_shuying_title: "50/50生命 · 先天桃园羁绊",
|
||||
mengsan_taoyuan_bond_shuying: "桃园羁绊",
|
||||
mengsan_taoyuan_bond_shuying_info: "先天:本征程初始结识关羽、张飞,二人羁绊均为8级。可在行军菜单中指定一名助战角色;战斗结束,到场增加50%升级进度,未到场增加25%。",
|
||||
mengsan_soldier_shuying: "士兵",
|
||||
mengsan_flyconid_shuying: "飞蝇菌子",
|
||||
mengsan_raider_brute_shuying: "劫掠者暴徒",
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { relicRewards, relicRewardIds } from "../../relics/definitions.js";
|
||||
|
||||
export const rewards = {
|
||||
...relicRewards,
|
||||
"shared.reward.support.scout": {
|
||||
name: "道具·斥候援令", description: "本次征程后续每场战斗,友方张辽携 4 张手牌参战;重复获得不叠加人数。",
|
||||
support: { category: "item", battles: -1, unit: { character: "re_zhangliao", camp: "ally", hand: 4 } },
|
||||
},
|
||||
"shared.reward.card.sha": { effectId: "card_sha", name: "获得一张【杀】", description: "加入独立永久牌组" },
|
||||
"shared.reward.card.zhongsha": {
|
||||
card: { name: "mengsan_zhongsha" }, name: "获得一张【重杀】",
|
||||
description: "2费:造成8点伤害,然后给予目标2层易伤。",
|
||||
},
|
||||
"shared.reward.card.tao": { effectId: "card_tao", name: "获得一张【桃】", description: "加入独立永久牌组" },
|
||||
"shared.reward.card.wuzhong": { card: { name: "wuzhong" }, name: "获得一张【无中生有】", description: "加入独立永久牌组" },
|
||||
"shared.reward.card.upgrade": { effectId: "upgrade", name: "随机强化", description: "随机一张牌强化一级" },
|
||||
"shared.reward.card.upgrade": { effectId: "upgrade", name: "随机强化", description: "随机强化一张尚未满级的可强化牌" },
|
||||
"shared.reward.heal": { effectId: "heal", name: "整顿伤势", description: "回复 8 点生命" },
|
||||
"shared.reward.item.handCharm": { effectId: "item_hand", name: "道具·束带", description: "基础手牌上限额外 +1" },
|
||||
"shared.reward.item.handCharm": { effectId: "item_hand",
|
||||
relic: "mengsan_hand_charm_shuying", name: "道具·束带",
|
||||
description: "基础手牌上限额外 +1" },
|
||||
"shared.reward.skill.heroic": { effectId: "skill_yingyong", name: "极品技能·英勇", description: "本次征程永久获得【英勇】" },
|
||||
"shared.reward.maxHp": { effectId: "max_hp", name: "极品强化·生机", description: "生命上限与当前生命各 +5" },
|
||||
};
|
||||
@@ -16,10 +25,12 @@ export const rewards = {
|
||||
export const rewardPools = {
|
||||
"shared.pool.battle.normal": [
|
||||
"shared.reward.card.sha",
|
||||
"shared.reward.card.zhongsha",
|
||||
"shared.reward.card.tao",
|
||||
"shared.reward.card.wuzhong",
|
||||
],
|
||||
"shared.pool.boss.premium": [
|
||||
...relicRewardIds,
|
||||
"shared.reward.skill.heroic",
|
||||
"shared.reward.item.handCharm",
|
||||
"shared.reward.maxHp",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
- 本模块是梦三玩法规则,不批量修改武将技能源码。
|
||||
- 主角固定单位 ID:`player`;`ally` 为友方,`enemy` 为敌方。
|
||||
- `neutral` 仅保留预设,目前配置为中立会报错,不启用中立行为。
|
||||
- 主角使用个人牌堆;友方支援和敌人仍使用公共牌堆。
|
||||
- 主角、敌人、羁绊助战使用各自牌堆;其他友方支援使用公共牌堆。
|
||||
- 没有 `battlePlan` 的旧关卡继续使用原来随机的一名敌人。
|
||||
- 本版单场累计最多 8 个席位,包含主角、初始单位、奖励支援、以后出现的援军。
|
||||
|
||||
@@ -82,7 +82,8 @@ battlePlan: {
|
||||
再将奖励 ID 加入指定 rewardPools。相同奖励来源不叠加人数;临时次数可累加,永久来源保持永久。
|
||||
数据保存在 `run.player.supports`,旧存档没有该字段时按空列表处理。
|
||||
有限次数只随结算事务提交;重载未完成战斗不会额外扣次,重试结算不会重复发放。
|
||||
行囊的“战斗支援”显示来源名称和持续场数。此版本未实现羁绊系统本身。
|
||||
行囊的“战斗支援”显示来源名称和持续场数。羁绊助战单独在行军菜单
|
||||
“羁绊”中管理,规则和配置见 [bonds.md](bonds.md)。
|
||||
|
||||
## 已接入的示例
|
||||
- 林中伏兵“主动追击”:两名初始敌人,第 3 轮必定出现一名敌军援军。
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# 梦三羁绊与助战
|
||||
|
||||
## 当前规则
|
||||
|
||||
- 行军菜单 → 羁绊:可结识多名 NPC,但只能指定一名助战;允许不指定。
|
||||
- 羁绊仅随本次征程保存,新征程重置。刘备先天技能“桃园羁绊”
|
||||
初始结识 `guanyu`、`zhangfei`,均为 8 级、升级进度 0%,默认未指定助战。
|
||||
- 普通事件结识默认 1 级、进度 0%。每级助战概率为 `(level - 1) / 9`,
|
||||
1 级为 0%,10 级为 100%。
|
||||
- 战前在地图切换助战;开战只判定一次。成功后占玩家下家,AI 自动行动。
|
||||
原有剧情与道具支援保留,原有 8 席校验和座次代码保留。
|
||||
- 完成战斗结算:指定 NPC 到场 +50%,未到场 +25%。
|
||||
濒死、战斗配置未补齐也属于未到场。不要求 NPC 存活,不限定胜利。
|
||||
退出或重载未完成战斗不增长;重复结算不重复增长。
|
||||
- 100% 进度升一级并保留溢出,最高 10 级,满级不再积累进度。
|
||||
升级数值与新增技能从下一场生效。
|
||||
- 存活 NPC 下一场恢复满血、重建自己的抽牌堆/弃牌堆/消耗牌堆。
|
||||
抽牌堆耗尽后洗回自身弃牌堆,无可用牌时不使玩家征程失败。
|
||||
- NPC 在自身出牌阶段前消耗 1 费用执行当前意图,然后照常使用手牌。
|
||||
意图在开场及玩家新一轮行动时预告,按 NPC 自身已执行次数循环。
|
||||
NPC 获得的护甲在其下次主循环回合开始时清除,与劫掠者一致。
|
||||
- 原生死亡结算后离场,存档标记 `down: true`,界面显示“濒死”。
|
||||
未救助前不能参战,但仍可指定助战并获得未到场的 25% 进度。
|
||||
- 怪物意图攻击、负面效果作用于全部存活敌对单位,含剧情与道具友军。
|
||||
每个目标承受完整段数,各自结算易伤、护甲与技能;怪物自身增益只执行一次。
|
||||
意图伤害数字在目标状态不同时显示伤害范围,护甲仍由伤害流程抵扣。
|
||||
|
||||
## 手动补充 NPC
|
||||
|
||||
配置位置:`bonds/definitions.js` 的 `bondDefinitions`。
|
||||
|
||||
关羽、张飞的 `portrait` 以及 10 个 `growth` 行目前均预留。
|
||||
成长数值与技能未填写完整前可以结识、选择与成长,但不能参战;
|
||||
不继承原版武将技能。`deck` 和 `intents` 无需预先填写,已有默认兜底。
|
||||
`portrait` 为空时不显示原版立绘。
|
||||
|
||||
每个成长行的字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `maxHp` | 该等级的最大生命,参战时满血,正整数 |
|
||||
| `hand` | 初始手牌数量,非负整数 |
|
||||
| `draw` | 自身摸牌阶段摸牌数,非负整数 |
|
||||
| `handLimit` | 手牌上限,非负整数 |
|
||||
| `energy` | 每回合费用,正整数 |
|
||||
| `skills` | 本等级新解锁技能 ID 数组;无新增时填写 `[]`,低等级已解锁技能累计保留 |
|
||||
|
||||
`growth[0]` 对应 1 级,`growth[9]` 对应 10 级。数值逐行填写,不自动插值。
|
||||
当前关卡校验要求生命及初始手牌不超过 99。
|
||||
`deck` 为该 NPC 独立的基础牌组,填写牌名数组,或 `{name,suit,number}` 数组。
|
||||
牌名必须已在游戏注册。每场创建新的卡牌实例,不与其他 NPC 或玩家共享实例。
|
||||
|
||||
## 默认牌库和意图
|
||||
|
||||
`deck` 未填写、为 `null` 或 `[]` 时,使用 `DEFAULT_BOND_DECK`:
|
||||
3 张【杀】、2 张【闪】、1 张【桃】、1 张【无中生有】,每场独立生成并洗牌。
|
||||
|
||||
NPC 的 `intents` 未填写、为 `null` 或 `[]` 时,使用
|
||||
`bonds/intents.js` 的 `DEFAULT_BOND_INTENTS`,按下列顺序循环:
|
||||
|
||||
| 意图 | 效果 |
|
||||
| --- | --- |
|
||||
| 援击 | 对每名存活敌对单位造成 6 点攻击伤害 |
|
||||
| 连击 | 对每名存活敌对单位造成 2 段攻击伤害,每段 3 点 |
|
||||
| 整备 | 自身获得 5 点护甲 |
|
||||
|
||||
默认意图数值不随等级自动插值。NPC 可以在 `bondDefinitions` 中
|
||||
填写独立的 `intents` 数组来覆盖默认组,例如:
|
||||
|
||||
```js
|
||||
intents: [
|
||||
{ id: "strike", name: "重击", damage: 8 },
|
||||
{ id: "break", name: "破阵", damage: 3, hits: 2,
|
||||
debuff: "易伤", stacks: 1 },
|
||||
{ id: "guard", name: "坚守", block: 6 },
|
||||
],
|
||||
```
|
||||
|
||||
每项需要 `id`、`name`,可配置 `damage`、`hits`、`debuff`、`stacks`、
|
||||
`block`、`strength`。伤害和增益为非负整数,攻击段数为正整数;
|
||||
负面效果目前支持“脆弱”“易伤”。`intentType: "death_blow"`
|
||||
可选,但只允许单段攻击,并沿用出牌阶段结束后自身死亡的规则。
|
||||
显式填写的无效意图会报错,避免将错误配置静默替换成默认值。
|
||||
|
||||
所有攻击目标各自结算护甲、易伤及技能;自身护甲、力量增益只执行一次。
|
||||
费用不足时该项不产生效果,轮次仍向后推进;每场重新从第一项开始。
|
||||
|
||||
## 事件结识接口
|
||||
|
||||
剧情选项的 `outcome` 可填写:
|
||||
|
||||
```js
|
||||
outcome: {
|
||||
bonds: ["guanyu", { id: "zhangfei", level: 3 }],
|
||||
}
|
||||
```
|
||||
|
||||
已结识 NPC 再次结识不会重置等级、进度或濒死状态。
|
||||
也可从 `bonds/state.js` 导入 `meetBond(run, id, level)`,
|
||||
在现有事件结算中调用并使用现有征程存档流程保存。
|
||||
|
||||
## 救助接口预留
|
||||
|
||||
`bonds/state.js` 导出 `rescueBond(run, id)`:
|
||||
仅解除该 NPC 的濒死标记,返回是否发生状态变化。
|
||||
当前没有绑定道具或取得途径,菜单仅显示禁用的“救助道具待补充”按钮。
|
||||
以后接入地图道具时,应在同一次存档中完成道具扣除与状态解除;
|
||||
不能把此接口作为免费救助按钮。救助后下一场按当前等级满血参战。
|
||||
|
||||
## 验证
|
||||
|
||||
`tools/test_bonds.mjs` 覆盖初始化、事件重复结识、单选、概率边界、
|
||||
成长溢出/上限、濒死/救助、独立牌组、结算重复提交和多目标意图。
|
||||
另覆盖默认牌库与意图兜底、独立循环、费用不足、自定义意图和击晕期间禁止执行。
|
||||
这些检查不等同于游戏原生战斗运行验证;空缺战斗配置待补充后再实机验收。
|
||||
@@ -1,10 +1,15 @@
|
||||
# 梦三战斗规则(测试阶段)
|
||||
|
||||
只影响梦三模式,不修改 noname 的标准/军争原始牌包。`cards/mode-cards.js` 从标准、军争实体牌列表逐一复制模式专属定义,沿用原牌名以保留响应、装备、转化兼容性;图鉴说明在梦三模式覆盖。新征程的界刘备为 50/50 生命,默认每回合摸 2 张、手牌上限 3、回合费用恢复至 3。主动使用牌消耗费用,转化牌按转化后的牌名计费;响应【闪】、救援【桃】等不消耗费用。默认主动牌 1 费,`battle/combat-rules.js` 中列出例外。【杀】1 费、可多次使用,牌定义的基础伤害为 4;【桃】及【桃园结义】回复 8 点生命;【南蛮入侵】、【万箭齐发】、【决斗】、【火攻】基础伤害 4,【闪电】命中伤害 12。其余卡牌保留原有效果,并加上梦三费用与图鉴说明。
|
||||
只影响梦三模式,不修改 noname 的标准/军争原始牌包。`cards/mode-cards.js` 从标准、军争实体牌列表逐一复制模式专属定义,沿用原牌名以保留响应、装备、转化兼容性;图鉴说明在梦三模式覆盖。新征程的界刘备为 50/50 生命,默认每回合摸 2 张、手牌上限 3、回合费用恢复至 3。主动使用牌消耗费用,转化牌按转化后的牌名计费;响应【闪】、救援【桃】等不消耗费用。默认主动牌 1 费,`battle/combat-rules.js` 中列出例外。【杀】1 费、可多次使用,牌定义的基础伤害为 6;【桃】及【桃园结义】回复 8 点生命;【南蛮入侵】、【万箭齐发】、【决斗】、【火攻】基础伤害 4,【闪电】命中伤害 12。其余卡牌保留原有效果,并加上梦三费用与图鉴说明。
|
||||
|
||||
## 怪物
|
||||
|
||||
`monsters/monster.js` 的 `Monster` 类统一提供阶级默认值。普通/精英/Boss 分别每回合摸 1/2/3 张、手牌上限 3/5/7、回合费用 2/3/4。每名敌人拥有自己的抽牌堆、弃牌堆与手牌;怪物默认使用阶级牌堆,不再从公共牌堆摸牌。友方支援仍用公共牌堆。
|
||||
梦三模式护甲不设数值上限,玩家、敌人和友方单位均适用;
|
||||
调用 `changeHujia` 时传入的上限参数不生效。
|
||||
护甲增减、伤害抵扣与日志仍沿用引擎流程;脆弱仍使获得护甲
|
||||
减少 25%(向下取整),劫掠者仍在自身回合开始前清空剩余护甲。
|
||||
|
||||
`monsters/monster.js` 的 `Monster` 类统一提供阶级默认值。普通/精英/Boss 分别每回合摸 1/2/3 张、手牌上限 3/5/7、回合费用 2/3/4。每名敌人拥有自己的抽牌堆、弃牌堆与手牌;怪物默认使用阶级牌堆,不再从公共牌堆摸牌。羁绊助战使用各自专属牌堆;其他友方支援仍用公共牌堆。
|
||||
|
||||
`HEALTH` 为目前各敌人的独立生命值;新怪物未配置时使用阶级兜底生命值。`OpeningSoldier extends Monster` 是继承示例;新子类在 `MONSTER_TYPES` 注册。关卡单位可以写 `tier`、`hp/maxHp`、`draw`、`handLimit`、`energy`、`deck` 覆盖默认值;`deck` 可为牌名数组(如 `deck:["sha","shan","tao"]`),也可用 `{name,suit,number}` 指定单张牌。牌名必须在 noname 中存在。
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
- 苏双&张世平为一名男性蜀势力武将,暂设无技能、4/4、4张初始手牌,可自行调整。
|
||||
- `discardEquipment` 弃置指定单位装备区的指定牌;已不在装备区则不重复弃置,不影响其他装备。
|
||||
- `fixedRewards` 固定获得梅花5的卢一张,加入征程个人牌库;此关关闭随机三选一。奖励与节点完成一起事务提交,重试不会重复发牌。
|
||||
- 固定奖励与选择奖励共用过关奖励界面,以“固定战利品”/“可选战利品”等文字区分;固定奖励显示名称和说明,点击“领取固定奖励”后继续结算,不能放弃。若同时配置两类奖励,固定奖励全部获得,候选奖励仍择一领取或放弃。
|
||||
- 初始士兵/支援手牌仍来自公共牌堆;弃掉的敌方的卢走原生弃牌流程,胜利所得的卢是持久牌库新卡,并非再从弃牌区拾取。
|
||||
- 提前击败敌人允许正常获胜,此时未到第四次回合的支援不会触发。不强行等待、不添加免死。
|
||||
|
||||
|
||||
+131
-26
@@ -1,10 +1,18 @@
|
||||
import { createScenarioCharacters, scenarioTranslations } from "./content/scenario-characters.js";
|
||||
import { createMengsanCards } from "./cards/mode-cards.js";
|
||||
import { createRelicBattle, createRelicSkills } from "./relics/battle.js";
|
||||
import { grantRelic, getRelic } from "./relics/definitions.js";
|
||||
import { mountRelics } from "./ui/relics.js";
|
||||
import { buildBattlePlan, consumeSupports, createBattleDirector, grantSupport } from "./battle/battle-director.js";
|
||||
import { installPersonalPiles } from "./cards/personal-piles.js";
|
||||
import { createMonster } from "./monsters/monster.js";
|
||||
import { prepareBondBattle } from "./bonds/state.js";
|
||||
import { initializeBondUnit, recordBondDeath } from "./bonds/battle.js";
|
||||
import { bondDefinitions } from "./bonds/definitions.js";
|
||||
import { createBondIntentActions } from "./bonds/intents.js";
|
||||
import {
|
||||
createMonsterIntentActions,
|
||||
getIntentHostiles,
|
||||
isFlyconid,
|
||||
isRaider,
|
||||
} from "./monsters/actions.js";
|
||||
@@ -37,10 +45,11 @@ import { createBattleSettlement } from "./battle/battle-settlement.js";
|
||||
import { createBattleFlow, quiesceEngine } from "./battle/battle-flow.js";
|
||||
import { lib, game, ui, get, _status } from "../../../noname.js";
|
||||
import config from "./config.js";
|
||||
import { showMap } from "./ui/map/index.js";
|
||||
import { showMap as renderMap } from "./ui/map/index.js";
|
||||
import { getRandomRewardChoices } from "./progression/reward.js";
|
||||
import { canAcquireCard } from "./cards/card-definitions.js";
|
||||
import { addCardToDeck, createRandomCardData } from "./cards/card-data.js";
|
||||
import { hasUpgradeableCard, upgradeRandomCard } from "./cards/upgrades.js";
|
||||
import { chooseBattleReward } from "./ui/reward-ui.js";
|
||||
import { applyStoryOutcome, getAvailableStoryChoices } from "./progression/story.js";
|
||||
import { playDialogue } from "./ui/dialogue.js";
|
||||
@@ -72,6 +81,7 @@ const saveRun = async run => {
|
||||
}
|
||||
};
|
||||
const clearRun = modeStorage.clearRun;
|
||||
const showMap = run => renderMap(run, { saveRun });
|
||||
let battleSequence = 0;
|
||||
let activeBattle = null;
|
||||
const reportFlowError = error => { console.error("梦三流程暂停,未自动清档:", error); };
|
||||
@@ -100,6 +110,13 @@ const createCardInstance = (run, name = "sha", upgrade = 0, affixes = []) =>
|
||||
|
||||
const applyReward = (run, rewardId) => {
|
||||
const player = run.player;
|
||||
const relicId = config.rewards[rewardId]?.relic;
|
||||
if (relicId) {
|
||||
if (grantRelic(run, relicId)) {
|
||||
game.log(`获得遗物【${getRelic(relicId).name}】`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const cardReward = config.rewards[rewardId]?.card;
|
||||
if (cardReward) {
|
||||
if (!canAcquireCard(player.character, cardReward.name)) throw new Error(`武将 ${player.character} 无法获得牌:${cardReward.name}`);
|
||||
@@ -116,9 +133,10 @@ const applyReward = (run, rewardId) => {
|
||||
addCardToDeck(run, createCardInstance(run, "tao"));
|
||||
break;
|
||||
case "upgrade": {
|
||||
const eligible = player.deck.filter(card => !card.affixes?.includes("eternal"));
|
||||
const card = eligible[Math.floor(nextRandom(run) * eligible.length)];
|
||||
if (card) card.upgrade = Number(card.upgrade || 0) + 1;
|
||||
const card = upgradeRandomCard(player.deck, () => nextRandom(run));
|
||||
if (card) game.log("梦三:", get.translation(card.name),
|
||||
"强化至", card.upgrade, "级");
|
||||
else game.log("梦三:没有可强化卡牌");
|
||||
break;
|
||||
}
|
||||
case "heal":
|
||||
@@ -129,10 +147,7 @@ const applyReward = (run, rewardId) => {
|
||||
player.hp += 5;
|
||||
break;
|
||||
case "item_hand":
|
||||
if (!player.items.includes("mengsan_hand_charm_shuying")) {
|
||||
player.items.push("mengsan_hand_charm_shuying");
|
||||
player.handLimitBonus++;
|
||||
}
|
||||
grantRelic(run, "mengsan_hand_charm_shuying");
|
||||
break;
|
||||
case "skill_yingyong":
|
||||
if (!player.permanentSkills.includes("mengsan_yingyong_shuying")) {
|
||||
@@ -187,7 +202,11 @@ const finishUtilityNode = async (run, node) => {
|
||||
if (node.type == "rest") {
|
||||
const choice = await chooseButtons("休息节点", [
|
||||
{ id: "heal", name: "休息", description: "回复 8 点生命" },
|
||||
{ id: "upgrade", name: "磨砺", description: "随机强化一张卡牌" },
|
||||
{ id: "upgrade", name: "磨砺",
|
||||
description: hasUpgradeableCard(run.player.deck)
|
||||
? "随机强化一张尚未满级的可强化牌"
|
||||
: "没有可强化卡牌",
|
||||
disabled: !hasUpgradeableCard(run.player.deck) },
|
||||
]);
|
||||
applyReward(run, choice);
|
||||
}
|
||||
@@ -266,7 +285,8 @@ const initBattleUnit = (player, spec) => {
|
||||
player.storage.mengsanInitialHand_shuying = spec.hand ?? 4;
|
||||
for (const skill of spec.skills || []) player.addSkill(skill);
|
||||
player.update();
|
||||
return monster;
|
||||
return monster || initializeBondUnit(player, spec,
|
||||
_status.mengsanRun_shuying);
|
||||
};
|
||||
|
||||
const installMonsterPile = (player, monster, current) => {
|
||||
@@ -286,6 +306,8 @@ const {
|
||||
executeFlyconidIntent,
|
||||
executeRaiderIntent,
|
||||
} = createMonsterIntentActions(game, () => activeBattle);
|
||||
const { planBondIntent, executeBondIntent } =
|
||||
createBondIntentActions(game, () => activeBattle);
|
||||
const equipBattleUnit = async (player, spec, resources) => {
|
||||
for (const info of spec.equipment || []) {
|
||||
const card = resources.card(game.createCard(info.name, info.suit, info.number, info.nature));
|
||||
@@ -311,13 +333,17 @@ const createScenario = (plan, current) => {
|
||||
current.energyUI.set(player,
|
||||
mountEnergy(player, current.session, document));
|
||||
current.intentUI.set(player, mountEnemyIntent(player,
|
||||
current.session, STYLE_PATH, document, () => game.me));
|
||||
current.session, STYLE_PATH, document,
|
||||
() => getIntentHostiles(game, player)));
|
||||
}
|
||||
for (const participant of [...game.players, ...game.dead]) participant.setSeatNum(Number(participant.dataset.position) + 1);
|
||||
await equipBattleUnit(player, spec, current.resources);
|
||||
await player.draw(spec.hand ?? 4);
|
||||
await game.triggerEnter(player);
|
||||
if (monster && current.session.active) planEnemyIntent(player, _status.mengsanRun_shuying);
|
||||
if (monster && current.session.active) {
|
||||
planEnemyIntent(player, _status.mengsanRun_shuying);
|
||||
planBondIntent(player);
|
||||
}
|
||||
});
|
||||
await join;
|
||||
game.log(player, "作为", spec.camp === "ally" ? "友方支援" : "敌方援军", "加入战斗");
|
||||
@@ -344,7 +370,12 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
|
||||
// Old saves keep their deck instances, but the retired affix no longer exists.
|
||||
for (const card of run.player.deck) if (Array.isArray(card.affixes)) card.affixes = card.affixes.filter(key => key !== "annihilate");
|
||||
if (encounter.requiredCharacter && run.player.character !== encounter.requiredCharacter) throw new Error("该关卡仅限指定主角,请开始刘备的新征程");
|
||||
const plan = buildBattlePlan(encounter, run);
|
||||
const bondUnit = prepareBondBattle(run, () => nextRandom(run));
|
||||
const plan = buildBattlePlan(encounter, run, bondUnit);
|
||||
if (run.bondBattle) {
|
||||
game.log(`羁绊助战:${bondDefinitions[run.bondBattle.id].name},${
|
||||
bondUnit ? "本场将到场" : "本场未到场"}`);
|
||||
}
|
||||
for (const spec of [...plan.units, ...plan.rules.flatMap(r => r.effects.filter(e => e.type === "spawn").map(e => e.unit))]) {
|
||||
if (!lib.character[spec.character]) throw new Error("关卡武将未加载:" + spec.character);
|
||||
for (const skill of spec.skills || []) if (!lib.skill[skill]) throw new Error("关卡技能未加载:" + skill);
|
||||
@@ -397,7 +428,13 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
|
||||
participants.forEach((player, index) => {
|
||||
if (monsters[index]) currentBattle.intentUI.set(player,
|
||||
mountEnemyIntent(player, session, STYLE_PATH, document,
|
||||
() => game.me));
|
||||
() => getIntentHostiles(game, player)));
|
||||
});
|
||||
participants.forEach(player => {
|
||||
if (player.storage.mengsanBond_shuying) {
|
||||
currentBattle.energyUI.set(player,
|
||||
mountEnergy(player, session, document));
|
||||
}
|
||||
});
|
||||
activeBattle.director = createScenario(plan, currentBattle);
|
||||
activeBattle.director.bind("player", me);
|
||||
@@ -414,6 +451,14 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
|
||||
me.maxHp = run.player.maxHp;
|
||||
me.hp = Math.max(1, Math.min(run.player.hp, me.maxHp));
|
||||
me.update();
|
||||
currentBattle.relics = createRelicBattle(run, {
|
||||
active: () => session.active && me.hp > 0,
|
||||
draw: number => game.mengsanDraw_shuying(me, number),
|
||||
log: (relic, effect) => {
|
||||
game.log(me, `遗物【${relic.name}】发动:${effect}`);
|
||||
currentBattle.relicUI?.refresh();
|
||||
},
|
||||
});
|
||||
run.player.permanentSkills.forEach(skill => {
|
||||
if (lib.skill[skill] && !me.hasSkill(skill)) me.addSkill(skill);
|
||||
});
|
||||
@@ -422,10 +467,12 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
|
||||
"mengsan_card_affixes_shuying", "mengsan_scenario_shuying",
|
||||
"mengsan_card_payment_shuying", "mengsan_monster_draw_shuying",
|
||||
"mengsan_flyconid_action_shuying", "mengsan_raider_action_shuying",
|
||||
"mengsan_bond_action_shuying",
|
||||
"mengsan_raider_card_strength_shuying",
|
||||
"mengsan_death_blow_finish_shuying",
|
||||
"mengsan_stun_skip_shuying", "mengsan_stun_recover_shuying",
|
||||
"mengsan_stun_clear_shuying",
|
||||
"mengsan_relics_shuying",
|
||||
]) {
|
||||
if (!lib.skill.global.includes(skill)) {
|
||||
game.addGlobalSkill(skill);
|
||||
@@ -433,6 +480,7 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
|
||||
}
|
||||
}
|
||||
activeBattle.pilesUI = mountBattlePiles(session, _status.mengsanBattle_shuying);
|
||||
currentBattle.relicUI = mountRelics(session, run, currentBattle.relics);
|
||||
activeBattle.gmUI = mountGMManager(session, me, activeBattle.personalPiles, _status.mengsanBattle_shuying, resources, game);
|
||||
for (let index = 0; index < plan.units.length; index++) await equipBattleUnit(participants[index], plan.units[index], resources);
|
||||
game.syncState();
|
||||
@@ -444,6 +492,8 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
|
||||
await game.mengsanDraw_shuying(me, 4);
|
||||
if (!session.active) return;
|
||||
activeBattle.personalPiles.drawInnate();
|
||||
await currentBattle.relics.start();
|
||||
if (!session.active) return;
|
||||
await playDialogue(encounter.openingDialogue, { run, title: encounter.name || "开场剧情" });
|
||||
if (!session.active) return;
|
||||
await currentBattle.director.start();
|
||||
@@ -456,16 +506,21 @@ const prepareBattle = async (run, node, encounter, session, resources) => {
|
||||
loop.setContent(async event => runPhaseLoop(event, me, session, {game, lib, _status, get, beforeTurn: async player => {
|
||||
if (player === me && !openingHandChecked) {
|
||||
openingHandChecked = true;
|
||||
currentBattle.personalPiles.trimOpeningHand(4);
|
||||
currentBattle.personalPiles.trimOpeningHand(
|
||||
4 + currentBattle.relics.openingHandBonus);
|
||||
}
|
||||
if (isRaider(player) && player.hujia > 0) await player.changeHujia(-player.hujia);
|
||||
if ((isRaider(player) || player.storage.mengsanBond_shuying) &&
|
||||
player.hujia > 0) await player.changeHujia(-player.hujia);
|
||||
if (player.storage.mengsanMaxEnergy_shuying != null) {
|
||||
player.storage.mengsanEnergy_shuying = player.storage.mengsanMaxEnergy_shuying;
|
||||
currentBattle.energyUI.get(player)?.();
|
||||
}
|
||||
await currentBattle.director.beforeTurn(player);
|
||||
if (player === me && session.active) {
|
||||
for (const enemy of game.players) planEnemyIntent(enemy, run);
|
||||
for (const participant of game.players) {
|
||||
planEnemyIntent(participant, run);
|
||||
planBondIntent(participant);
|
||||
}
|
||||
}
|
||||
game.checkResult();
|
||||
}}));
|
||||
@@ -489,9 +544,25 @@ const registerPlayers = (session, players) => {
|
||||
};
|
||||
const makeFlow = (releaseSkills = async () => {}) => createBattleFlow({
|
||||
releaseSkills,
|
||||
presentVictory: pending => playDialogue(pending.victoryDialogue, { run: pending.base, title: "战后剧情" }),
|
||||
presentVictory: pending => {
|
||||
if (pending.bondGrowth) {
|
||||
const growth = pending.bondGrowth;
|
||||
game.log(`羁绊【${bondDefinitions[growth.id].name}】进度增加${
|
||||
growth.gain}%,当前${growth.level}级(${growth.progress}%)`);
|
||||
}
|
||||
if (pending.relicRecovery > 0) {
|
||||
game.log(`遗物【燃烧之血】战后回复${pending.relicRecovery}点生命`);
|
||||
}
|
||||
return playDialogue(pending.victoryDialogue,
|
||||
{ run: pending.base, title: "战后剧情" });
|
||||
},
|
||||
settlement:createBattleSettlement({store:modeStorage, config, getRandomRewardChoices, applyReward, completeNode, enterNextAct}),
|
||||
chooseReward:chooseBattleReward,
|
||||
chooseReward: (choices, options) => chooseBattleReward(choices, {
|
||||
...options,
|
||||
fixedRewards: options.fixedRewards.map(id => ({
|
||||
...config.rewards[id], id,
|
||||
})),
|
||||
}),
|
||||
showMap,
|
||||
showEnding:async route => { game.over(route === "victory"); },
|
||||
quiesce:() => quiesceEngine(_status),
|
||||
@@ -499,10 +570,15 @@ const makeFlow = (releaseSkills = async () => {}) => createBattleFlow({
|
||||
const requestBattleFinish = outcome => {
|
||||
const current = activeBattle;
|
||||
if (!current) return false;
|
||||
for (const player of game.dead) {
|
||||
recordBondDeath(player, _status.mengsanRun_shuying,
|
||||
message => game.log(message));
|
||||
}
|
||||
const accepted = current.flow.requestFinish({session:current.session, hp:game.me?.hp || 0, outcome, defeatedEnemies:current.director?.defeatedEnemies ?? 1});
|
||||
if (accepted) {
|
||||
current.pilesUI?.dispose();
|
||||
current.gmUI?.dispose();
|
||||
current.relicUI?.dispose();
|
||||
stopBattleTurn(current.session, _status.eventManager);
|
||||
if (_status.mengsanBattle_shuying) _status.mengsanBattle_shuying.resolving = true;
|
||||
current.finished();
|
||||
@@ -627,18 +703,22 @@ const createMode = identityMode => {
|
||||
...identityElement,
|
||||
player: {
|
||||
...identityPlayer,
|
||||
dieAfter() { game.checkResult(); },
|
||||
dieAfter() {
|
||||
recordBondDeath(this, _status.mengsanRun_shuying,
|
||||
message => game.log(message));
|
||||
game.checkResult();
|
||||
},
|
||||
dieAfter2() {}, // Identity-mode kill rewards/loyalist penalties do not apply here.
|
||||
isFriendOf(player) { return this === player || (this.storage.mengsanCamp_shuying === player?.storage?.mengsanCamp_shuying); },
|
||||
isEnemyOf(player) { return Boolean(player && this.storage.mengsanCamp_shuying !== player.storage?.mengsanCamp_shuying); },
|
||||
getEnemies(filter, includeDie) { return game[includeDie ? "filterPlayer2" : "filterPlayer"](p => this.isEnemyOf(p) && (!filter || filter(p))); },
|
||||
getFriends(filter, includeDie) { const self = filter === true; return game[includeDie ? "filterPlayer2" : "filterPlayer"](p => (p !== this || self) && this.isFriendOf(p) && (typeof filter !== "function" || filter(p))); },
|
||||
changeHujia(num, type, limit) {
|
||||
changeHujia(num, type) {
|
||||
// 脆弱只削减正向获得的护甲,不改变受伤时消耗护甲的数值。
|
||||
if (this.storage?.mengsanFrail_shuying > 0 && (num == null || num > 0) && type !== "damage") {
|
||||
num = Math.floor((num ?? 1) * 0.75);
|
||||
}
|
||||
return nativeChangeHujia.call(this, num, type, limit);
|
||||
return nativeChangeHujia.call(this, num, type, false);
|
||||
},
|
||||
getHandcardLimit() {
|
||||
if (this.storage?.mengsanPlayer_shuying) {
|
||||
@@ -647,6 +727,9 @@ const createMode = identityMode => {
|
||||
return PLAYER_HAND_LIMIT + bonus;
|
||||
}
|
||||
if (this.storage?.mengsanMonster_shuying) return this.storage.mengsanMonster_shuying.handLimit;
|
||||
if (this.storage?.mengsanBond_shuying) {
|
||||
return this.storage.mengsanBond_shuying.handLimit;
|
||||
}
|
||||
let number = Math.max(this.hp, 0);
|
||||
number = game.checkMod(this, number, "maxHandcardBase", this);
|
||||
number = game.checkMod(this, number, "maxHandcard", this);
|
||||
@@ -755,6 +838,11 @@ const createMode = identityMode => {
|
||||
},
|
||||
skill: {
|
||||
...(identityMode.skill || {}),
|
||||
...createRelicSkills(() => activeBattle, () => game.me),
|
||||
mengsan_taoyuan_bond_shuying: {
|
||||
locked: true, mark: true, marktext: "义",
|
||||
intro: { content: "先天结识关羽、张飞,初始羁绊均为8级。助战角色在行军菜单中选择。" },
|
||||
},
|
||||
mengsan_card_payment_shuying: {
|
||||
forced: true, silent: true, popup: false,
|
||||
firstDo: true, priority: 10000,
|
||||
@@ -775,10 +863,14 @@ const createMode = identityMode => {
|
||||
mengsan_monster_draw_shuying: {
|
||||
trigger: { player: "phaseDrawBegin2" },
|
||||
forced: true, silent: true, popup: false,
|
||||
filter(event, player) { return Boolean(player.storage?.mengsanMonster_shuying); },
|
||||
filter(event, player) {
|
||||
return Boolean(player.storage?.mengsanMonster_shuying ||
|
||||
player.storage?.mengsanBond_shuying);
|
||||
},
|
||||
async content(event, trigger, player) {
|
||||
trigger.num = isStunned(player) ? 0 :
|
||||
player.storage.mengsanMonster_shuying.draw;
|
||||
(player.storage.mengsanMonster_shuying ||
|
||||
player.storage.mengsanBond_shuying).draw;
|
||||
},
|
||||
},
|
||||
mengsan_flyconid_action_shuying: {
|
||||
@@ -797,11 +889,24 @@ const createMode = identityMode => {
|
||||
event.player.storage.mengsanRaiderIntent_shuying); },
|
||||
async content(event, trigger) { await executeRaiderIntent(trigger.player); },
|
||||
},
|
||||
mengsan_bond_action_shuying: {
|
||||
trigger: { global: "phaseUseBefore" },
|
||||
forced: true, silent: true, popup: false, priority: 100,
|
||||
filter(event) {
|
||||
return Boolean(activeBattle?.session.active &&
|
||||
!isStunned(event.player) &&
|
||||
event.player.storage?.mengsanBond_shuying?.intent);
|
||||
},
|
||||
async content(event, trigger) {
|
||||
await executeBondIntent(trigger.player);
|
||||
},
|
||||
},
|
||||
mengsan_raider_card_strength_shuying: {
|
||||
trigger: { source: "damageBegin1" },
|
||||
forced: true, silent: true, popup: false, priority: 90,
|
||||
filter(event, player) { return Boolean(activeBattle?.session.active &&
|
||||
isRaider(player) && player.storage.mengsanStrength_shuying > 0 &&
|
||||
(isRaider(player) || player.storage.mengsanBond_shuying) &&
|
||||
player.storage.mengsanStrength_shuying > 0 &&
|
||||
event.card && !event.mengsanScriptedSkill_shuying); },
|
||||
async content(event, trigger, player) {
|
||||
trigger.num += player.storage.mengsanStrength_shuying;
|
||||
@@ -993,7 +1098,7 @@ const createMode = identityMode => {
|
||||
},
|
||||
config: {},
|
||||
help: {
|
||||
梦三: "梦三:玩家和敌方怪物各有独立牌堆与弃牌堆,友方支援仍使用公共牌堆。主动出牌消耗费用,转化牌按转化后牌名计费;响应不耗费。DIY须在技能所属角色事件内访问牌堆,不可缓存公共牌堆节点。",
|
||||
梦三: "梦三:玩家、敌方怪物和羁绊助战各有独立牌堆与弃牌堆,其他友方支援使用公共牌堆。主动出牌消耗费用,转化牌按转化后牌名计费;响应不耗费。DIY须在技能所属角色事件内访问牌堆,不可缓存公共牌堆节点。",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import {
|
||||
attackHitCount,
|
||||
outgoingAttackDamage,
|
||||
} from "../battle/intent-damage.js";
|
||||
import { beginDeathBlow, isDeathBlowIntent } from "../battle/death-blow.js";
|
||||
import { createIntentExecutor } from "../battle/intent-effects.js";
|
||||
export { getIntentHostiles } from "../battle/intent-effects.js";
|
||||
import { isStunned } from "../battle/stun-intent.js";
|
||||
import { nextRandom } from "../progression/state.js";
|
||||
import { selectFlyconidMove, recordFlyconidAction } from "./flyconid-intent.js";
|
||||
@@ -21,6 +18,7 @@ export const isRaider = player =>
|
||||
isRaiderCharacter(player.name);
|
||||
|
||||
export function createMonsterIntentActions(game, getActiveBattle) {
|
||||
const executeEffects = createIntentExecutor(game, getActiveBattle);
|
||||
const planEnemyIntent = (player, run) => {
|
||||
if (!player?.isAlive() || isStunned(player)) return;
|
||||
if (isFlyconid(player)) {
|
||||
@@ -39,30 +37,6 @@ export function createMonsterIntentActions(game, getActiveBattle) {
|
||||
}
|
||||
};
|
||||
|
||||
const applyIntentDebuff = (target, move) => {
|
||||
if (!move.debuff) return;
|
||||
const frail = move.debuff === "脆弱";
|
||||
const key = frail ? "mengsanFrail_shuying" :
|
||||
"mengsanVulnerable_shuying";
|
||||
target.storage[key] = (target.storage[key] || 0) + move.stacks;
|
||||
target.addSkill(frail ? "mengsan_frail_shuying" :
|
||||
"mengsan_vulnerable_shuying");
|
||||
target.markSkill(frail ? "mengsan_frail_shuying" :
|
||||
"mengsan_vulnerable_shuying");
|
||||
};
|
||||
|
||||
const dealIntentDamage = async (source, target, move) => {
|
||||
if (move.damage == null) return;
|
||||
for (let index = 0; index < attackHitCount(move); index++) {
|
||||
if (!target.isAlive() || !source.isAlive()) break;
|
||||
const hit = target.damage(
|
||||
outgoingAttackDamage(move, source), source);
|
||||
hit.mengsanAttack_shuying = true;
|
||||
hit.mengsanScriptedSkill_shuying = true;
|
||||
await hit;
|
||||
}
|
||||
};
|
||||
|
||||
const executeFlyconidIntent = async player => {
|
||||
const move = player.storage.mengsanFlyconidIntent_shuying;
|
||||
if (!move) return;
|
||||
@@ -80,16 +54,7 @@ export function createMonsterIntentActions(game, getActiveBattle) {
|
||||
player.storage.mengsanEnergy_shuying--;
|
||||
getActiveBattle()?.energyUI.get(player)?.();
|
||||
game.log(player, "消耗1费用发动", move.name);
|
||||
const target = game.me;
|
||||
if (!target?.isAlive() || !player.isAlive()) return;
|
||||
if (isDeathBlowIntent(move)) {
|
||||
await beginDeathBlow(player, target, move);
|
||||
return;
|
||||
}
|
||||
await dealIntentDamage(player, target, move);
|
||||
if (target.isAlive() && player.isAlive()) {
|
||||
applyIntentDebuff(target, move);
|
||||
}
|
||||
await executeEffects(player, move);
|
||||
};
|
||||
|
||||
const executeRaiderIntent = async player => {
|
||||
@@ -106,21 +71,7 @@ export function createMonsterIntentActions(game, getActiveBattle) {
|
||||
player.storage.mengsanEnergy_shuying--;
|
||||
getActiveBattle()?.energyUI.get(player)?.();
|
||||
game.log(player, "消耗1费用发动", move.name);
|
||||
const target = game.me;
|
||||
if (!target?.isAlive() || !player.isAlive()) return;
|
||||
await dealIntentDamage(player, target, move);
|
||||
if (target.isAlive() && player.isAlive()) {
|
||||
applyIntentDebuff(target, move);
|
||||
}
|
||||
if (!player.isAlive() || !getActiveBattle()?.session.active) return;
|
||||
if (move.block) await player.changeHujia(move.block);
|
||||
if (move.strength) {
|
||||
player.storage.mengsanStrength_shuying =
|
||||
(player.storage.mengsanStrength_shuying || 0) +
|
||||
move.strength;
|
||||
player.addSkill("mengsan_raider_strength_shuying");
|
||||
player.markSkill("mengsan_raider_strength_shuying");
|
||||
}
|
||||
await executeEffects(player, move);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -8,9 +8,9 @@ const TIER_RULES = Object.freeze({
|
||||
});
|
||||
|
||||
const BASE_DECKS = Object.freeze({
|
||||
normal: Object.freeze(["sha", "sha", "sha", "sha", "shan", "shan", "tao", "jiu", "guohe", "sha"]),
|
||||
normal: Object.freeze(["sha", "sha", "sha", "sha", "shan", "shan", "tao", "jiu", "sha"]),
|
||||
elite: Object.freeze(["sha", "sha", "sha", "sha", "shan", "shan", "shan", "tao", "jiu", "juedou", "wuzhong", "guohe"]),
|
||||
boss: Object.freeze(["sha", "sha", "sha", "sha", "sha", "shan", "shan", "shan", "tao", "tao", "jiu", "jiu", "juedou", "wuzhong", "guohe"]),
|
||||
boss: Object.freeze(["sha", "sha", "sha", "sha", "sha", "shan", "shan", "shan", "tao", "jiu", "juedou", "wuzhong", "guohe"]),
|
||||
});
|
||||
|
||||
const HEALTH = Object.freeze({
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import config from "../config.js";
|
||||
import { nextRandom } from "./state.js";
|
||||
import { canAcquireCard } from "../cards/card-definitions.js";
|
||||
import { hasUpgradeableCard } from "../cards/upgrades.js";
|
||||
import { canAcquireRelic } from "../relics/definitions.js";
|
||||
|
||||
export const rewardCardName = reward => reward?.card?.name || ({ card_sha: "sha", card_tao: "tao" })[reward?.effectId] || null;
|
||||
export const canAcquireReward = (run, reward) => {
|
||||
if (reward?.relic) return canAcquireRelic(run, reward.relic);
|
||||
if (reward?.effectId === "item_hand") {
|
||||
return canAcquireRelic(run, "mengsan_hand_charm_shuying");
|
||||
}
|
||||
if (reward?.effectId === "upgrade") {
|
||||
return hasUpgradeableCard(run?.player?.deck);
|
||||
}
|
||||
const name = rewardCardName(reward);
|
||||
return !name || canAcquireCard(run?.player?.character, name);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createStartingDeck } from "../cards/starting-deck.js";
|
||||
import config from "../config.js";
|
||||
import { initializeInnateBonds } from "../bonds/state.js";
|
||||
import { RAIDER_TRIO_ENCOUNTER, createRaiderTrioBattlePlan } from "../content/acts/act1/raider-trio.js";
|
||||
|
||||
const hashText = text => {
|
||||
@@ -177,6 +178,7 @@ export const createRun = character => {
|
||||
},
|
||||
storyFlags: {},
|
||||
};
|
||||
initializeInnateBonds(run);
|
||||
run.map = generateActMap(run, 0);
|
||||
return run;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { meetBond } from "../bonds/state.js";
|
||||
|
||||
export const getAvailableStoryChoices = (run, content) => {
|
||||
const flags = run.storyFlags || {};
|
||||
return (content?.choices || []).filter(choice => {
|
||||
@@ -9,5 +11,9 @@ export const getAvailableStoryChoices = (run, content) => {
|
||||
export const applyStoryOutcome = (run, outcome = {}) => {
|
||||
if (!run.storyFlags) run.storyFlags = {};
|
||||
Object.assign(run.storyFlags, outcome.flags || {});
|
||||
for (const bond of outcome.bonds || []) {
|
||||
meetBond(run, typeof bond === "string" ? bond : bond.id,
|
||||
typeof bond === "string" ? 1 : bond.level ?? 1);
|
||||
}
|
||||
return outcome;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# 遗物资源待补清单
|
||||
|
||||
当前所有遗物的 `image` 均为 `null`,使用文字名称和效果说明,
|
||||
不会加载不存在的图片。后续图片统一放入 `mengsan/assets/relics/`,
|
||||
补齐时更新 `definitions.js` 并接入展示;本次不生成占位图片。
|
||||
|
||||
| 遗物 | 稳定 ID | 建议图片文件名 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 燃烧之血 | `mengsan_burning_blood_shuying` | `burning_blood.png` | 待提供 |
|
||||
| 蛇之戒指 | `mengsan_snake_ring_shuying` | `snake_ring.png` | 待提供 |
|
||||
| 长蛇戒指 | `mengsan_long_snake_ring_shuying` | `long_snake_ring.png` | 待提供 |
|
||||
| 千年积木 | `mengsan_millennium_puzzle_shuying` | `millennium_puzzle.png` | 待提供 |
|
||||
| 摆动球 | `mengsan_pendulum_shuying` | `pendulum.png` | 待提供 |
|
||||
| 束带(既有道具) | `mengsan_hand_charm_shuying` | `hand_charm.png` | 待提供 |
|
||||
|
||||
建议使用统一尺寸的透明背景 PNG,实际尺寸和视觉风格在资源制作时确定。
|
||||
图片仅负责外观,不承载名称、效果或触发计数。
|
||||
@@ -0,0 +1,89 @@
|
||||
# 梦三遗物(道具)
|
||||
|
||||
本目录负责遗物定义、持有规则与战斗效果管理。
|
||||
已实现五件新遗物,并接入既有“束带”。图片暂缺,清单见 [ASSETS.md](ASSETS.md)。
|
||||
|
||||
## 已实现的遗物
|
||||
|
||||
| 遗物 | 效果 |
|
||||
| --- | --- |
|
||||
| 燃烧之血 | 胜利结算后回复 6 点生命,不超过上限;失败不回血 |
|
||||
| 蛇之戒指 | 每场战斗开始额外摸 2 张,开场手牌整理不会移除这两张 |
|
||||
| 长蛇戒指 | 每场战斗第 1~3 个自身回合开始,各额外摸 2 张 |
|
||||
| 千年积木 | 每场战斗首次实际生命减少且仍存活时摸 3 张;纯护甲损失不触发 |
|
||||
| 摆动球 | 每场战斗第 3、6、9……个自身回合开始额外摸 1 张 |
|
||||
| 束带 | 原有手牌上限 +1,重复获得不叠加 |
|
||||
|
||||
遗物互相独立:同时持有两种戒指时,开场和前三回合效果分别触发。
|
||||
所有回合计数按玩家自身实际开始的回合计算,不计敌方和支援回合,
|
||||
不使用界面显示的全局轮数。每场战斗重置计数及“首次损失生命”标记。
|
||||
额外摸牌沿用个人牌堆规则;牌堆与弃牌堆不足时仍按原规则挑战失败。
|
||||
|
||||
### 获得、保存和查看
|
||||
|
||||
- 五件新遗物加入共享 Boss 奖励池及第一章宝箱奖励池;
|
||||
后续章节使用共享 Boss 奖励池的宝箱也可出现它们。
|
||||
- 角色默认不携带新遗物;未新增商店价格或精英掉落规则。
|
||||
- 持有记录沿用 `run.player.items`,已持有遗物会从随机奖励中排除。
|
||||
- `definitions.js` 提供注册、资格检查和去重获得;旧“束带”ID 与效果保留。
|
||||
- `battle.js` 管理战斗计数、摸牌效果及战后回血;战斗运行状态不写入征程。
|
||||
- 战后回血写入结算候选快照,之后才领取奖励,重试、重复提交和读档
|
||||
复用快照,不再次回血;刚从本场奖励获得的燃烧之血从下一场生效。
|
||||
- 战斗中的“遗物”按钮显示名称、效果与触发状态;地图行囊显示持有详情。
|
||||
- 获得和效果触发使用游戏日志反馈;没有图片时只显示文字。
|
||||
|
||||
## 参考来源
|
||||
|
||||
- [杀戮尖塔 2 中文百科:遗物](https://sts2.huijiwiki.com/wiki/%E9%81%97%E7%89%A9)
|
||||
- 查阅日期:2026-09-30。
|
||||
- 页面直连返回 403;以下概述依据搜索索引返回的该页正文整理。
|
||||
本文概述机制,不复制完整遗物列表、具体效果或图片。
|
||||
|
||||
## 网页中的遗物概念
|
||||
|
||||
遗物是单局游戏中获得并持续持有的物品,会影响牌组构筑、
|
||||
手牌管理和其他玩法。这里的“永久”指当前这一局,
|
||||
不代表跨新征程永久保留。
|
||||
|
||||
- 每个角色拥有自己的初始遗物,此外也有通用和角色专属遗物。
|
||||
- 获得途径包括击败精英或 Boss、宝箱、商店、部分事件和先古之民。
|
||||
- 已经持有的遗物不会再次出现。
|
||||
- 通常不能主动移除;部分事件可以交换或移除遗物。
|
||||
- 部分特殊遗物可以失效,但失效后仍保留在持有列表中。
|
||||
- 页面按稀有度或获取类别区分初始、普通、罕见、稀有、
|
||||
商店、先古之民、事件及其他遗物。
|
||||
|
||||
## 接入前的项目背景
|
||||
|
||||
接入前,项目已有以下零散道具逻辑:
|
||||
|
||||
| 位置 | 现有职责 |
|
||||
| --- | --- |
|
||||
| `../progression/state.js` | 新征程创建 `player.items` 和手牌上限加成数据 |
|
||||
| `../mode.js` | 获得“束带”时记录道具 ID,手牌上限加成增加 1,重复获得不叠加 |
|
||||
| `../content/shared/rewards.js` | 配置“束带”和“斥候援令”等奖励 |
|
||||
| `../battle/battle-director.js` | 管理援令带来的支援单位与重复来源处理 |
|
||||
| `../ui/map/renderer.js` | 行囊展示永久道具,数量读取 `player.items` |
|
||||
|
||||
“斥候援令”走支援管理流程,不能仅凭奖励名中的“道具”
|
||||
认定它已经进入 `player.items` 或统一遗物系统。
|
||||
|
||||
## 设计约定与后续扩展
|
||||
|
||||
1. 将“遗物”作为本征程持续持有道具的统一概念,
|
||||
与手牌、装备牌、消耗品及临时战斗状态分别管理。
|
||||
2. 每个遗物定义稳定 ID、名称、说明、图标、角色限制、
|
||||
获取类别及效果规则;具体三国题材名称与数值另行配置。
|
||||
3. 根据效果需要提供获得时、战斗开始、回合开始、出牌后、
|
||||
战斗结束等触发时机;不能假定所有遗物只提供固定属性加成。
|
||||
4. 获得前统一检查角色资格和是否已持有,
|
||||
默认不重复获取;失效、交换和移除规则由具体遗物明确声明。
|
||||
5. 将持有记录和需要跨战斗保存的计数写入征程存档,
|
||||
战斗内计数由战斗会话管理,读档不重复执行“获得时”效果。
|
||||
6. 先以“束带”作为接入现有机制的候选,保留它当前的效果与去重行为;
|
||||
“斥候援令”需要明确与支援系统的关系,再决定如何接入。
|
||||
7. 获得提示、持有列表、说明与触发反馈放在 `../ui/`,
|
||||
图片建议放在 `../assets/relics/`;本目录负责遗物自身规则。
|
||||
|
||||
资源、稀有度、精英掉落、商店价格、角色初始遗物以及特殊交换或移除
|
||||
规则仍待后续配置;本次保留原道具与存档字段,不引入独立遗物存档副本。
|
||||
@@ -0,0 +1,105 @@
|
||||
import { heldRelics } from "./definitions.js";
|
||||
|
||||
export function createRelicBattle(run, { active, draw, log }) {
|
||||
const relics = heldRelics(run);
|
||||
let started = false, turns = 0, firstLoss = false;
|
||||
let openingHandBonus = 0, pendingHpDraw = null;
|
||||
const seenTurns = new WeakSet();
|
||||
const usable = () => started && active();
|
||||
const drawEffect = async relic => {
|
||||
if (!usable()) return;
|
||||
if (turns === 0) openingHandBonus += relic.amount;
|
||||
log(relic, `额外摸${relic.amount}张牌`);
|
||||
await draw(relic.amount);
|
||||
};
|
||||
return {
|
||||
get openingHandBonus() { return openingHandBonus; },
|
||||
get openingDraw() {
|
||||
return relics.filter(relic => relic.effect === "openingDraw")
|
||||
.reduce((sum, relic) => sum + relic.amount, 0);
|
||||
},
|
||||
async start() {
|
||||
if (started || !active()) return;
|
||||
started = true;
|
||||
for (const relic of relics) {
|
||||
if (relic.effect === "openingDraw") await drawEffect(relic);
|
||||
}
|
||||
if (pendingHpDraw) {
|
||||
const relic = pendingHpDraw;
|
||||
pendingHpDraw = null;
|
||||
await drawEffect(relic);
|
||||
}
|
||||
},
|
||||
async turnStart(event) {
|
||||
if (!usable() || seenTurns.has(event)) return;
|
||||
seenTurns.add(event);
|
||||
turns++;
|
||||
for (const relic of relics) {
|
||||
if ((relic.effect === "earlyTurnDraw" &&
|
||||
turns <= relic.turns) ||
|
||||
(relic.effect === "periodicTurnDraw" &&
|
||||
turns % relic.turns === 0)) await drawEffect(relic);
|
||||
}
|
||||
},
|
||||
async loseHp(event) {
|
||||
if (!active() || firstLoss || !(event.num < 0)) return;
|
||||
const relic = relics.find(item =>
|
||||
item.effect === "firstHpLossDraw");
|
||||
if (!relic) return;
|
||||
firstLoss = true;
|
||||
if (!started) {
|
||||
pendingHpDraw = relic;
|
||||
return;
|
||||
}
|
||||
await drawEffect(relic);
|
||||
},
|
||||
status(relic) {
|
||||
if (relic.effect === "firstHpLossDraw") {
|
||||
return firstLoss ? "本场已触发" : "本场尚未触发";
|
||||
}
|
||||
if (relic.effect === "earlyTurnDraw") {
|
||||
return `本场已开始${turns}个自身回合`;
|
||||
}
|
||||
if (relic.effect === "periodicTurnDraw") {
|
||||
return `回合计数:${turns % relic.turns}/${relic.turns}`;
|
||||
}
|
||||
if (relic.effect === "openingDraw") {
|
||||
return started ? "本场已触发" : "等待战斗开始";
|
||||
}
|
||||
return "持续持有";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyBattleEndRelics(run, outcome) {
|
||||
if (outcome !== "victory" || !(run.player.hp > 0)) return 0;
|
||||
const amount = heldRelics(run).filter(relic =>
|
||||
relic.effect === "battleHeal").reduce((sum, relic) =>
|
||||
sum + relic.amount, 0);
|
||||
const before = run.player.hp;
|
||||
run.player.hp = Math.min(run.player.maxHp ?? before,
|
||||
before + amount);
|
||||
return run.player.hp - before;
|
||||
}
|
||||
|
||||
export function createRelicSkills(getBattle, getOwner) {
|
||||
return {
|
||||
mengsan_relics_shuying: {
|
||||
trigger: { global: ["phaseBegin", "changeHp"] },
|
||||
forced: true, silent: true, popup: false, priority: 100,
|
||||
filter(event) {
|
||||
const battle = getBattle(), owner = getOwner();
|
||||
return Boolean(battle?.session.active && battle.relics &&
|
||||
owner?.hp > 0 && event.player === owner &&
|
||||
(event.name === "phase" || event.num < 0));
|
||||
},
|
||||
async content(event, trigger) {
|
||||
const battle = getBattle();
|
||||
if (!battle?.session.active) return;
|
||||
if (event.triggername === "phaseBegin") {
|
||||
await battle.relics.turnStart(trigger);
|
||||
} else await battle.relics.loseHp(trigger);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
const entries = [
|
||||
{ id: "mengsan_burning_blood_shuying", name: "燃烧之血",
|
||||
description: "战斗结束后回复6点生命,不能超过生命上限。",
|
||||
effect: "battleHeal", amount: 6 },
|
||||
{ id: "mengsan_snake_ring_shuying", name: "蛇之戒指",
|
||||
description: "每场战斗开始时,额外摸2张牌。",
|
||||
effect: "openingDraw", amount: 2 },
|
||||
{ id: "mengsan_long_snake_ring_shuying", name: "长蛇戒指",
|
||||
description: "每场战斗的前3个自身回合开始时,额外摸2张牌。",
|
||||
effect: "earlyTurnDraw", amount: 2, turns: 3 },
|
||||
{ id: "mengsan_millennium_puzzle_shuying", name: "千年积木",
|
||||
description: "每场战斗首次实际损失生命且仍存活时,摸3张牌。",
|
||||
effect: "firstHpLossDraw", amount: 3 },
|
||||
{ id: "mengsan_pendulum_shuying", name: "摆动球",
|
||||
description: "每场战斗每到第3个自身回合开始时,额外摸1张牌。",
|
||||
effect: "periodicTurnDraw", amount: 1, turns: 3 },
|
||||
{ id: "mengsan_hand_charm_shuying", name: "束带",
|
||||
description: "基础手牌上限额外+1;重复获得不叠加。",
|
||||
effect: "handLimit", amount: 1 },
|
||||
];
|
||||
|
||||
export const relicDefinitions = Object.freeze(Object.fromEntries(
|
||||
entries.map(entry => [entry.id, Object.freeze({ ...entry,
|
||||
owner: null, image: null })])));
|
||||
|
||||
export const getRelic = id => Object.hasOwn(relicDefinitions, id)
|
||||
? relicDefinitions[id] : null;
|
||||
|
||||
export const heldRelics = run => [...new Set(run?.player?.items || [])]
|
||||
.map(getRelic).filter(Boolean);
|
||||
|
||||
export function canAcquireRelic(run, id) {
|
||||
const relic = getRelic(id);
|
||||
return Boolean(run?.player && relic &&
|
||||
(relic.owner === null || relic.owner === run.player.character) &&
|
||||
!run.player.items?.includes(id));
|
||||
}
|
||||
|
||||
export function grantRelic(run, id) {
|
||||
if (!getRelic(id)) throw new Error(`梦三遗物未注册:${id}`);
|
||||
if (!canAcquireRelic(run, id)) return false;
|
||||
run.player.items ||= [];
|
||||
run.player.items.push(id);
|
||||
const relic = getRelic(id);
|
||||
if (relic.effect === "handLimit") {
|
||||
run.player.handLimitBonus = (run.player.handLimitBonus || 0) +
|
||||
relic.amount;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const relicRewardIds = entries.filter(entry =>
|
||||
entry.effect !== "handLimit").map(entry => `shared.reward.relic.${entry.id}`);
|
||||
|
||||
export const relicRewards = Object.fromEntries(relicRewardIds.map(id => {
|
||||
const relicId = id.slice("shared.reward.relic.".length);
|
||||
const relic = getRelic(relicId);
|
||||
return [id, { relic: relicId, name: `遗物·${relic.name}`,
|
||||
description: relic.description }];
|
||||
}));
|
||||
@@ -0,0 +1,91 @@
|
||||
import { bondDefinitions, getBondCombat, getBondDeck }
|
||||
from "../bonds/definitions.js";
|
||||
import { getBondIntents } from "../bonds/intents.js";
|
||||
import { bondProbability, ensureBonds, selectBond } from "../bonds/state.js";
|
||||
|
||||
export function renderBonds(parent, run, saveRun, onBusy = () => {}) {
|
||||
const bonds = ensureBonds(run);
|
||||
const root = document.createElement("div");
|
||||
root.className = "mengsan-bonds-shuying";
|
||||
parent.appendChild(root);
|
||||
const text = (tag, content, target = root) => {
|
||||
const node = document.createElement(tag);
|
||||
node.textContent = content;
|
||||
target.appendChild(node);
|
||||
return node;
|
||||
};
|
||||
const status = text("p", "");
|
||||
status.setAttribute("role", "status");
|
||||
const buttons = [];
|
||||
let saving = false;
|
||||
const choose = async id => {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
onBusy(true);
|
||||
const previous = bonds.selected;
|
||||
for (const button of buttons) button.disabled = true;
|
||||
status.textContent = "正在保存助战选择…";
|
||||
try {
|
||||
selectBond(run, id);
|
||||
await saveRun(run);
|
||||
const updated = renderBonds(parent, run, saveRun, onBusy);
|
||||
root.replaceWith(updated);
|
||||
updated.querySelector('button[aria-pressed="true"]')
|
||||
?.focus({ preventScroll: true });
|
||||
} catch (error) {
|
||||
selectBond(run, previous);
|
||||
status.textContent = "保存失败,请重试。";
|
||||
console.error("梦三助战选择保存失败", error);
|
||||
for (const button of buttons) button.disabled = false;
|
||||
} finally {
|
||||
saving = false;
|
||||
onBusy(false);
|
||||
}
|
||||
};
|
||||
const clear = text("button", "不指定助战");
|
||||
clear.type = "button";
|
||||
clear.setAttribute("aria-pressed", String(bonds.selected === null));
|
||||
clear.addEventListener("click", () => choose(null));
|
||||
buttons.push(clear);
|
||||
if (!Object.keys(bonds.npcs).length) text("p", "尚未结识羁绊 NPC。");
|
||||
for (const [id, npc] of Object.entries(bonds.npcs)) {
|
||||
const definition = bondDefinitions[id];
|
||||
if (!definition) continue;
|
||||
const card = document.createElement("section");
|
||||
card.className = "mengsan-bond-card-shuying";
|
||||
root.appendChild(card);
|
||||
text("h3", definition.name, card);
|
||||
const ready = Boolean(getBondCombat(id, npc.level));
|
||||
const state = npc.down ? "濒死 · 等待救助" :
|
||||
ready ? "可助战" : "战斗配置待补";
|
||||
text("p", `${state}${npc.down && !ready ?
|
||||
" · 战斗配置待补" : ""}`, card);
|
||||
text("p", `羁绊 ${npc.level} 级 · 助战概率 ${
|
||||
(bondProbability(npc.level) * 100).toFixed(2)}%`, card);
|
||||
const progress = text("progress", "", card);
|
||||
progress.max = 100;
|
||||
progress.value = npc.level === 10 ? 100 : npc.progress;
|
||||
progress.setAttribute("aria-label", `${definition.name}升级进度`);
|
||||
text("p", npc.level === 10 ? "羁绊已满级" :
|
||||
`升级进度 ${npc.progress}% / 100%`, card);
|
||||
const button = text("button", bonds.selected === id ?
|
||||
"已指定助战" : "指定助战", card);
|
||||
button.type = "button";
|
||||
button.setAttribute("aria-pressed", String(bonds.selected === id));
|
||||
button.addEventListener("click", () => choose(id));
|
||||
buttons.push(button);
|
||||
const combat = getBondCombat(id, npc.level);
|
||||
text("p", combat ? `生命 ${combat.maxHp} · 初始手牌 ${combat.hand}` :
|
||||
"生命、初始手牌及技能等待配置。", card);
|
||||
text("p", `独立牌库 ${getBondDeck(definition).length} 张 · 意图 ${
|
||||
getBondIntents(definition).map(move => move.name).join(" → ")}`,
|
||||
card);
|
||||
if (npc.down) {
|
||||
const rescue = text("button", "救助道具待补充", card);
|
||||
rescue.type = "button";
|
||||
rescue.disabled = true;
|
||||
}
|
||||
}
|
||||
text("p", "指定 NPC 后,每场战斗结束:到场 +50%,未到场 +25%。");
|
||||
return root;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lib, get, ui } from "../../../../noname.js";
|
||||
import { AFFIX_INFO } from "../cards/affixes.js";
|
||||
import { cardUpgradeLevel, cardUpgradeRule } from "../cards/upgrades.js";
|
||||
|
||||
const suits = { spade: "♠ 黑桃", heart: "♥ 红桃", club: "♣ 梅花", diamond: "♦ 方块" };
|
||||
const types = { basic: "基本牌", trick: "锦囊牌", delay: "延时锦囊", equip: "装备牌" };
|
||||
@@ -20,9 +21,12 @@ export function describeLibraryCard(card) {
|
||||
number: ({ 1: "A", 11: "J", 12: "Q", 13: "K" })[card.number] || String(card.number ?? "—"),
|
||||
nature: nature || "无属性", type: types[info?.type] || "未知类别",
|
||||
red: card.suit === "heart" || card.suit === "diamond",
|
||||
description: text(lib.translate[`${card.name}_info`] || "暂无卡牌介绍。"),
|
||||
description: text(cardUpgradeRule(card.name) && info?.cardPrompt
|
||||
? info.cardPrompt(card)
|
||||
: lib.translate[`${card.name}_info`] || "暂无卡牌介绍。"),
|
||||
affixes: (Array.isArray(card.affixes) ? card.affixes : []).filter(key => key !== "annihilate").map(key => affixes[key] || `词缀:${translate(key)}`),
|
||||
upgrade: Number.isFinite(card.upgrade) ? card.upgrade : 0,
|
||||
upgrade: cardUpgradeLevel(card),
|
||||
upgradeLimit: cardUpgradeRule(card.name)?.maxLevel || 0,
|
||||
available: Boolean(info),
|
||||
};
|
||||
}
|
||||
@@ -49,13 +53,17 @@ function preview(card, details, parent) {
|
||||
const art = element("div", "ms-deck-art", null, face);
|
||||
element("span", "ms-deck-glyph", details.name, art);
|
||||
const imageName = lib.card[card.name]?.cardimage || card.name;
|
||||
const modeImage = lib.card[card.name]?.image;
|
||||
const localArt = typeof modeImage === "string" &&
|
||||
/^ext:术樱包\/mengsan\/assets\/cards\/[a-z0-9_]+\.png$/.test(modeImage)
|
||||
? modeImage.replace(/^ext:/, "extension/") : null;
|
||||
// Only mode-standard local art paths; unknown or missing art keeps the text fallback.
|
||||
if (/^[a-zA-Z0-9_]+$/.test(imageName)) {
|
||||
const image = element("img", "ms-deck-image", null, art);
|
||||
image.alt = "";
|
||||
image.loading = "lazy";
|
||||
image.addEventListener("error", () => image.remove(), { once: true });
|
||||
image.src = `${lib.assetURL || ""}image/card/${imageName}.png`;
|
||||
image.src = `${lib.assetURL || ""}${localArt || `image/card/${imageName}.png`}`;
|
||||
}
|
||||
element("strong", "ms-deck-name", details.name, face);
|
||||
element("span", "ms-deck-meta", `${details.type} · ${details.nature}`, face);
|
||||
@@ -110,7 +118,9 @@ export function openCardLibrary(run, options = {}) {
|
||||
element("h4", "", "卡牌介绍", copy);
|
||||
element("p", "ms-deck-rules", details.description, copy);
|
||||
if (!details.available) element("p", "ms-deck-warning", "该卡牌定义未加载;保留存档记录,不删除此牌。", copy);
|
||||
if (details.upgrade) element("p", "", `强化等级:${details.upgrade}(实际效果以玩法规则为准)`, copy);
|
||||
element("p", "", details.upgradeLimit
|
||||
? `强化:${details.upgrade}/${details.upgradeLimit}`
|
||||
: "此牌不可强化", copy);
|
||||
for (const affix of details.affixes) element("p", "ms-deck-rules", affix, copy);
|
||||
body.scrollTop = 0; backButton.focus();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cardUpgradeLevel } from "../cards/upgrades.js";
|
||||
|
||||
export function createHandUpgradeBadges(document) {
|
||||
const badges = new Map();
|
||||
return {
|
||||
refresh(card) {
|
||||
if (!card?.name) return;
|
||||
const level = cardUpgradeLevel(card);
|
||||
let badge = badges.get(card) || Array.from(card.children || [])
|
||||
.find(child => child.classList?.contains(
|
||||
"mengsan-hand-upgrade-shuying"));
|
||||
if (!level) {
|
||||
badge?.remove();
|
||||
badges.delete(card);
|
||||
delete card.dataset.mengsanUpgrade;
|
||||
return;
|
||||
}
|
||||
if (!badge) {
|
||||
badge = document.createElement("span");
|
||||
badge.className = "mengsan-hand-upgrade-shuying";
|
||||
badge.setAttribute("role", "img");
|
||||
}
|
||||
if (badge.parentNode !== card) card.appendChild(badge);
|
||||
badge.textContent = "";
|
||||
badge.dataset.mengsanUpgrade = String(level);
|
||||
badge.setAttribute("aria-label", `已强化${level}次`);
|
||||
card.dataset.mengsanUpgrade = String(level);
|
||||
badges.set(card, badge);
|
||||
},
|
||||
dispose() {
|
||||
for (const [card, badge] of badges) {
|
||||
badge.remove();
|
||||
delete card.dataset.mengsanUpgrade;
|
||||
}
|
||||
badges.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -114,11 +114,17 @@ export function mountEnemyIntent(player, session, assetBase, document,
|
||||
if (deathBlow && hits !== 1) {
|
||||
throw new RangeError("濒死一击只能造成一次伤害");
|
||||
}
|
||||
const damage = previewAttackDamage(intent, player, getTarget());
|
||||
const target = getTarget();
|
||||
const targets = Array.isArray(target) ? target : [target];
|
||||
const damages = (targets.length ? targets : [null]).map(
|
||||
current => previewAttackDamage(intent, player, current));
|
||||
const damage = Math.max(...damages);
|
||||
const minimum = Math.min(...damages);
|
||||
const range = minimum === damage ? String(damage) :
|
||||
`${minimum}~${damage}`;
|
||||
const tier = deathBlow ? null :
|
||||
attackIconForDamage(damage * hits);
|
||||
const damageText = hits > 1 ? `${damage}×${hits}` :
|
||||
String(damage);
|
||||
const damageText = hits > 1 ? `${range}×${hits}` : range;
|
||||
if (amount.textContent !== damageText) {
|
||||
amount.textContent = damageText;
|
||||
}
|
||||
@@ -126,7 +132,7 @@ export function mountEnemyIntent(player, session, assetBase, document,
|
||||
`${assetBase}/assets/intent/Intent_death_blow.png` :
|
||||
`${assetBase}/assets/intent/Intent_attack_${tier}.png`;
|
||||
if (icon.getAttribute("src") !== src) icon.src = src;
|
||||
effects.push(`${deathBlow ? "Death Blow" : "Attack"}: ${damage} damage${hits > 1 ? ` × ${hits} hits` : ""}`);
|
||||
effects.push(`${deathBlow ? "Death Blow" : "Attack"}: ${range} damage${hits > 1 ? ` × ${hits} hits` : ""}`);
|
||||
}
|
||||
if (hasDebuff) {
|
||||
const label = intent.debuff === "脆弱" ? "Frail" :
|
||||
@@ -143,6 +149,7 @@ export function mountEnemyIntent(player, session, assetBase, document,
|
||||
effects.push("Unknown");
|
||||
}
|
||||
const label = effects.join(", ");
|
||||
badge.title = "攻击与负面效果作用于所有存活敌对角色;伤害按各目标状态计算,护甲另行抵扣。";
|
||||
if (badge.getAttribute("aria-label") !== label) badge.setAttribute("aria-label", label);
|
||||
badge.hidden = false;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@ import { openCardLibrary } from "../card-library.js";
|
||||
import { askMenu, openModeSelection } from "../navigation.js";
|
||||
import { ui, get } from "../../../../../noname.js";
|
||||
import config from "../../config.js";
|
||||
import { getRelic } from "../../relics/definitions.js";
|
||||
import { renderBonds } from "../bonds.js";
|
||||
import { getSelectableNodes } from "../../progression/state.js";
|
||||
|
||||
// Original vector pictograms; no external image/font requests.
|
||||
@@ -88,15 +90,16 @@ const getTraversedEdgeIds = map => {
|
||||
return traversed;
|
||||
};
|
||||
|
||||
export const showMap = run => new Promise(resolve => {
|
||||
export const showMap = (run, { saveRun } = {}) => new Promise(resolve => {
|
||||
const act = config.acts[run.actIndex];
|
||||
const selectable = getSelectableNodes(run);
|
||||
const selectableIds = new Set(selectable.map(node => node.id));
|
||||
const traversedEdgeIds = getTraversedEdgeIds(run.map);
|
||||
const { overlay, panel, header } = createMapOverlay(act.name);
|
||||
let closeCardLibrary = null;
|
||||
let savingBonds = false;
|
||||
const finish = result => {
|
||||
if (overlay.dataset.resolved) return;
|
||||
if (overlay.dataset.resolved || savingBonds) return;
|
||||
overlay.dataset.resolved = "true";
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
closeCardLibrary?.();
|
||||
@@ -173,7 +176,7 @@ export const showMap = run => new Promise(resolve => {
|
||||
exitCopy.append(exitName, exitDescription);
|
||||
exitButton.appendChild(exitCopy);
|
||||
exitButton.addEventListener("click", async () => {
|
||||
if (exitButton.disabled) return;
|
||||
if (exitButton.disabled || savingBonds) return;
|
||||
exitButton.disabled = true;
|
||||
try {
|
||||
const result = await askMenu("返回模式选择?", "保留上次完成节点的存档,未完成内容不会提交。", [
|
||||
@@ -189,6 +192,7 @@ export const showMap = run => new Promise(resolve => {
|
||||
menuFooter.appendChild(exitButton);
|
||||
|
||||
const setMenuOpen = open => {
|
||||
if (savingBonds) return;
|
||||
if (open) renderMainMenu();
|
||||
menuLayer.hidden = !open;
|
||||
menuTrigger.setAttribute("aria-expanded", String(open));
|
||||
@@ -214,7 +218,7 @@ export const showMap = run => new Promise(resolve => {
|
||||
return button;
|
||||
};
|
||||
|
||||
const showMenuPage = (title, subtitle, render) => {
|
||||
const showMenuPage = (title, subtitle, render, focusSelector = null) => {
|
||||
menuTitle.textContent = title;
|
||||
menuContent.replaceChildren();
|
||||
const intro = ui.create.div(".mengsan-map-menu-page-intro-shuying", menuContent);
|
||||
@@ -229,7 +233,11 @@ export const showMap = run => new Promise(resolve => {
|
||||
back.appendChild(label);
|
||||
back.addEventListener("click", renderMainMenu);
|
||||
menuContent.appendChild(back);
|
||||
requestAnimationFrame(() => back.focus());
|
||||
requestAnimationFrame(() => {
|
||||
const target = focusSelector ?
|
||||
menuContent.querySelector(focusSelector) : back;
|
||||
target?.focus({ preventScroll: Boolean(focusSelector) });
|
||||
});
|
||||
};
|
||||
|
||||
const renderBackpack = parent => {
|
||||
@@ -260,8 +268,9 @@ export const showMap = run => new Promise(resolve => {
|
||||
}
|
||||
values.forEach(value => {
|
||||
const row = ui.create.div(".mengsan-map-inventory-row-shuying", list);
|
||||
createText("", get.translation(value) || value, row);
|
||||
createText("", "已获得", row);
|
||||
const relic = getRelic(value);
|
||||
createText("", relic?.name || get.translation(value) || value, row);
|
||||
createText("", relic?.description || "已获得", row);
|
||||
});
|
||||
};
|
||||
const supports = run.player.supports || [];
|
||||
@@ -273,7 +282,7 @@ export const showMap = run => new Promise(resolve => {
|
||||
createText("", config.rewards[support.rewardId]?.name || get.translation(support.unit.character), row);
|
||||
createText("", support.battles === -1 ? "本次征程持续生效" : `剩余 ${support.battles} 场`, row);
|
||||
}
|
||||
appendNames("永久道具", run.player.items, "尚未获得永久道具。");
|
||||
appendNames("遗物(道具)", run.player.items, "尚未获得遗物。");
|
||||
appendNames("永久技能", run.player.permanentSkills, "尚未获得永久技能。");
|
||||
};
|
||||
|
||||
@@ -289,6 +298,13 @@ export const showMap = run => new Promise(resolve => {
|
||||
menuContent.replaceChildren();
|
||||
createMenuAction({ icon: "map", name: "继续行军", description: "关闭菜单并返回当前路线", onClick: () => setMenuOpen(false) });
|
||||
createMenuAction({ icon: "bag", name: "行囊", description: `查看牌组、道具与永久技能 · ${run.player.deck.length}张牌`, onClick: () => showMenuPage("行囊", "本次征程携带的资源", renderBackpack) });
|
||||
createMenuAction({ icon: "book", name: "羁绊", description: "查看羁绊等级、成长进度与助战角色", onClick: () => showMenuPage("羁绊", "本次征程的同行之谊 · 仅可指定一名助战", parent => renderBonds(parent, run, saveRun, busy => {
|
||||
savingBonds = busy;
|
||||
closeMenu.disabled = busy;
|
||||
exitButton.disabled = busy;
|
||||
const back = menuContent.querySelector(".mengsan-map-menu-back-shuying");
|
||||
if (back) back.disabled = busy;
|
||||
}), ".mengsan-bonds-shuying button") });
|
||||
createMenuAction({ icon: "book", name: "图鉴", description: "查看已发现的敌人、事件与奖励 · 开发中", onClick: () => showMenuPage("图鉴", "征程见闻与收集记录", parent => renderPlaceholder(parent, "后续将收录已发现的节点、敌人和奖励。")) });
|
||||
createMenuAction({ icon: "record", name: "征程记录", description: `已通过 ${run.statistics?.completedNodes || 0} 个节点`, onClick: () => showMenuPage("征程记录", "本次征程的阶段统计", parent => {
|
||||
const list = ui.create.div(".mengsan-map-record-list-shuying", parent);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ui } from "../../../../noname.js";
|
||||
import { heldRelics } from "../relics/definitions.js";
|
||||
|
||||
export function mountRelics(session, run, battleRelics) {
|
||||
let dialog = null, list = null, previousFocus = null, disposed = false;
|
||||
const relics = heldRelics(run);
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "mengsan-battle-piles-button-shuying " +
|
||||
"mengsan-battle-relics-button-shuying";
|
||||
button.textContent = `遗物 ${relics.length}`;
|
||||
button.setAttribute("aria-haspopup", "dialog");
|
||||
ui.window.appendChild(button);
|
||||
const close = () => {
|
||||
if (!dialog) return;
|
||||
const current = dialog;
|
||||
dialog = null; list = null;
|
||||
if (current.open) current.close();
|
||||
current.remove();
|
||||
if (previousFocus?.isConnected) previousFocus.focus();
|
||||
};
|
||||
const refresh = () => {
|
||||
if (!list) return;
|
||||
list.replaceChildren();
|
||||
if (!relics.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.textContent = "本次征程尚未获得遗物。";
|
||||
list.appendChild(empty);
|
||||
}
|
||||
for (const relic of relics) {
|
||||
const row = document.createElement("section");
|
||||
const name = document.createElement("h3");
|
||||
name.textContent = relic.name;
|
||||
const description = document.createElement("p");
|
||||
description.textContent = relic.description;
|
||||
const status = document.createElement("small");
|
||||
status.textContent = battleRelics.status(relic);
|
||||
row.append(name, description, status);
|
||||
list.appendChild(row);
|
||||
}
|
||||
};
|
||||
button.addEventListener("click", () => {
|
||||
if (disposed || !session.active || dialog) return;
|
||||
previousFocus = document.activeElement;
|
||||
dialog = document.createElement("dialog");
|
||||
const currentDialog = dialog;
|
||||
dialog.className = "mengsan-deck-dialog-shuying " +
|
||||
"mengsan-relic-dialog-shuying";
|
||||
dialog.setAttribute("aria-label", "遗物(道具)");
|
||||
const header = document.createElement("header");
|
||||
header.className = "ms-deck-header";
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = "遗物(道具)";
|
||||
const dismiss = document.createElement("button");
|
||||
dismiss.type = "button";
|
||||
dismiss.className = "ms-deck-button";
|
||||
dismiss.textContent = "关闭";
|
||||
dismiss.addEventListener("click", close);
|
||||
header.append(title, dismiss);
|
||||
list = document.createElement("div");
|
||||
list.className = "mengsan-relic-list-shuying";
|
||||
dialog.append(header, list);
|
||||
dialog.addEventListener("cancel", event => {
|
||||
event.preventDefault(); close();
|
||||
});
|
||||
dialog.addEventListener("close", () => {
|
||||
if (dialog === currentDialog) close();
|
||||
});
|
||||
dialog.addEventListener("keydown", event => event.stopPropagation());
|
||||
refresh();
|
||||
document.body.appendChild(dialog);
|
||||
try { dialog.showModal(); dismiss.focus(); }
|
||||
catch (error) { close(); throw error; }
|
||||
});
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true; close(); button.remove();
|
||||
};
|
||||
session.ownResource(button, dispose);
|
||||
return { dispose, refresh };
|
||||
}
|
||||
+45
-8
@@ -1,5 +1,6 @@
|
||||
import { lib, get } from "../../../../noname.js";
|
||||
import { cardCost } from "../battle/combat-rules.js";
|
||||
import { cardUpgradeRule } from "../cards/upgrades.js";
|
||||
import { chooseButtons } from "./flow-ui.js";
|
||||
|
||||
const element = (tag, className, text, parent) => {
|
||||
@@ -23,11 +24,20 @@ const viewCard = choice => {
|
||||
const name = cardNameOf(choice);
|
||||
const info = lib.card[name];
|
||||
const imageName = info?.cardimage || name;
|
||||
const image = /^[a-zA-Z0-9_]+$/.test(imageName) ? `${lib.assetURL || ""}image/card/${imageName}.png` : null;
|
||||
const modeImage = info?.image;
|
||||
const localArt = typeof modeImage === "string" &&
|
||||
/^ext:术樱包\/mengsan\/assets\/cards\/[a-z0-9_]+\.png$/.test(modeImage)
|
||||
? modeImage.replace(/^ext:/, "extension/") : null;
|
||||
const image = localArt ? `${lib.assetURL || ""}${localArt}` :
|
||||
/^[a-zA-Z0-9_]+$/.test(imageName)
|
||||
? `${lib.assetURL || ""}image/card/${imageName}.png` : null;
|
||||
const type = { basic: "基本牌", trick: "锦囊牌", delay: "延时锦囊", equip: "装备牌" }[info?.type] || "卡牌";
|
||||
return {
|
||||
name: get.translation(name), type, image, cost: cardCost({ name }),
|
||||
description: plainText(lib.translate[`${name}_info`] || choice.description || "暂无卡牌介绍。"),
|
||||
description: plainText(cardUpgradeRule(name) && info?.cardPrompt
|
||||
? info.cardPrompt(choice.card || { name })
|
||||
: lib.translate[`${name}_info`] || choice.description ||
|
||||
"暂无卡牌介绍。"),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -94,7 +104,11 @@ export function chooseCardDialog(choices, { title = "选择一张牌", allowSkip
|
||||
|
||||
// Reusable post-battle choice surface. Each option owns its follow-up; the surface
|
||||
// only resolves a stable candidate ID (or null) and never awards a reward itself.
|
||||
export function chooseVictoryOptions(options, { title = "战后抉择", allowSkip = true } = {}) {
|
||||
export function chooseVictoryOptions(options, {
|
||||
title = "战后抉择", allowSkip = true,
|
||||
description = "此役已定,选择下一段征途的收获。",
|
||||
rewardLabel = "可选战利品",
|
||||
} = {}) {
|
||||
if (!Array.isArray(options) || !options.length || options.some(option => !option?.id || !option?.label)) {
|
||||
return Promise.reject(new TypeError("chooseVictoryOptions requires nonempty options with IDs and labels"));
|
||||
}
|
||||
@@ -107,9 +121,9 @@ export function chooseVictoryOptions(options, { title = "战后抉择", allowSki
|
||||
element("span", "mengsan-reward-overline-shuying", "梦三 · 战后", intro);
|
||||
element("span", "mengsan-victory-seal-shuying", "胜", intro);
|
||||
element("h2", "", title, intro);
|
||||
element("p", "", "此役已定,选择下一段征途的收获。", intro);
|
||||
element("p", "", description, intro);
|
||||
const board = element("div", "mengsan-victory-options-shuying", null, stage);
|
||||
element("span", "mengsan-victory-eyebrow-shuying", "可选战利品", board);
|
||||
element("span", "mengsan-victory-eyebrow-shuying", rewardLabel, board);
|
||||
let settled = false, choosing = false;
|
||||
const finish = id => {
|
||||
if (settled) return;
|
||||
@@ -162,8 +176,26 @@ export function chooseVictoryOptions(options, { title = "战后抉择", allowSki
|
||||
|
||||
// Gold is a persisted candidate, not an automatic reward. The card pool remains
|
||||
// a separate Dialog; skipping that Dialog returns to the victory choices.
|
||||
export function chooseBattleReward(choices) {
|
||||
if (!Array.isArray(choices) || !choices.length) return Promise.resolve(null);
|
||||
export function chooseBattleReward(choices, { fixedRewards = [] } = {}) {
|
||||
if (!Array.isArray(choices)) return Promise.resolve(null);
|
||||
const fixedDescription = fixedRewards.map(reward =>
|
||||
`${reward.name || reward.id}${reward.description
|
||||
? `:${plainText(reward.description)}` : ""}`
|
||||
).join(";");
|
||||
if (!choices.length) {
|
||||
if (!fixedRewards.length) return Promise.resolve(null);
|
||||
return chooseVictoryOptions([{
|
||||
id: "claim-fixed-rewards",
|
||||
label: "领取固定奖励",
|
||||
description: fixedDescription,
|
||||
choose: () => null,
|
||||
}], {
|
||||
title: "过关奖励",
|
||||
description: "此役已定,以下固定奖励将全部获得。",
|
||||
rewardLabel: "固定战利品",
|
||||
allowSkip: false,
|
||||
});
|
||||
}
|
||||
const gold = choices.find(choice => choice.kind === "gold");
|
||||
const rewards = choices.filter(choice => choice.kind !== "gold");
|
||||
const options = [];
|
||||
@@ -186,5 +218,10 @@ export function chooseBattleReward(choices) {
|
||||
},
|
||||
});
|
||||
}
|
||||
return chooseVictoryOptions(options);
|
||||
return chooseVictoryOptions(options, {
|
||||
title: "过关奖励",
|
||||
description: fixedRewards.length
|
||||
? `固定奖励:${fixedDescription}。另可选择一项战利品。`
|
||||
: "此役已定,可选择一项战利品,也可放弃本次选择。",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1387,3 +1387,79 @@ body.mengsan-battle-ui-shuying :is(
|
||||
.handcards > .card[data-mengsan-cost] > .mengsan-hand-cost-shuying::before {
|
||||
content: attr(data-mengsan-cost);
|
||||
}
|
||||
.handcards > .card[data-mengsan-upgrade] > .mengsan-hand-upgrade-shuying {
|
||||
position: absolute !important;
|
||||
top: 4px !important;
|
||||
right: 4px !important;
|
||||
z-index: 8 !important;
|
||||
display: grid !important;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 20px;
|
||||
border: 1px solid #86bd85;
|
||||
border-radius: 4px;
|
||||
background: #214f36;
|
||||
box-shadow: 0 1px 3px #0008;
|
||||
color: #f5ffea;
|
||||
font: 700 13px/1 "Microsoft YaHei", sans-serif;
|
||||
pointer-events: none;
|
||||
}
|
||||
.handcards > .card[data-mengsan-upgrade] > .mengsan-hand-upgrade-shuying::before {
|
||||
content: "+" attr(data-mengsan-upgrade);
|
||||
}
|
||||
.mengsan-battle-relics-button-shuying { top: 162px; }
|
||||
.mengsan-relic-dialog-shuying { max-width: 620px; }
|
||||
.mengsan-relic-list-shuying { overflow-y: auto; padding: 12px 0; }
|
||||
.mengsan-relic-list-shuying section {
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid #b9975d55;
|
||||
}
|
||||
.mengsan-relic-list-shuying h3 { margin: 0 0 8px; color: #ffe0a1; }
|
||||
.mengsan-relic-list-shuying p { margin: 0 0 6px; line-height: 1.7; }
|
||||
.mengsan-relic-list-shuying small { color: #d1bd9c; }
|
||||
@media (max-height: 500px) {
|
||||
.mengsan-battle-relics-button-shuying { top: 122px; }
|
||||
}
|
||||
.mengsan-bonds-shuying {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.mengsan-bond-card-shuying {
|
||||
border: 1px solid rgba(116, 89, 56, .35);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.mengsan-bonds-shuying p {
|
||||
margin: 8px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.mengsan-bonds-shuying h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.mengsan-bonds-shuying progress {
|
||||
width: 100%;
|
||||
accent-color: #8c6a34;
|
||||
}
|
||||
.mengsan-bonds-shuying button {
|
||||
min-height: 44px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.mengsan-bonds-shuying button[aria-pressed="true"] {
|
||||
background: rgba(140, 106, 52, .18);
|
||||
font-weight: bold;
|
||||
}
|
||||
.mengsan-bonds-shuying button:disabled {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.mengsan-bonds-shuying button:focus-visible {
|
||||
outline: 2px solid #8c6a34;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { grantRelic, canAcquireRelic, getRelic, heldRelics,
|
||||
relicRewardIds } from "../mengsan/relics/definitions.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("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);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user