Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions data/cards/BS04.json
Original file line number Diff line number Diff line change
Expand Up @@ -5421,8 +5421,15 @@
"kind": "magic",
"timing": "flash",
"action": {
"type": "suppressTriggerThisTurn",
"trigger": "onDestroy"
"type": "timedEffect",
"content": [
{
"type": "suppressTrigger",
"trigger": "onDestroy"
}
],
"duration": "turn",
"all": true
}
}
]
Expand Down
11 changes: 9 additions & 2 deletions data/cards/BS14.json
Original file line number Diff line number Diff line change
Expand Up @@ -2548,8 +2548,15 @@
"exhaustSelf": true
},
"action": {
"type": "markSuppressTriggerThisTurn",
"trigger": "onAttack"
"type": "timedEffect",
"content": [
{
"type": "suppressTrigger",
"trigger": "onAttack"
}
],
"duration": "turn",
"count": 1
}
}
]
Expand Down
1 change: 1 addition & 0 deletions docs/design/ACTION_VOCABULARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
- アタック時⇔ブロック時の効果の付け替えは `timedEffect` の内容 `{ type: "triggerSwap"; from }`。1体は個体の印、`all: true` は `side: "both"`=ゲーム全体の印・`side: "own"`=プレイヤーに掛かる制約(どちらも判定のたびに見るので後から出たスピリットにも効く)(2026-09-24)
- 「このバトルの間、BPの代わりに Lv/コアの数/コストを比べる」は `timedEffect` の内容 `{ type: "compareBy"; by }`、「BPの高い方が破壊される」は `{ type: "invertBattleWinner" }`(期間 `battle`・対象はバトル)。印は `state.battle` に置き、バトル外は不発(2026-09-25)
- 「このターンの間、可能ならば必ずアタックする」は `timedEffect` の内容 `{ type: "mustAttack" }`(期間 `turn`)。1体は個体の印 `mustAttackThisTurn`(既に印がある個体は候補にしない)、「すべて」は `timedRule` で後から出たスピリットにも効く。判定は `shared/rules.ts` の `mustAttackThisTurn`。「好きなだけ指定」(BS12-079)だけ旧 `forceAttackThisTurn` に残す(2026-09-25)
- 「このターンの間、〜の効果は発揮されない」は `timedEffect` の内容 `{ type: "suppressTrigger"; trigger }`(期間 `turn`)。1体は個体の `suppressedTriggersThisTurn`、`all: true` は陣営ごとの `state.triggerSuppressionThisTurn`(陣営は `side`、絞り込みは未対応)。判定は `triggers.ts` の `isTriggerSuppressed`(2026-09-25)
- `timedEffect` の `all: true` は個体を選ばず `turnConstraints` に `timedRule`(内容・陣営・解決済みの `filter`)を積み、宣言のたびに `shared/rules.ts` の `cantActByTimedRule` が `matchesTarget` で照合する。陣営は `side`(相手=既定/`own`/`both`)。いまは期間 `turn` だけ(2026-09-24)
- 「このターンの間、〜のスピリットすべては〜できない」は、効果の解決後に場に出たスピリットや、後から条件に合うようになったスピリットにも効く(判定のたびに条件を照合する全体ルール。2026-09-24 ユーザー確認)。「〜1体を指定し」は解決時に選んだ個体にだけ効く
- 「この効果で消滅したスピリット1体につき」(BS10-X02)は、直前の結果を数える形(`if` の `last`/カウンタ)で読む
Expand Down
2 changes: 1 addition & 1 deletion docs/design/REFACTOR_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ R1 と R2 は挙動を変えずに効くので最初にやる。R6 と R7 は着
| 3 | M1 `pay` | 18種・延べ44枚 | `pay { cost, then }` と「完全に解決できる」の判定 | **12種は移行済み(09-24)**。残りは量が支払いの結果で決まる6種と、アクションに組み込まれた `costXxx` 31種・54枚(移すときに数どおりの規則へ揃える) |
| 4 | M2 `if`・`forEach`・マジックの使用 | 18+2+3種 | `if { cond: last/state/event }`、`forEach`、`マジックの使用 { from, コスト }` | 接続詞ごとの読み(下記) |
| 5 | M5 オープン | 17種・延べ36枚 | `オープン { from, 枚数, 選ぶ, 行き先, 残り }` | |
| 6 | M8 期間つき継続効果(既存の内容あり) | 51種・延べ95枚 | 器「継続効果を期間つきで置く」+期間の値。内容は既存の継続効果の語彙をそのまま使う | **器 `timedEffect` を作り、「アタック/ブロックできない」を個体に付ける3種(4か所)を移行済み(09-24)**。全体ルール型(`all: true`・`side`)と `banActByCostThisTurn`・`restrictActionsToColorThisTurn`(5か所)も移行済み。「すべてをBP+」(`bpBuffAll` 11か所)も内容 `bp` で移行済み。1体を指定する `bpBuff` も150か所移行済み(オプション付き7か所は旧 `bpBuff` に残す)。`selfBuff`(82か所)も `target: "self"` で移行済み。『アタック時』『ブロック時』の BP+ も「バトルの間」にそろえた(86か所)。見出しの BP+ 4枚はオーラへ移した。キーワードを与える(`grantKeyword`・`grantKeywordAll`)も移行済み。Lv を「として扱う」の1体指定2種(9か所)も移行済み。「すべて」2種も全体ルールで移行済み(後から出たスピリットにも効く)。色を与える(`grantColorThisTurn`・`grantColorChoice`)も移行済み(系統・色の貸与は器に入れない)。プレイヤーに掛かる制約8種(10か所)も `playerRule` で移行済み。「ブロックされない」3種(5か所)も移行済み(ゲッコ・グライダーとムーンボウクロークは残し)。バトル中の印(`lockFlash`・`disableOpponentBurstThisBattle`、9か所)も移行済み。シンボル・コスト(4種・4か所)と効果の付け替え(4種・4か所)も移行済み。比較基準・勝敗反転(4種・6か所)も `compareBy`・`invertBattleWinner` で移行済み。強制アタック(`forceAttackThisTurn` 7か所)も `mustAttack` で移行済み(「好きなだけ指定」の1枚は残し)。次は §A の残り(「として扱う」・効果の借用など、内容を新設するもの) |
| 6 | M8 期間つき継続効果(既存の内容あり) | 51種・延べ95枚 | 器「継続効果を期間つきで置く」+期間の値。内容は既存の継続効果の語彙をそのまま使う | **器 `timedEffect` を作り、「アタック/ブロックできない」を個体に付ける3種(4か所)を移行済み(09-24)**。全体ルール型(`all: true`・`side`)と `banActByCostThisTurn`・`restrictActionsToColorThisTurn`(5か所)も移行済み。「すべてをBP+」(`bpBuffAll` 11か所)も内容 `bp` で移行済み。1体を指定する `bpBuff` も150か所移行済み(オプション付き7か所は旧 `bpBuff` に残す)。`selfBuff`(82か所)も `target: "self"` で移行済み。『アタック時』『ブロック時』の BP+ も「バトルの間」にそろえた(86か所)。見出しの BP+ 4枚はオーラへ移した。キーワードを与える(`grantKeyword`・`grantKeywordAll`)も移行済み。Lv を「として扱う」の1体指定2種(9か所)も移行済み。「すべて」2種も全体ルールで移行済み(後から出たスピリットにも効く)。色を与える(`grantColorThisTurn`・`grantColorChoice`)も移行済み(系統・色の貸与は器に入れない)。プレイヤーに掛かる制約8種(10か所)も `playerRule` で移行済み。「ブロックされない」3種(5か所)も移行済み(ゲッコ・グライダーとムーンボウクロークは残し)。バトル中の印(`lockFlash`・`disableOpponentBurstThisBattle`、9か所)も移行済み。シンボル・コスト(4種・4か所)と効果の付け替え(4種・4か所)も移行済み。比較基準・勝敗反転(4種・6か所)も `compareBy`・`invertBattleWinner` で移行済み。強制アタック(`forceAttackThisTurn` 7か所)も `mustAttack` で移行済み(「好きなだけ指定」の1枚は残し)。トリガー抑止(2種・2か所)も `suppressTrigger` で移行済み。次は §A の残り(「として扱う」・効果の借用など、内容を新設するもの) |
| 7 | M8 期間つき継続効果(内容を新設) | 37種・延べ136枚 | 新しい内容(効果の付け替え・比較基準・ブロックの追加コスト・シンボル・「として扱う」) | 内容ごとに分けて複数 PR にする |

