메뉴
HN
Hacker News 53일 전

던전앤드래곤을 위한 모델 기반 테스팅

IMP
7/10
핵심 요약

이 글은 던전앤드래곤(D&D)의 복잡한 전투 시스템을 형식 모델링 언어인 Quint를 사용하여 모델 기반 테스팅(Model-Based Testing)으로 구현한 경험을 공유합니다. 단순한 타격이 아닌 '카운터스펠(Counterspell)' 연쇄 반응, 전투 중 인터럽트, 전설적 내성(Legendary Resistance) 등 룰의 복잡한 상호작용을 상태 기계(State Machine)로 엄밀하게 모델링하여 극단적인 에지 케이스에서 발생할 수 있는 데드락이나 오류를 조기에 검증하는 것이 핵심입니다. 이러한 접근은 복잡한 비즈니스 로직과 상태 전환이 얽힌 시스템의 설계와 테스트에 있어 훌륭한 기술적 참고 자료가 됩니다.

번역된 본문

던전앤드래곤을 위한 모델 기반 테스팅 2026년 4월 7일

지난번 글에서 저는 단일 D&D 캐릭터를 모델링했습니다. 보이드(공허) 속에 홀로 서 있는 하나의 크리처로서, 체력(hit points), 상태(conditions), 주문 슬롯을 추적하는 것이었죠. 이를 위한 Quint 명세(형식 모델링 언어)는 약 6,000줄이었습니다. 이번에는 전투를 추가했습니다. 그냥 '파이터가 고블린을 검으로 때린다'는 식의 단순한 전투가 아니라 진짜 어려운 부분을 말입니다. 카운터스펠(Counterspell) 연쇄, 공격 중반을 끊어내는 실드(Shield) 인터럽트, 집중을 유지하는 준비된 주문(readied spells), 내성 굴림을 뒤집어버리는 전설적 내성(Legendary Resistance) 등이 바로 그것입니다. 그래서 저는 가장 어려운 부분을 먼저 해결했습니다. 그것 없이는 완전한 실행 가능성을 증명할 수 없기 때문입니다. 단순한 케이스만 처리하는 명세는 아무것도 증명하지 못하며, 그저 '투두 앱(Todo App) 프로젝트'와 다를 바 없습니다. 진짜 문제가 되는 병목은 바로 상호작용 속에 존재합니다. 만약 카운터스펠 체인이 상태 기계를 교착 상태(Deadlock)에 빠뜨리거나, 준비된 주문의 집중이 활성화된 효과를 고아(orphan) 상태로 만들 수 있다면, 저는 그 위에 세 겹을 더 쌓은 뒤가 아니라 지금 당장 알아야만 했습니다.

전투 명세는 크리처 코드의 절반도 안 됩니다. 그래서 복잡성도 절반밖에 안 되겠거니 생각하겠지만, 천만에요. 단일 크리처의 상태 공간은 평면적인 리듀서(reducer)일 뿐입니다. 그저 화려하게 꾸민 캐릭터 시트에 불과하죠. 레벨업하고, 피해를 입고, 상태를 얻거나 잃는 것. 상태 공간은 크지만 평면적입니다. 하지만 전투는 이를 심오하게 만듭니다. 두 크리처가 상호작용한다는 것은 모든 행동이 반응을 유발할 수 있고, 모든 반응은 반대 반응을 유발할 수 있으며, 누군가 피해를 입기도 전에 모든 것이 중첩된다는 것을 의미합니다. 초보자는 물론 숙련된 플레이어조차도 종종 이 부분에서 혼란을 겪곤 합니다.

현재 명세가 다루는 내용은 다음과 같습니다: 모든 캐릭터 클래스(12개), 할 수 있는 것과 없는 것을 수정하는 14가지 상태. 다른 크리처의 턴에 행동하는 전설적 크리처. 주문 시전을 방해하는 카운터스펠. 이동 중간에 발동되어 전체 공격 판정 체인을 트리거하는 기회 공격(Opportunity attacks). 캐릭터 목록과 능력치 블록을 가진 플레이어 캐릭터와 몬스터 모두. 데모는 스크립트화된 신비한 전투를 단계별로 재생합니다. 파이어볼, 카운터스펠 체인, 광역 피해(AoE), 죽음 내성 굴림 등이죠. 앞으로 가거나, 뒤로 가거나, 재생 버튼을 누르면 인터럽트 창이 열리고 해결되는 것을 지켜볼 수 있습니다. 지난번에는 보이드 속의 한 크리처였습니다. 이제는 모든 클래스와 상태, 그리고 SRD 1이나 출판된 책의 주문이나 피트(feats)를 추가할 수 있는 프레임워크가 완성되었습니다. 마침내 개념 증명(Proof of Concept)을 넘어섰네요!

인터럽트 체인 (The Interrupt Chain) 단일 공격은 단일 이벤트가 아닙니다. 한 크릭쳐가 다른 크리처를 공격할 때, 규칙은 일련의 인터럽트 창, 즉 참여자가 해결 중간에 반응하고 결과를 바꿀 수 있는 순간들을 엽니다. 각각은 게임 상태를 갈라지게(branch) 할 수 있습니다.

