mirror of
https://github.com/Shu-Ying/noname-shuying.git
synced 2026-10-22 07:10:01 +08:00
fixed # 补全梦三怪物、卡牌
fixed # 修复天书乱斗中,过关技能不会重置次数的问题
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// Candidate external coordinator. Finish callbacks return a boolean, NEVER a drain promise.
|
||||
const copy = value => JSON.parse(JSON.stringify(value));
|
||||
export function createBattleFlow({settlement, chooseReward, showMap, showEnding, quiesce, releaseSkills = async () => {}, presentVictory = async () => {}}) {
|
||||
export function createBattleFlow({settlement, chooseReward, chooseDefeat, showMap, showEnding, quiesce, releaseSkills = async () => {}, presentVictory = async () => {}}) {
|
||||
let state = "map", active = null, work = null, job = null, error = null;
|
||||
async function drive() {
|
||||
try {
|
||||
@@ -13,16 +13,20 @@ export function createBattleFlow({settlement, chooseReward, showMap, showEnding,
|
||||
await presentVictory(copy(job.pending));
|
||||
job.victoryPresented = true;
|
||||
}
|
||||
if (job.pending.state !== "chosen") {
|
||||
// Also reopen a legacy chosen defeat: it never had a recovery choice.
|
||||
if (job.pending.state !== "chosen" ||
|
||||
(job.pending.outcome === "defeat" && !job.pending.failureAction)) {
|
||||
state = "choosing";
|
||||
if (!job.hasChoice) {
|
||||
job.choiceId = job.pending.outcome === "defeat" ? null
|
||||
job.choiceId = job.pending.outcome === "defeat" ? await chooseDefeat()
|
||||
: await chooseReward(copy(job.pending.choices), {
|
||||
boss: job.pending.boss,
|
||||
fixedRewards: copy(job.pending.fixedRewards || []),
|
||||
rewardPackage: copy(job.pending.rewardPackage || null),
|
||||
});
|
||||
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");
|
||||
if (job.pending.outcome !== "defeat" && !job.pending.rewardPackage &&
|
||||
job.choiceId !== null &&
|
||||
!job.pending.choices.some(c => c.id === job.choiceId && c.kind !== "gold")) throw new Error("Invalid reward selection");
|
||||
job.hasChoice = true;
|
||||
}
|
||||
job.pending = await settlement.choose(job.input.run.runId, job.pending.id, job.choiceId);
|
||||
@@ -47,7 +51,9 @@ export function createBattleFlow({settlement, chooseReward, showMap, showEnding,
|
||||
job.nextNode = await showMap(mapRun);
|
||||
job.committed.run = mapRun;
|
||||
}
|
||||
else await showEnding(job.committed.route, copy(job.committed.run));
|
||||
else if (!["retry", "new"].includes(job.committed.route)) {
|
||||
await showEnding(job.committed.route, copy(job.committed.run));
|
||||
}
|
||||
job.delivered = true;
|
||||
}
|
||||
state = job.committed.route === "map" ? "map" : "ended";
|
||||
@@ -73,7 +79,7 @@ export function createBattleFlow({settlement, chooseReward, showMap, showEnding,
|
||||
state = "starting";
|
||||
try {
|
||||
await quiesce();
|
||||
active = {session, run, node:copy(node), encounter:copy(encounter)};job = null;work = null;
|
||||
active = {session, run, retryRun:copy(run), node:copy(node), encounter:copy(encounter)};job = null;work = null;
|
||||
// start MUST schedule only; it must not await the battle root's completion.
|
||||
state = "battle"; start();
|
||||
} catch (cause) { state = "blocked"; error = cause; throw cause; }
|
||||
@@ -82,7 +88,7 @@ export function createBattleFlow({settlement, chooseReward, showMap, showEnding,
|
||||
if (state !== "battle" || session !== active.session) return false;
|
||||
if (!["victory", "defeat"].includes(outcome)) throw new Error("Unknown outcome");
|
||||
if (!Number.isInteger(defeatedEnemies) || defeatedEnemies < 0) throw new Error("Invalid defeated enemy count");
|
||||
job = {input:{run:copy(active.run),node:copy(active.node),encounter:{...copy(active.encounter),defeatedEnemies},hp,outcome}};
|
||||
job = {input:{run:copy(active.run),retryRun:copy(active.retryRun),node:copy(active.node),encounter:{...copy(active.encounter),defeatedEnemies},hp,outcome}};
|
||||
state = "stopping";
|
||||
active.session.requestStop();
|
||||
launch();
|
||||
|
||||
@@ -8,7 +8,7 @@ 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, logRewardPackage = () => {},
|
||||
enterNextAct, createRun, now = Date.now, logRewardPackage = () => {},
|
||||
}) {
|
||||
const readRun = () => store.read()[config.saveKey];
|
||||
function current(storage, runId) {
|
||||
@@ -25,9 +25,9 @@ export function createBattleSettlement({
|
||||
return Object.freeze({
|
||||
readRun,
|
||||
// Persist fixed candidates before opening choice UI. No mutation of the live run.
|
||||
async prepare({run: snapshot, node, encounter, hp, outcome = "victory"}) {
|
||||
async prepare({run: snapshot, retryRun, node, encounter, hp, outcome = "victory"}) {
|
||||
if (!["victory", "defeat"].includes(outcome)) throw new Error("Unknown battle outcome");
|
||||
if (outcome === "victory" && (!Number.isFinite(hp) || (!encounter.skipRandomReward && (!Number.isSafeInteger(encounter.gold) || encounter.gold < 0)))) throw new Error("Invalid battle result");
|
||||
if (outcome === "victory" && (!Number.isFinite(hp) || (!encounter.rewardPackage && (!Number.isSafeInteger(encounter.gold) || encounter.gold < 0)))) throw new Error("Invalid battle result");
|
||||
const id = idFor(snapshot, node);
|
||||
return store.update(storage => {
|
||||
const run = current(storage, snapshot.runId);
|
||||
@@ -42,7 +42,7 @@ export function createBattleSettlement({
|
||||
}
|
||||
// Battle may have changed RNG/card state since the last checkpoint.
|
||||
const base = copy(snapshot); delete base.battleFlow;
|
||||
const bondGrowth = settleBondBattle(base);
|
||||
const bondGrowth = settleBondBattle(base, node);
|
||||
if (outcome === "victory") {
|
||||
base.player.hp = Math.max(1, Math.min(hp, base.player.maxHp ?? hp));
|
||||
base.statistics.defeatedEnemies += encounter.defeatedEnemies ?? 1;
|
||||
@@ -50,21 +50,68 @@ export function createBattleSettlement({
|
||||
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
|
||||
// Persist guaranteed gold alongside loot; award it once in the chosen result.
|
||||
const choices = outcome === "victory" && !encounter.rewardPackage
|
||||
? [{id:"mengsan.reward.gold.shuying", kind:"gold", name:"金币", amount:encounter.gold}, ...rewards] : [];
|
||||
const rewardPackage = outcome === "victory" && encounter.rewardPackage
|
||||
? prepareRewardPackage(base, encounter.rewardPackage,
|
||||
getRandomRewardChoices) : null;
|
||||
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};
|
||||
run.battleFlow.pending.rewardPackage = copy(rewardPackage);
|
||||
if (outcome === "victory" && !rewardPackage) {
|
||||
run.battleFlow.pending.rewardMode = "one-or-skip";
|
||||
}
|
||||
if (outcome === "defeat") {
|
||||
const beforeBattle = retryRun || run;
|
||||
if (beforeBattle.runId !== run.runId ||
|
||||
beforeBattle.actIndex !== run.actIndex ||
|
||||
beforeBattle.revision !== run.revision) throw new Error("Stale retry checkpoint");
|
||||
run.battleFlow.pending.retryRun = copy(beforeBattle);
|
||||
run.battleFlow.pending.retryRun.battleFlow = {
|
||||
receipts:copy(run.battleFlow.receipts), pending:null,
|
||||
};
|
||||
run.battleFlow.pending.retryNode = copy(node);
|
||||
run.battleFlow.pending.retryEncounter = copy(encounter);
|
||||
}
|
||||
return run.battleFlow.pending;
|
||||
});
|
||||
},
|
||||
async choose(runId, id, choiceId) {
|
||||
return store.update(storage => {
|
||||
const {item} = pending(storage, runId, id);
|
||||
const {run, item} = pending(storage, runId, id);
|
||||
if (item.outcome === "defeat") {
|
||||
const action = typeof choiceId === "string" ? choiceId : choiceId?.action;
|
||||
if (!["retry", "new"].includes(action)) throw new Error("Invalid defeat action");
|
||||
if (item.failureAction) {
|
||||
if (item.failureAction !== action ||
|
||||
(action === "new" && item.choiceId.character !== choiceId.character)) {
|
||||
throw new Error("Defeat choice already locked");
|
||||
}
|
||||
return item;
|
||||
}
|
||||
if (action === "new") {
|
||||
if (!config.characters.includes(choiceId.character) || typeof createRun !== "function") {
|
||||
throw new Error("Invalid new journey character");
|
||||
}
|
||||
// Generate once in the chosen checkpoint; a commit retry must not reroll it.
|
||||
item.nextRun = createRun(choiceId.character);
|
||||
if (!item.nextRun || item.nextRun.status !== "running" || item.nextRun.runId === runId) {
|
||||
throw new Error("Invalid new journey");
|
||||
}
|
||||
item.result = copy(item.base);
|
||||
item.result.status = "failed";
|
||||
} else {
|
||||
// Old pending saves have no retryRun: retain their last successful save.
|
||||
item.result = copy(item.retryRun || run);
|
||||
item.result.status = "running";
|
||||
item.result.battleFlow = {receipts:copy(run.battleFlow.receipts), pending:null};
|
||||
item.retryNode ||= copy(run.map?.nodes.find(node => node.id === item.nodeId));
|
||||
if (!item.retryNode || item.retryNode.completed) throw new Error("Missing retry battle node");
|
||||
}
|
||||
item.state = "chosen"; item.choiceId = copy(choiceId);
|
||||
item.failureAction = action; item.route = action; item.completedAt = now();
|
||||
return item;
|
||||
}
|
||||
if (item.state === "chosen") {
|
||||
const sameChoice = item.rewardPackage
|
||||
? item.choiceId?.cardId === choiceId?.cardId &&
|
||||
@@ -80,16 +127,24 @@ export function createBattleSettlement({
|
||||
applyRewardPackage(result, item.rewardPackage, choiceId,
|
||||
applyReward);
|
||||
}
|
||||
const choice = item.choices.find(c => c.id === choiceId);
|
||||
if (!item.rewardPackage && item.choices.length && choiceId !== null) {
|
||||
if (!choice) throw new Error("Reward is not a fixed candidate");
|
||||
if (choice.kind === "gold") {
|
||||
if (!Number.isSafeInteger(choice.amount) || choice.amount < 0) throw new Error("Invalid pending gold reward");
|
||||
result.player.gold += choice.amount;
|
||||
result.statistics.goldEarned += choice.amount;
|
||||
} else applyReward(result, choice.effectId || choice.id);
|
||||
} else if (!item.rewardPackage && choiceId !== null) throw new Error("Unexpected reward selection");
|
||||
for (const rewardId of item.fixedRewards || []) applyReward(result, rewardId);
|
||||
if (!item.rewardPackage) {
|
||||
const reward = item.choices.find(c => c.id === choiceId && c.kind !== "gold");
|
||||
if (choiceId !== null && !reward) {
|
||||
throw new Error("Choose one pending reward or skip");
|
||||
}
|
||||
// This result is persisted before commit; retries never credit gold twice.
|
||||
for (const gold of item.choices.filter(c => c.kind === "gold")) {
|
||||
if (!Number.isSafeInteger(gold.amount) || gold.amount < 0) throw new Error("Invalid pending gold reward");
|
||||
result.player.gold += gold.amount;
|
||||
result.statistics.goldEarned += gold.amount;
|
||||
}
|
||||
if (reward) {
|
||||
applyReward(result, reward.effectId || reward.id);
|
||||
}
|
||||
for (const rewardId of item.fixedRewards || []) applyReward(result, rewardId);
|
||||
} else {
|
||||
for (const rewardId of item.fixedRewards || []) applyReward(result, rewardId);
|
||||
}
|
||||
if (!completeNode(result, item.nodeId)) throw new Error("Node completion failed");
|
||||
route = item.boss && !enterNextAct(result) ? "victory" : "map";
|
||||
} else result.status = "failed";
|
||||
@@ -111,6 +166,14 @@ export function createBattleSettlement({
|
||||
const {run, item} = pending(storage, runId, id);
|
||||
if (item.state !== "chosen") throw new Error("Durable chosen result required");
|
||||
const result = copy(item.result);
|
||||
if (item.route === "retry") {
|
||||
// A failed attempt does not complete this node or add its victory receipt.
|
||||
result.battleFlow = {receipts:copy(run.battleFlow.receipts), pending:null,
|
||||
retry:{node:copy(item.retryNode), encounter:copy(item.retryEncounter || null)}};
|
||||
storage[config.saveKey] = result;
|
||||
return {route:"retry", run:result, retryNode:copy(item.retryNode),
|
||||
retryEncounter:copy(item.retryEncounter || null), duplicate:false};
|
||||
}
|
||||
result.battleFlow = {receipts:[...run.battleFlow.receipts, id], pending:null};
|
||||
if (item.route === "map") storage[config.saveKey] = result;
|
||||
else {
|
||||
@@ -118,12 +181,13 @@ export function createBattleSettlement({
|
||||
profile.completedRuns ||= [];
|
||||
if (!profile.completedRuns.some(r => r.runId === runId)) {
|
||||
if (item.route === "victory") profile.wins = (profile.wins || 0) + 1;
|
||||
profile.completedRuns.push({runId, settlementId:id, outcome:item.route, character:result.player.character,
|
||||
profile.completedRuns.push({runId, settlementId:id, outcome:item.route === "new" ? "defeat" : item.route, character:result.player.character,
|
||||
completedNodes:result.statistics.completedNodes, goldEarned:result.statistics.goldEarned, completedAt:item.completedAt});
|
||||
}
|
||||
delete storage[config.saveKey]; // Same transaction as profile; never a second clear write.
|
||||
if (item.route === "new") storage[config.saveKey] = copy(item.nextRun);
|
||||
else delete storage[config.saveKey]; // Same transaction as profile; never a second clear write.
|
||||
}
|
||||
return {route:item.route, run:result, duplicate:false};
|
||||
return {route:item.route, run:item.route === "new" ? copy(item.nextRun) : result, duplicate:false};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
// 梦三独有的出牌经济,不修改 noname 的全局牌定义。
|
||||
import { isAttackCard } from "../monsters/vine-tangled.js";
|
||||
import { _status } from "../../../../noname.js";
|
||||
import { cardUpgradeLevel, cardUpgradeRule } from "../cards/upgrades.js";
|
||||
export const PLAYER_ENERGY = 3;
|
||||
export const PLAYER_HAND_LIMIT = 3;
|
||||
export const SHA_DAMAGE = 6;
|
||||
export const TRICK_DAMAGE = 4;
|
||||
|
||||
const COSTS = Object.freeze({ sha: 1, mengsan_zhongsha: 2,
|
||||
const COSTS = Object.freeze({ sha: 1, mengsan_zhongsha: 2, mengsan_fangyu: 1, mengsan_fennu: 0,
|
||||
mengsan_feijianhuixuanbiao: 1, mengsan_jianbingdaji: 1, mengsan_quanshenzhuangji: 1, mengsan_rongrongzhiquan: 1, mengsan_shandianpili: 1, mengsan_shuangchongdaji: 1, mengsan_tiezhanbo: 1, mengsan_touchui: 1, mengsan_tupo: 1, mengsan_yubeidaji: 1, mengsan_wanmeidaji: 2, mengsan_yujin: 2, mengsan_fangxue: 0, mengsan_jianyi: 1, mengsan_pomie: 1, mengsan_songjianwushi: 1,
|
||||
tao: 1, jiu: 1, shan: 0, wuxie: 0,
|
||||
juedou: 2, nanman: 2, wanjian: 2, taoyuan: 2 });
|
||||
|
||||
export const cardCost = card => COSTS[card?.name] ?? 1;
|
||||
export const cardCost = (card, player = null) => {
|
||||
const rule = cardUpgradeLevel(card) ? cardUpgradeRule(card.name) : null;
|
||||
const base = rule?.cost ?? COSTS[card?.name] ?? 1;
|
||||
return base + (player?.storage?.mengsanTangled_shuying > 0 && isAttackCard(card) ? 1 : 0);
|
||||
};
|
||||
|
||||
export const isActiveCardUse = (event, player) => {
|
||||
if (!event || !player) return false;
|
||||
@@ -17,7 +24,7 @@ export const isActiveCardUse = (event, player) => {
|
||||
let inPhaseUse = false;
|
||||
for (let current = event; current && !seen.has(current); current = current.parent) {
|
||||
seen.add(current);
|
||||
if (current.name === "dying" || current.name === "chooseToRespond") return false;
|
||||
if (current.name === "dying" || current.name === "respond" || current.name === "chooseToRespond") return false;
|
||||
if (current.name === "chooseToUse" && (current.respondTo || String(current.type || "").startsWith("respond"))) return false;
|
||||
if (current.name === "phaseUse" && current.player === player) inPhaseUse = true;
|
||||
}
|
||||
@@ -26,10 +33,35 @@ export const isActiveCardUse = (event, player) => {
|
||||
_status.currentPhase === player);
|
||||
};
|
||||
|
||||
export const canPayCard = (player, card) => (player?.storage?.mengsanEnergy_shuying ?? Infinity) >= cardCost(card);
|
||||
// 仅授权破灭的这一条原生选择/使用父链及同一张实体牌;副本和其他使用不免费。
|
||||
const freeUses = new WeakMap();
|
||||
export function authorizeFreeCardUse(player, card, choice, valid) {
|
||||
const entries = freeUses.get(player) || new Set();
|
||||
freeUses.set(player, entries);
|
||||
const entry = { card, choice, valid }; entries.add(entry);
|
||||
return () => { entries.delete(entry); if (!entries.size) freeUses.delete(player); };
|
||||
}
|
||||
function isFreeCardUse(player, card, event) {
|
||||
for (const entry of freeUses.get(player) || []) {
|
||||
if (!entry.valid() || card?.name !== entry.card.name ||
|
||||
!(card === entry.card || card?.cards?.includes(entry.card))) continue;
|
||||
const seen = new Set();
|
||||
for (let current = event; current && !seen.has(current); current = current.parent) {
|
||||
seen.add(current);
|
||||
if (current.name === "useCard" && (current.player !== player ||
|
||||
current.cards?.length !== 1 || current.cards[0] !== entry.card)) break;
|
||||
if (current === entry.choice) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const payCard = (player, card) => {
|
||||
if (!canPayCard(player, card)) return false;
|
||||
player.storage.mengsanEnergy_shuying -= cardCost(card);
|
||||
export const canPayCard = (player, card, event = _status.event) =>
|
||||
isFreeCardUse(player, card, event) || (player?.storage?.mengsanEnergy_shuying ?? Infinity) >= cardCost(card, player);
|
||||
|
||||
export const payCard = (player, card, event = _status.event) => {
|
||||
if (isFreeCardUse(player, card, event)) return true;
|
||||
if (!canPayCard(player, card, event)) return false;
|
||||
player.storage.mengsanEnergy_shuying -= cardCost(card, player);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { slowAttackDamage } from "../monsters/effigy-slow.js";
|
||||
import { shrinkAttackDamage } from "../monsters/shrinker-status.js";
|
||||
|
||||
export function attackHitCount(intent) {
|
||||
const hits = intent.hits ?? 1;
|
||||
if (!Number.isInteger(hits) || hits < 1) {
|
||||
@@ -16,7 +19,7 @@ export function outgoingAttackDamage(intent, source) {
|
||||
if (source?.storage?.mengsanWeak_shuying > 0) {
|
||||
damage = Math.floor(damage * 0.75);
|
||||
}
|
||||
return damage;
|
||||
return shrinkAttackDamage(damage, source);
|
||||
}
|
||||
|
||||
export function previewAttackDamage(intent, source, target) {
|
||||
@@ -24,5 +27,5 @@ export function previewAttackDamage(intent, source, target) {
|
||||
if (target?.storage?.mengsanVulnerable_shuying > 0) {
|
||||
damage = Math.ceil(damage * 1.5);
|
||||
}
|
||||
return damage;
|
||||
return slowAttackDamage(damage, target);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { applyMengsanDebuff } from "../monsters/artifact-status.js";
|
||||
import { applyTangled } from "../monsters/vine-tangled.js";
|
||||
import { attackHitCount, outgoingAttackDamage } from "./intent-damage.js";
|
||||
import { beginDeathBlow, isDeathBlowIntent } from "./death-blow.js";
|
||||
|
||||
@@ -11,14 +13,8 @@ export function createIntentExecutor(game, getActiveBattle) {
|
||||
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");
|
||||
if (move.debuff === "缠结") { applyTangled(getActiveBattle(), target, move.stacks); return; }
|
||||
applyMengsanDebuff(target, move.debuff === "脆弱" ? "frail" : "vulnerable", move.stacks);
|
||||
};
|
||||
return async (source, move) => {
|
||||
for (const target of getIntentHostiles(game, source)) {
|
||||
|
||||
Reference in New Issue
Block a user