1〜2 は確認なしで進められる。3・4 は着手前に下の確認を取る。
Expand Down
4 changes: 2 additions & 2 deletions scripts/smoke/part316.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// smoke パート316(BS14 白バッチ20枚:BS14-034〜044/070/082〜084/103〜106/X04)
// 新設した器: AuraCounter { ownColor: Color }/kind:"blockTriggersAsAttackGrant".whileOwnBurstSet/
// action:"markSuppressTriggerThisTurn"(CardInstance.suppressedTriggersThisTurn)/
// timedEffect の suppressTrigger(CardInstance.suppressedTriggersThisTurn)/
// globalConstraint "handImmuneForPid".includeNexus/
// globalConstraint "ownLifeFloor".costSelfToTrash/then(EffectModules.tryOwnLifeFloorByCost)/
// kind:"fieldEvent" event:"ownBurstActivated" condition.ownBurstSet/action:"returnToHandCostBudget"
Expand Down Expand Up @@ -170,7 +170,7 @@ console.log("=== BS14-043 月光姫マーニ:【氷壁:赤/緑/白/青】+
const target = put(s, "p2", "BS01-004", 1) // ドラグノ偵察兵:『このスピリットのアタック時』BP+2000
const baseBp = effectiveBp(s, "p2", target)
s.phase = "attack"
resolveAction(s, "p1", marni, { type: "markSuppressTriggerThisTurn", trigger: "onAttack" }, target.instanceId)
resolveAction(s, "p1", marni, { type: "timedEffect", content: [{ type: "suppressTrigger", trigger: "onAttack" }], duration: "turn", count: 1 }, target.instanceId)
assert(target.suppressedTriggersThisTurn?.includes("onAttack") === true, "指定した相手のスピリットのonAttackを抑止する印が付く")
fireTrigger(s, "p2", target, "onAttack")
assert(effectiveBp(s, "p2", target) === baseBp, "指定されたスピリットの『アタック時』効果は発揮されない")
Expand Down
59 changes: 59 additions & 0 deletions scripts/smoke/part378.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// smoke パート378(トリガー抑止:timedEffect の内容 suppressTrigger。移したカードデータを直接解決する)
import { assert, createGame, createInstance, getCard, refreshLevelAsOverrides, resolveAction } from "./helpers"
import type { EffectAction, GameState } from "../../server/src/type"
import { isTriggerSuppressed } from "../../server/src/logic/triggers"

function suppressAction(cardId: string): EffectAction {
let found: EffectAction | undefined
const walk = (o: unknown): void => {
if (found || !o || typeof o !== "object") return
if (Array.isArray(o)) return o.forEach(walk)
const r = o as Record<string, unknown>
if (r.type === "timedEffect" && JSON.stringify(r.content).includes('"suppressTrigger"')) found = r as unknown as EffectAction
Object.values(r).forEach(walk)
}
walk(getCard(cardId).effects)
assert(found !== undefined, `${cardId}:カードデータに suppressTrigger がある`)
return found!
}

const VANILLA = "BS01-002" // ロクケラトプス

function game(): GameState {
const s = createGame("p378", { p1: "アキラ", p2: "ユウキ" }, { p1: "purple", p2: "red" })
s.players.p1.field.spirits = []
s.players.p2.field.spirits = []
return s
}

console.log("=== 前提: カードの機械確認 ===")
{
assert(getCard("BS04-093").name === "ユーサネイジア", "BS04-093 はユーサネイジア")
assert(getCard("BS14-043").name === "月光姫マーニ", "BS14-043 は月光姫マーニ")
assert(getCard(VANILLA).name === "ロクケラトプス", "VANILLAはロクケラトプス")
}

console.log("=== 1. すべて:ユーサネイジア=相手のスピリットの『破壊時』が発揮されない ===")
{
const s = game()
resolveAction(s, "p1", null, suppressAction("BS04-093"), undefined, undefined, "magic")
assert(isTriggerSuppressed(s, "p2", "onDestroy"), "相手の破壊時は抑止される")
assert(!isTriggerSuppressed(s, "p1", "onDestroy"), "自分の破壊時は抑止されない")
assert(!isTriggerSuppressed(s, "p2", "onAttack"), "ほかのトリガーは抑止されない")
}

console.log("=== 2. 1体:月光姫マーニ=実効BP最大の相手1体の『アタック時』だけ ===")
{
const s = game()
const strong = createInstance(VANILLA, 1, 3)
const weak = createInstance(VANILLA, 1, 1)
s.players.p2.field.spirits = [strong, weak]
refreshLevelAsOverrides(s)
resolveAction(s, "p1", null, suppressAction("BS14-043"))
assert(strong.suppressedTriggersThisTurn?.includes("onAttack") === true, "実効BP最大の1体に印が付く")
assert(weak.suppressedTriggersThisTurn === undefined, "もう1体には付かない")
resolveAction(s, "p1", null, suppressAction("BS14-043"))
assert(weak.suppressedTriggersThisTurn?.includes("onAttack") === true, "既に印がある個体は候補にしない")
}

console.log("すべてのチェックに合格しました 🎉(part378)")
10 changes: 7 additions & 3 deletions scripts/validate-cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,9 +546,13 @@ export function validateCards(cards: CardData[]): ValidationIssue[] {
} else if (!VALID_ACTIONS.has(a.type)) {
add(id, `未登録の action.type: "${a.type}"(ハンドラが無いため実行時にクラッシュする)`)
}
// suppressTriggerThisTurn.trigger も同様に検証する(未登録は無言で何も抑止しない)
if (a.type === "suppressTriggerThisTurn" && (typeof a.trigger !== "string" || !VALID_TRIGGERS.has(a.trigger))) {
add(id, `未知の trigger(suppressTriggerThisTurn): ${String(a.trigger)}`)
// timedEffect の suppressTrigger.trigger も同様に検証する(未登録は無言で何も抑止しない)
if (a.type === "timedEffect") {
for (const x of (a as { content?: { type?: unknown; trigger?: unknown }[] }).content ?? []) {
if (x.type === "suppressTrigger" && (typeof x.trigger !== "string" || !VALID_TRIGGERS.has(x.trigger))) {
add(id, `未知の trigger(suppressTrigger): ${String(x.trigger)}`)
}
}
}
}

Expand Down
37 changes: 0 additions & 37 deletions server/src/logic/actions/exhaustRefresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1018,45 +1018,8 @@ const markSkipNextRefreshHandler: ActionHandler<"markSkipNextRefresh"> = (ctx, a
log(state, `${sourceName}は${getCard(target.cardId).name}を指定した。(次のリフレッシュステップで回復しない)`)
}

const markSuppressTriggerThisTurnHandler: ActionHandler<"markSuppressTriggerThisTurn"> = (ctx, action) => {
const { state, opp, self, sourceName } = ctx
const filter = normalizeFilter(ctx, action)
if (filter === SELF_REQUIRED) return
const candidates = state.players[opp].field.spirits.filter(
(s) =>
matchesTarget(state, opp, s, filter, self?.instanceId) &&
!isResisted(state, opp, s, attemptOf(ctx, "other", "targeted")),
)
if (candidates.length === 0) {
log(state, `${sourceName}:指定できる相手のスピリットがいなかった。`)
return
}
if (
ctx.targetInstanceId === undefined &&
tryInteractiveTargetChoice(
state,
ctx.owner,
self,
`${sourceName}:効果を発揮させなくするスピリットを選んでください`,
candidates,
action,
null,
)
) {
return
}
const target =
(ctx.targetInstanceId !== undefined
? candidates.find((s) => s.instanceId === ctx.targetInstanceId)
: undefined) ??
candidates.reduce((best, s) => (effectiveBp(state, opp, s) > effectiveBp(state, opp, best) ? s : best))
target.suppressedTriggersThisTurn = [...(target.suppressedTriggersThisTurn ?? []), action.trigger]
log(state, `${sourceName}は${getCard(target.cardId).name}を指定した。(このターンの間、効果が発揮されない)`)
}

const handlers = {
markSkipNextRefresh: markSkipNextRefreshHandler,
markSuppressTriggerThisTurn: markSuppressTriggerThisTurnHandler,
exhaust: exhaustHandler,
exhaustAllOpponentNexuses: exhaustAllOpponentNexusesHandler,
exhaustSpiritsAndNexusesUpTo: exhaustSpiritsAndNexusesUpToHandler,
Expand Down
12 changes: 0 additions & 12 deletions server/src/logic/actions/grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,17 +277,6 @@ const addSymbolPermanentHandler: ActionHandler<"addSymbolPermanent"> = (ctx, act
return
}

const suppressTriggerThisTurnHandler: ActionHandler<"suppressTriggerThisTurn"> = (ctx, action) => {
const { state, owner, opp, self, sourceName, srcColors, srcType, destroyContext, targetInstanceId, chosenOption, chosenCardIndex } = ctx
// ユーサネイジア:このターンの間、相手のスピリットの指定トリガーを発揮させない
const already = state.triggerSuppressionThisTurn.some(
(e) => e.pid === opp && e.trigger === action.trigger,
)
if (!already) state.triggerSuppressionThisTurn.push({ pid: opp, trigger: action.trigger })
log(state, `${sourceName}:このターンの間、${state.players[opp].name}のスピリットの誘発効果は発揮されない。`)
return
}

const protectLifeByCostThisTurnHandler: ActionHandler<"protectLifeByCostThisTurn"> = (ctx, action) => {
const { state, owner, self, sourceName, targetInstanceId } = ctx
// BS07秘密の花園Lv2:「楽族」1体を疲労させることで、このターンの間、
Expand Down Expand Up @@ -884,7 +873,6 @@ const handlers = {
grantBlockRequiresMagicDiscardThisTurn: grantBlockRequiresMagicDiscardThisTurnHandler,
refreshWhenBlockedByChosenColorThisTurn: refreshWhenBlockedByChosenColorThisTurnHandler,
colorChoiceLendThisTurn: colorChoiceLendThisTurnHandler,
suppressTriggerThisTurn: suppressTriggerThisTurnHandler,
protectLifeByCostThisTurn: protectLifeByCostThisTurnHandler,
grantBlockerImmunity: grantBlockerImmunityHandler,
negateOwnBlockConstraint: negateOwnBlockConstraintHandler,
Expand Down
Loading
Loading