1단계: 공격이 명중합니다. d20이 굴러가고, DM이 이를 방어도(Armor Class)와 비교합니다 — 명중인지 빗나감인지. 하지만 대상은 실드(Shield)를 시전할 수 있습니다(방어도 +5, 잠재적으로 명중을 빗나감으로 뒤집을 수 있음). 바드의 날카로운 언어(Cutting Words)는 굴림 결과에서 주사위 하나의 값을 뺍니다. 이 모든 것은 굴림 후, 명중이 확정되기 전에 일어납니다.

2단계: 피해가 적중합니다. 타격이 들어갑니다. 주사위를 굴립니다. 하지만 로그는 불가사의한 회피(Uncanny Dodge)를 가지고 있습니다 — 반응으로 그 피해를 절반으로 줄입니다. 몽크의 공격 튕겨내기(Deflect Attacks)는 1d10에 민첩 수정치와 레벨을 더한 값을 뺍니다. 이들은 명중 후, 피해가 적용되기 전에 발동합니다.

3단계: 주문이 시전됩니다. 주문 시전은 자체적인 창을 엽니다. 카운터스펠을 준비하고 반응을 사용할 수 있는 크리처라면 누구나 대상에게 건강 내성 굴림(Constitution save)을 강제할 수 있습니다. 시전자가 실패하면 주문은 실패(fizzle)합니다. 충분히 높은 슬롯을 사용하면 자동 성공입니다. 카운터스펠 자체도 주문이므로, 다른 크리처가 그 카운터스펠을 다시 카운터스펠로 무효화할 수도 있습니다. 이 명세에서 이것은 문자 그대로 스택(Stack) 데이터 구조입니다 — 푸시하고, 해결하고, 팝 합니다:

var bSpellStack: List[SpellStackEntry] // 시전 시 푸시, 해결 시 팝

4단계: 내성 굴림이 실패합니다. 대상이 내성 굴림에 실패합니다. 하지만 고대 드래곤은 전설적 내성(Legendary Resistance)을 가지고 있습니다 — 충전량 1개를 소모하면 내성 굴림이 자동 성공합니다. 이것은 내성 굴림 결과가 나온 후, 실패 효과가 적용되기 전에 발동합니다.

이제 이것들을 서로 겹겹이 쌓아보세요. 로그가 적 크리처에게서 멀어지며 이동합니다. 적은 사용하지 않은 반응이 남아 있으므로 기회 공격(Opportunity Attack)을 얻습니다. 즉, 근접 공격 한 번입니다. (공격자가 다회 공격(Multiattack) 속성을 가지고 있더라도 한 번이죠!) (역주: 본문 마지막의 'T'는 잘린 글자로, 문맥상 기회 공격의 상세한 규칙 설명으로 이어집니다.)

