Skip to content

refactor(bot): 콤보 로직을 선언적 룰 엔진으로 - #468

Draft
dev-yunseong wants to merge 2 commits into
mainfrom
refactor/466
Draft

refactor(bot): 콤보 로직을 선언적 룰 엔진으로#468
dev-yunseong wants to merge 2 commits into
mainfrom
refactor/466

Conversation

@dev-yunseong

Copy link
Copy Markdown
Collaborator

Closes #466

무엇을

BotBrain 에 하드코딩돼 있던 두 콤보 전술(findSeedSpiritCombo, findMobClusterCombo)을 선언형 룰로 뽑아냈다.

두 메서드는 각자 같은 발판을 다시 세우고 있었다. 손패 필터링, 레시피 가격 계산, 대상 찾기, 점수 매기기. 정작 전술 자체는 그 발판에 파묻혀 있어서, 새 전술 하나를 추가하려면 같은 발판을 세 번째로 쓰는 수밖에 없었다.

이제 둘 다 BotRuleBook 의 문장이다.

rule(SEED_SPIRIT_RULE_ID)
        .priority(100)
        .tierAtLeast(BotTier.INTRO)
        .castingMagic(VineTossMagic.class, OvergrowthMagic.class)
        .when(ally(PrefabType.SeedSpirit).inCastRange())
        .at(matchedTarget())
        .scoring(ONE_PER_MANA);

rule(MOB_CLUSTER_RULE_ID)
        .priority(90)
        .tierAtLeast(BotTier.INTRO)
        .castingMagic(AbstractExplosionMagic.class)
        .when(enemyCluster().minSize(CLUSTER_MIN_MOBS).inCastRange())
        .at(clusterCenter())
        .scoring(clusterSizePerMana());

설계의 핵심

조건은 boolean 이 아니라 매칭된 대상을 반환한다. BotCondition.matchOptional<ConditionMatch> 를 내고, 거기 담긴 객체나 클러스터 중심점이 그대로 조준점이 된다. 이게 없으면 "씨앗정령이 근처에 있다" 를 "그 씨앗정령에게 시전한다" 로 이을 수가 없다.

엔진은 우선순위가 가장 높은, 매칭에 성공한 한 룰의 모든 후보를 돌려주고 승자는 고르지 않는다. 티어별 난수 섭동은 BotBrain 에 남아 후보마다 정확히 한 번 적용된다 — BotBrainComboTest.appliesTierNoiseExactlyOncePerScoredCandidateRandom.nextDouble() 호출 횟수를 못박고 있다. 엔진이 승자를 골랐다면 그 노이즈가 경계 반대편으로 넘어가거나 사라졌을 것이다.

구조

BotBrain.think()
  1. BotWorldViewFactory.build(...)   팩트 레이어 (신규)
  2. BotRuleEngine.evaluate(...)      룰 매칭 (신규)
  3. 미매칭 -> 기존 값 스코어러        그대로

룰이 하나도 안 맞으면 기존 스코어러가 예전과 똑같이 돈다.

관측 데이터 확장

  • 접근/후퇴: RigidBody 는 매 프레임 적분 직후 velocity.clear() 를 하므로 스냅샷의 속도는 사실상 항상 0이다. 이전 think 패스와 위치를 차분하는 것이 유일하게 유효한 측정이다 (BotMemory).
  • 공격 중: BotVisibleObjectStatus 추가. 루프 스레드가 이미 객체를 쥐고 복사하므로 비용이 없다.
  • 뭉쳐있음: ClusterFinder 가 기존 2패스 중심점 알고리즘을 그대로 이식했다. 앵커 주변을 모으고, 그 중심점 주변으로 다시 고르는 두 번째 패스가 조준점을 무리의 중심으로 만든다.
  • 태그: TagRepository.getGameObjectTags 는 호출마다 DB 를 친다. PrefabType 은 유한 enum 이라 시작 시 전량 워밍업한다 (GameObjectTagService).

행동 변화

두 가지 있다.

  1. 접대 봇의 콤보 제외가 if (hospitalityDirector == null) 에서 각 룰의 티어 게이트로 옮겨갔다. HOSPITALITYBotTier 에서 INTRO 아래라 티어 게이트가 걸린 룰은 자동으로 접대 봇을 배제한다.
  2. 클러스터 전술이 이제 모든 무리를 후보로 본다(예전엔 최선 하나). 크기가 같은 무리끼리 동점일 때 예전에는 가까운 쪽이 이겼고, 이제는 티어 노이즈가 가른다.

테스트

./gradlew test462개 통과, 실패 0.

  • BotBrainComboTest 가 동등성 하네스다. 룰 아이디, 카드, 조준점, 이유 문자열까지 리팩터링 전과 동일하게 유지된다.
  • 신규: ClusterFinderTest(6), BotMemoryTest(9), BotWorldViewFactoryTest(9), BotConditionTest(23), AimSpecTest(6), PriorityBotRuleEngineTest(14), ExistingTacticsInRuleDslTest(5).
  • PriorityBotRuleEngineTest 는 엔진이 난수를 전혀 소비하지 않음을 확인한다(50회 평가 결과 동일).

게임플레이 영향

ruleIdreasonBotThoughtInfoDto 로 클라이언트에 방송되므로 프로토콜의 일부다. 기존 문자열 combo.seed-spirit, combo.mob-cluster 를 그대로 유지했다.

GameObjectTagService 가 앱 시작 시 태그 테이블을 워밍업한다. 실패해도 게임은 뜨고, 봇이 태그 없이 점수를 매긴다(BotCounterEvaluator 와 같은 방식).

버전 0.9.0 -> 0.10.0 (MINOR, 런타임 동작 변경).

BotVisibleObject carries Status so a rule can ask whether something is
attacking; the loop thread already holds the object when it copies the
snapshot, so this costs nothing.

RecipeOption, RuleOutcome and the BotRuleEngine interface fix the shape
of the rule layer before either half of it exists. RuleOutcome stops
short of being a decision on purpose: the per-candidate tier noise stays
in BotBrain, where it is applied exactly once per candidate today.

RecipeMatcher extracts the hand-can-make-it check that three decision
sites had each open-coded.
findSeedSpiritCombo and findMobClusterCombo were two bespoke methods on
the brain that each rebuilt the same scaffolding: filter the hand, price
the recipe, find a target, score it. The tactic itself was buried in
that scaffolding, so a new one meant writing a third copy of it.

Both are now sentences in BotRuleBook:

    rule(SEED_SPIRIT_RULE_ID)
        .castingMagic(VineTossMagic.class, OvergrowthMagic.class)
        .when(ally(PrefabType.SeedSpirit).inCastRange())
        .at(matchedTarget())
        .scoring(ONE_PER_MANA)

A condition returns the objects it matched rather than a boolean, which
is what lets the aim point be the thing the condition found - without it
'a seed spirit is nearby' cannot become 'cast at that seed spirit'.

The engine returns every candidate of the highest-priority matching rule
and picks no winner: the tier noise stays in BotBrain, applied exactly
once per candidate, as BotBrainComboTest asserts.

Two behaviour notes. Rules are gated at INTRO or above, which is how the
hospitality bot is now excluded from combos - it used to be an if on the
director being null. And the cluster tactic now sees every crowd rather
than only the best one, so a tie between equally sized crowds is broken
by the tier noise instead of by proximity.

Refs #466
@dev-yunseong dev-yunseong self-assigned this Aug 25, 2026
@dev-yunseong
dev-yunseong marked this pull request as draft August 25, 2026 05:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor(bot): 콤보 로직을 선언적 룰 엔진으로

1 participant