원문 보기
원문 보기 (영어)
Model-Based Testing for Dungeons & Dragons Apr 7, 2026 Last time , I modeled a single D&D character. One creature, standing alone in the void, tracking hit points, conditions, and spell slots. The Quint spec (a formal modeling language) for that was around 6,000 lines. Now I added combat. Not just “a Fighter hits a Goblin with a sword” kind of combat, but the hard part: Counterspell chains, Shield interrupting mid-attack, readied spells holding concentration, Legendary Resistance flipping saves. Thus, I did the hard part first. Without it, you can’t prove full viability. A spec that handles the simple cases proves nothing, and is akin to a “Todo App project”; the real blockers live in the interactions. If Counterspell chains can deadlock the state machine, or readied-spell concentration can orphan active effects, I needed to know now, not after building three more layers on top. The battle spec is less than half the creature code. You’d think it amounts to less than half the complexity, but that is far from true. A single creature’s state space is a flat reducer. A glorified character sheet . Level up, take damage, gain a condition, lose a condition. The state space is large but flat. Combat makes it profound. Two creatures interacting means every action can trigger reactions, every reaction can trigger counter-reactions, and the whole thing nests before anyone’s taken damage. This is where beginner and even proficient players often get confused. Here’s what the spec covers now: all the character classes (12 of them,) 14 conditions that modify what you can and can’t do. Legendary creatures that act on other creatures’ turns. Counterspell that interrupts spellcasting. Opportunity attacks that fire mid-movement and trigger full attack resolution chains. Both player characters and monsters, with character lists and stat blocks. The demo replays a scripted arcane battle step by step: Fireball, Counterspell chains, AoE damage, death saves. Step forward, step back, or hit play and watch the interrupt windows open and resolve. Last time it was one creature in the void. Now it’s all classes, conditions, and a framework for adding any spell or feat whether from the SRD 1 or published books . Finally, well past proof of concept! The Interrupt Chain A single attack is not a single event. When one creature attacks another, the rules open a series of interrupt windows — moments where participants can react and alter the outcome mid-resolution. Each one can branch the game state. Phase 1: The attack hits. The d20 lands, the DM compares it to Armor Class — hit or miss. But the target might cast Shield (+5 AC, potentially flipping the hit to a miss). A Bard’s Cutting Words subtracts a die from the roll. All of this happens after the roll but before the hit is confirmed. Phase 2: Damage lands. The hit lands. Dice are rolled. But the Rogue has Uncanny Dodge — halve that damage as a reaction. A Monk’s Deflect Attacks subtracts 1d10 plus their Dexterity modifier and level. These fire after the hit but before the damage applies. Phase 3: A spell is being cast. Spellcasting opens its own window: any creature with Counterspell prepared and a reaction available can force a Constitution save. If the caster fails, the spell fizzles. Use a high enough slot and it auto-succeeds. The Counterspell is itself a spell, so another creature can Counterspell the Counterspell. In the spec, this is a literal stack data structure — push, resolve, pop: var bSpellStack: List[SpellStackEntry] // push on cast, pop on resolve Phase 4: A save fails. The target fails their saving throw. But the Ancient Dragon has Legendary Resistance — it spends a charge and the save auto-succeeds. This fires after the save result but before the fail effects apply. Now layer these on top of each other. A Rogue moves away from an enemy creature. The enemy has an unused reaction, so it gets an Opportunity Attack — one melee attack (even if the attacker has multiattack property, mind me!). That attack enters Phase 1 (can the Rogue cast Shield?), then Phase 2 (Uncanny Dodge?), then after-damage effects (concentration check if the Rogue was concentrating on a spell). All of this happens mid-movement, before the Rogue reaches their destination. An ally Wizard casts Hold Monster on the Dragon. Phase 3: Counterspell window. The Dragon’s minion tries to counter it. A second ally counter-Counterspells. The stack pops: inner Counterspell resolves first, then the outer one. The spell goes through. The Dragon fails its save. Phase 4: Legendary Resistance. The Dragon burns a charge. Save flipped to success. After all of this resolves, where does the state machine go back to? It depends on what started it. An attack on your turn resumes the turn. An Opportunity Attack mid-movement resumes movement. An AoE spell moves to the next target. The damage resolution is identical in all cases — only the resume point differs. In the spec, each source carries its own return address as a tagged union: type AfterDamageReturn = | ADRActiveTurn | ADRResolvingAoE(AoESpellCtx) | ADRResolvingMovement(MovementCtx) | ADRAwaitingLegendaryAction(LAWindowCtx) | ADRAwaitingReadiedAction(ReadyWindowCtx) Every time the state machine enters an interrupt chain, it carries the return address with it. When the chain resolves — all reactions offered, all damage applied, all concentration checks done — it pattern-matches on that tag and resumes the correct phase. The XState machine mirrors the same type and the same pattern match: // battle-machine-types.ts type AfterDamageReturn = | { tag : "ADRActiveTurn" } | { tag : "ADRResolvingAoE" ; aoe : AoESpellCtx } | { tag : "ADRResolvingMovement" ; mv : MovementCtx } | { tag : "ADRAwaitingLegendaryAction" ; la : LAWindowCtx } | { tag : "ADRAwaitingReadiedAction" ; ready : ReadyWindowCtx } // battle-machine-helpers.ts function returnToState ( r : AfterDamageReturn ) : PhaseFields { return Match. value (r). pipe ( // effect/Match: exhaustive pattern matching byTag ( "ADRActiveTurn" , () => PHASE_ACTIVE ), byTag ( "ADRResolvingAoE" , ( v ) => phaseResolvingAoE (v.aoe)), byTag ( "ADRResolvingMovement" , ( v ) => phaseResolvingMovement (v.mv)), // ... Match.exhaustive // no default branch — compiler rejects missing variants ) } Quint pattern-matches on the tag to decide where to go next. XState does the same via Match.exhaustive — which also means adding a new return variant is a compile error until every call site handles it. MBT compares context after every step: if the XState machine routes to the wrong phase, the trace diverges and the seed is logged. This is why the formal spec matters. The interrupt interactions create a combinatorial state space. The Quint fuzzer explores it; unit tests cover the paths you thought of. The spec models all of this. But it doesn’t model everything . What’s Spec vs What’s DM The spec doesn’t model everything. It models everything mechanically deterministic — given the same inputs, there’s exactly one correct answer. Everything else enters the spec as caller-provided input. Dice rolls are the simplest case. The spec never generates random numbers. The caller passes rolls as arguments, already resolved. The spec proves that given any roll, the downstream mechanics are correct. Randomness is the caller’s problem. Cover works the same way. The spec receives it as a typed enum: NoCover | HalfCover | ThreeQuartersCover | TotalCover . It proves the AC and DEX save bonuses for each level. Whether that pillar constitutes half cover or three-quarters cover is DM’s call on geometry they use. Fun fact: traditionally, Dungeons and Dragons combat geometry is non-Euclidean. Opportunity attacks push this further. When a creature moves through a threatened area, the spec receives a set of threatening creatures as input, then tests: given any set, does the OA pipeline resolve correctly? It never asks “who’s actually within 5 feet?” — that’s spatial, a different problem entirely. The spec proves the