Skip to content

Deploy main to deploy - #426

Merged
dev-yunseong merged 65 commits into
deployfrom
main
Aug 19, 2026
Merged

Deploy main to deploy#426
dev-yunseong merged 65 commits into
deployfrom
main

Conversation

@dev-yunseong

Copy link
Copy Markdown
Collaborator

Promotes the current main branch to deploy.

dev-yunseong and others added 30 commits June 8, 2026 15:42
* feat(sql): split magic seed data

* feat(sql): extract component params

* feat(sql): split prefab params

Move shared shot/slime/drop/explode/build/field values to prefab-specific keys and seed them through a new SQL file.

* feat(sql): split parameter skill

* feat(chain-lightning): parameterize min damage

* feat(wind-blade): decay damage on pierce

* refactor(tide-call): use prefab parameters

* chore: remove rain_cloud

* feat(rallying-torch): add inspired buff

* fix(leafair): refresh timed lifetimes

* feat(frenzy-totem): parameterize buff

* refactor: tower에서 tower, dummy mob을 분리하도록 수정

* feature: tower back에 tower 기능을 추가하고 attack mob으로 근접 공격도 가능하도록 구현

* refactor: fire spirit의 burn 효과 제공 범위를 sub attack range로 분리

* feature magma spirit의 범위 burn provider 추가

* fix: tornado 안에 있는 mod들에게 데미지를 주는 로직 추가

* fix: item 기능을 고침
Keep production migrations in WordOnlineDatabase and retain only
test-specific SQL fixtures in the game server.

Closes #274
* feature(botPresona)

- 봇 페르소나 추가

* feature(scheduling)

- 봇 스케쥴링 구현

* feature(botmagics)

- 봇 마법 시전시 봇의 magic 파싱하게 수정

* Update WordOnlineLoop.java

- 시간 초과시 승패 체크
* refactor(effect): support multiple effects

* feat(card): add aura feedback
-태그 시스템에 폴백 추가
* refactor(coordinates): use y-up 3d axes

* refactor(coordinates): remove Vector2 ground plane type

* feature(magic): clamp cast position to map bounds before range

* refactor(field): introduce AbstractFieldPrefabInitializer
* feat(bot): link personas to bot users

* feat(debug): match practice with random bot
* feat(wall)

- 벽에 콜리젼을 추가함

* feature(newMagic)

- Vine World 마법 추가
- Seed Nest 마법 추가

* feature(evolve)

- 덩굴류 마법에 맞은 풀씨 정령이 덩굴 정령으로 진화하는 매커니즘 추가
- 소환하는 몹의 소환물 위치를 대상의 중심에서 대상이 최대 사거리 안이라면 대상 위치, 밖이라면 대상 방향의 최대사거리로 변경했습니다.
- 집결의 횃불 리워크
- 집결 매커니즘 변경 (대상 위치로 이동 -> 대상 위치로 이동 및 대상 주위 범위의 가능한 적 공격)
- 광란 상태이상 리워크
- 나 이외의 모든 대상을 공격합니다.
* 260403 deploy (#230)

* Update Docker CI to support multiple platforms

* Fix/228 (#229)

* fix(bug)

- pve boss 2스테이지 이후부터 소환 시 거리 제한이 있던 것 수정
- 공격 프리팹을 만들 때 시전자가 맞는 현상 수정

* fix(shot)

- 덩굴, 바람 칼날 자해 안함

* adjust(pve balance)

- pve 보스들의 소환 주기와 소환 수를 대폭 하향
- pve 보스 안나오고 사라지는 버그 수정

* feature(debugPVE)

- 디버그용 pve 엔드포인트 생성

* adjust(pve balance)

 - pve 보스들의 소환 주기와 소환 수를 대폭 하향

* Update PveVineWitchPrefabInitializer.java

---------

Co-authored-by: Yunseong Jeong <88422717+dev-yunseong@users.noreply.github.com>

* Feature/249 (#250) (#251)

* feautre(newmagic)

- 전기 폭발 추가
- 칼바람 회오리 추가

* Update razor_gale.sql

- 범위 수정

* Deploy main to deploy (#285)

* Feature/271 (#272)

* feat(sql): split magic seed data

* feat(sql): extract component params

* feat(sql): split prefab params

Move shared shot/slime/drop/explode/build/field values to prefab-specific keys and seed them through a new SQL file.

* feat(sql): split parameter skill

* feat(chain-lightning): parameterize min damage

* feat(wind-blade): decay damage on pierce

* refactor(tide-call): use prefab parameters

* chore: remove rain_cloud

* feat(rallying-torch): add inspired buff

* fix(leafair): refresh timed lifetimes

* feat(frenzy-totem): parameterize buff

* refactor: tower에서 tower, dummy mob을 분리하도록 수정

* feature: tower back에 tower 기능을 추가하고 attack mob으로 근접 공격도 가능하도록 구현

* refactor: fire spirit의 burn 효과 제공 범위를 sub attack range로 분리

* feature magma spirit의 범위 burn provider 추가

* fix: tornado 안에 있는 mod들에게 데미지를 주는 로직 추가

* fix: item 기능을 고침

* refactor(db): move SQL ownership (#275)

Keep production migrations in WordOnlineDatabase and retain only
test-specific SQL fixtures in the game server.

Closes #274

* feat: expose debug session list (#279)

* fix(deck): seed initial shuffle (#277)

* Feature/273 (#280)

* feature(botPresona)

- 봇 페르소나 추가

* feature(scheduling)

- 봇 스케쥴링 구현

* feature(botmagics)

- 봇 마법 시전시 봇의 magic 파싱하게 수정

* Update WordOnlineLoop.java

- 시간 초과시 승패 체크

* fix(gameplay): repair magic behavior regressions (#282)

* 카드 선택 아우라 피드백 추가 (#284)

* refactor(effect): support multiple effects

* feat(card): add aura feedback

* fix(error)

-태그 시스템에 폴백 추가

---------

Co-authored-by: Monolong <83206119+Monolong@users.noreply.github.com>

* fix: reconcile deploy with main (#297)

* Feature/271 (#272)

* feat(sql): split magic seed data

* feat(sql): extract component params

* feat(sql): split prefab params

Move shared shot/slime/drop/explode/build/field values to prefab-specific keys and seed them through a new SQL file.

* feat(sql): split parameter skill

* feat(chain-lightning): parameterize min damage

* feat(wind-blade): decay damage on pierce

* refactor(tide-call): use prefab parameters

* chore: remove rain_cloud

* feat(rallying-torch): add inspired buff

* fix(leafair): refresh timed lifetimes

* feat(frenzy-totem): parameterize buff

* refactor: tower에서 tower, dummy mob을 분리하도록 수정

* feature: tower back에 tower 기능을 추가하고 attack mob으로 근접 공격도 가능하도록 구현

* refactor: fire spirit의 burn 효과 제공 범위를 sub attack range로 분리

* feature magma spirit의 범위 burn provider 추가

* fix: tornado 안에 있는 mod들에게 데미지를 주는 로직 추가

* fix: item 기능을 고침

* refactor(db): move SQL ownership (#275)

Keep production migrations in WordOnlineDatabase and retain only
test-specific SQL fixtures in the game server.

Closes #274

* feat: expose debug session list (#279)

* fix(deck): seed initial shuffle (#277)

* Feature/273 (#280)

* feature(botPresona)

- 봇 페르소나 추가

* feature(scheduling)

- 봇 스케쥴링 구현

* feature(botmagics)

- 봇 마법 시전시 봇의 magic 파싱하게 수정

* Update WordOnlineLoop.java

- 시간 초과시 승패 체크

* fix(gameplay): repair magic behavior regressions (#282)

* 카드 선택 아우라 피드백 추가 (#284)

* refactor(effect): support multiple effects

* feat(card): add aura feedback

* fix(error)

-태그 시스템에 폴백 추가

* 서버 좌표계를 Unity 3D 기준으로 전환 (#287)

* refactor(coordinates): use y-up 3d axes

* refactor(coordinates): remove Vector2 ground plane type

* feature(magic): clamp cast position to map bounds before range

* refactor(field): introduce AbstractFieldPrefabInitializer

* feat(bot): 봇 사용자 연동 및 Debug 랜덤 연습 대전 (#289)

* feat(bot): link personas to bot users

* feat(debug): match practice with random bot

---------

Co-authored-by: Monolong <83206119+Monolong@users.noreply.github.com>

* Deploy main to deploy (#295)

* Feature/271 (#272)

* feat(sql): split magic seed data

* feat(sql): extract component params

* feat(sql): split prefab params

Move shared shot/slime/drop/explode/build/field values to prefab-specific keys and seed them through a new SQL file.

* feat(sql): split parameter skill

* feat(chain-lightning): parameterize min damage

* feat(wind-blade): decay damage on pierce

* refactor(tide-call): use prefab parameters

* chore: remove rain_cloud

* feat(rallying-torch): add inspired buff

* fix(leafair): refresh timed lifetimes

* feat(frenzy-totem): parameterize buff

* refactor: tower에서 tower, dummy mob을 분리하도록 수정

* feature: tower back에 tower 기능을 추가하고 attack mob으로 근접 공격도 가능하도록 구현

* refactor: fire spirit의 burn 효과 제공 범위를 sub attack range로 분리

* feature magma spirit의 범위 burn provider 추가

* fix: tornado 안에 있는 mod들에게 데미지를 주는 로직 추가

* fix: item 기능을 고침

* refactor(db): move SQL ownership (#275)

Keep production migrations in WordOnlineDatabase and retain only
test-specific SQL fixtures in the game server.

Closes #274

* feat: expose debug session list (#279)

* fix(deck): seed initial shuffle (#277)

* Feature/273 (#280)

* feature(botPresona)

- 봇 페르소나 추가

* feature(scheduling)

- 봇 스케쥴링 구현

* feature(botmagics)

- 봇 마법 시전시 봇의 magic 파싱하게 수정

* Update WordOnlineLoop.java

- 시간 초과시 승패 체크

* fix(gameplay): repair magic behavior regressions (#282)

* 카드 선택 아우라 피드백 추가 (#284)

* refactor(effect): support multiple effects

* feat(card): add aura feedback

* fix(error)

-태그 시스템에 폴백 추가

* 서버 좌표계를 Unity 3D 기준으로 전환 (#287)

* refactor(coordinates): use y-up 3d axes

* refactor(coordinates): remove Vector2 ground plane type

* feature(magic): clamp cast position to map bounds before range

* refactor(field): introduce AbstractFieldPrefabInitializer

* feat(bot): 봇 사용자 연동 및 Debug 랜덤 연습 대전 (#289)

* feat(bot): link personas to bot users

* feat(debug): match practice with random bot

---------

Co-authored-by: Monolong <83206119+Monolong@users.noreply.github.com>

---------

Co-authored-by: Monolong <83206119+Monolong@users.noreply.github.com>
* feat: block paths with dedicated rock remnants

Rock creatures and rock turrets that die in combat now leave a
RockRemnant instead of a live MiniRock: no movement, no attack, no hp,
just a solid collider that expires after 20 seconds.

StaticObstacle is needed because PhysicSystem only pairs objects that
own a Collidable component, so a bare collider would never block.

Closes #308

* feat: leave rock remnants for structures and rock units

Extends the remnant allowlist to every cannon, tower, turret and
towerback plus MiniRock and RockDrop, so any of them destroyed in combat
blocks the ground it stood on.

TTL expiry still leaves nothing, which is what most towers do when
nobody kills them.
* fix: let aerial mobs engage targets they hover over

Engagement was measured in 3D while mobs path on the ground plane, so a
mob holding a hover height of three units could never satisfy its attack
range against a ground target and parked above it instead.

Range is now measured horizontally and checked before the path arrival
bookkeeping, which used to drop a mob back to idle on the frame it
reached the last path point.

Also stops ThunderBird from diving one more frame at a destroyed target,
and replaces the dive interpolation with a shared helper that no longer
divides by a zero altitude gap.

Closes #314

* feat: 바람 정령 지상 유닛 충돌 확장 (DEV-41) (#313)

* feat: let wind spirits dive at ground units too

WindSpirit now detects with TargetMask.ANY. Diving into ground crowds
made the untargeted parts of the dive visible, so target selection and
the explosion now skip self, allies and objects that are destroyed or
still initializing.

Closes #310

* fix: let wind spirits commit their dive on ground targets

BehaviorMob measures target distance in 3D, so a mob hovering three
units up never satisfied a 0.6 trigger distance and parked above ground
targets instead of diving. The trigger distance now includes the
altitude the dive has to cover.

* Revert "fix: let wind spirits commit their dive on ground targets"

This reverts commit 2608df0.
Magic execution ran on the STOMP inbound thread (and, for bots, on the bot
executor thread) while the loop thread structurally modified the same
unsynchronized lists (gameObjects, gameObjectsToAdd, createdObjectDtos,
updatedObjectDtos), causing ConcurrentModificationException, dropped
summons and half-paid casts. The loop now holds the GameContext monitor
for the duration of a frame and both off-loop input entry points take the
same monitor, so payment and effect stay atomic against a frame.

Closes #317

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Collider cached the RigidBody in its constructor, but prefab initializers
that register the RigidBody through the deferred addComponent queue build
the collider before that queue is flushed. Those colliders permanently
reported invMass 0 and zero velocity, so PhysicSystem produced no
separation impulse and the units stacked or acted as immovable walls.
Resolving the RigidBody on each read fixes every collider subclass at once.

Closes #318

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
GameObject.start() called flushComponents() and then looped over
components, so anything registered through addComponent() was started
twice — OnStartAttacker dealt its AoE damage twice and BehaviorMob
pushed duplicate gizmos. Merge the pending queue into components before
the single start loop instead, which also keeps the start order the same
as the prefab's registration order.

Closes #319

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
PlayerData.mana was a plain int read-modify-written by the game loop thread
in ManaCharger.chargeMana and by STOMP inbound threads in useCards and
handleBotMagicInput, with useCards doing an unguarded check-then-act. Two
concurrent casts could both pass validation against the same balance, and a
concurrent charge could overwrite a spend, letting a player gain mana by
casting or overdraw into negative mana. All mutators now synchronize on the
owning PlayerData and mana is volatile for the plain reads elsewhere.

Closes #321

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The WindSpirit only targets air mobs, which all hover at the same
height, so the dive interpolation always divided by a zero altitude
delta. That produced a NaN position that the out-of-bounds guard does
not catch, leaving the mob stuck and unable to ever reach or damage its
target. Same-altitude intercepts now home straight at the target.

Closes #320

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat: block paths with dedicated rock remnants

Rock creatures and rock turrets that die in combat now leave a
RockRemnant instead of a live MiniRock: no movement, no attack, no hp,
just a solid collider that expires after 20 seconds.

StaticObstacle is needed because PhysicSystem only pairs objects that
own a Collidable component, so a bare collider would never block.

Closes #308

* feat: let aerial units fall before they die

Aerial mobs vanished mid-air the instant their HP hit zero, so the client faked
the fall with a throwaway sprite clone: its position was not authoritative and
the fall distance was a fixed guess that missed the ground on anything but a
standard cruise height.

Death is now a server-side state. An aerial mob at zero HP enters Status.Dying,
releases its hover so PhysicSystem's gravity takes over, and is only removed
once it reaches the ground. While dying it takes no further damage, is dropped
by every target detector, ticks no components and is skipped by collision, so
the fall is purely cosmetic in gameplay terms while the position stays real.

Aerial units are identified through ZPhysics.isAerial() -- the hover constructor
was already the de facto flag -- so this needs no new unit data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: explode self-destruct mobs where they die, not where they land

An aerial self-destruct mob now entered the dying fall like any other, so its
blast went off at the landing point instead of the point of impact -- a real
balance change for WindSpirit, which is the whole reason it is aerial.

Death that happens at a point opts out of the fall through Mob.fallsOnDeath(),
which SelfDestructMob overrides. Keeping the opt-out on Mob rather than
type-checking inside AerialDeathFall leaves the fall unaware of any particular
mob class, the same way isActiveWhileDying() works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A frame that threw only logged and broke out of the loop, so handleGameEnd()
never ran: no ResultDto reached either client, MMR was not updated, and
markOnline() was never called, leaving both users stuck flagged in-game.
The catch block now forces a terminal result and finalizes the match, guarded
by _running so a frame that throws after the match already ended does not
broadcast or update ratings twice.

Closes #323

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
dev-yunseong and others added 28 commits August 6, 2026 17:52
findById always wrapped a scenario record, so an out-of-range scenarioId
produced an empty-but-present scenario instead of failing fast: no boss or
objective spawned, objectiveIds stayed empty, and PveResultChecker could
never mark the match cleared. Treating "no installer rows" as absent makes
PveScenarioRegistry's orElseThrow reachable again.

Closes #331

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
saveGameResult writes statistic_games plus card, magic and update-time
child rows through four separate JdbcClient statements. It runs on the
game loop thread via SessionService.onLoopTerminated, so no transaction
was bound and every statement autocommitted on its own: a mid-way
failure left a statistic_games row with truncated children and no retry
or cleanup, silently under-counting downstream aggregates.

Closes #330

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Spring's proxy transaction advice only applies to public methods, so the
package-private updateMatchResult ran with no transaction and committed each
rating UPDATE in its own autocommit, letting a failure on the second write
leave the ladder inflated. Making it public lets the class-level
@transactional wrap both writes; the nested resolveRatingRef call is
self-invocation and simply joins the same transaction.

Closes #328

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
KnockbackStatusEffect never sets a speed modifier in start(), but its
expire() zeroed the shared Stat.modifierPercent that Snared/Slow own, so
a knockback landing during a snare cancelled the remaining slow while the
Snared visual stayed on. Drop the reset and keep only the ZPhysics
impulse revert, which now matches start()'s unconditional impulse.

Closes #326

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A finished or unknown session made isSessionActive dereference null, so the
lobby poll that observes the transition returned 500 instead of false. Session
teardown also leaked the GameResultBuilder on draws and crash exits, and let a
statistics write failure skip scenario completion and win counting.

Closes #322
Closes #327
Closes #329

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Actuator serves only health and prometheus, bound to MANAGEMENT_PORT
(default 8081), so the public app port never serves /actuator. Metrics
carry an application tag so Prometheus can tell the servers apart.

The management context is not covered by the app SecurityFilterChain,
so 8081 must be firewalled to the Prometheus host only.

Refs #363

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(server): publish heartbeat and session capacity

* feat(session): expose ready session contract (#362)

* refactor(server): bind server identity via ConfigurationProperties

AGENTS.md now forbids injecting individual settings with @value. This branch
added server.max-sessions, so ServerStatusService moves its whole server.*
group to a constructor-injected ServerIdentityProperties record and its test
drops ReflectionTestUtils. Defaults now live only in application.yml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
game과 lobby가 각자의 DTO로만 계약을 검증하고 있어서, 필드명이나 중첩이
어긋나도 양쪽 스위트가 모두 통과했다.

계약이 깨져도 예외가 나지 않는다. lobby는 attemptId가 안 맞는 응답을
거절로 판정하고 다음 후보로 넘어가므로, 증상이 "모든 서버가 거절"과
503으로만 나타나고 로그에 원인이 남지 않는다.

WordOnlineMatching과 같은 경로·같은 내용의 JSON 픽스처를 두고 양쪽이
각자 검증한다. 한쪽만 필드를 바꾸면 반대쪽 테스트가 깨진다.

이 저장소의 첫 ObjectMapper 기반 테스트다.
* feat(frame): add event list with hit events

Frame info now carries a per-frame event list so single moments -- not just
state -- reach the client. First entry: hit, so the client knows who struck
whom and can place the hit effect on the struck side.

Attacker id rides on AttackInfo and the event is emitted where damage
actually lands, so any attack path opts in by setting the id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feature(frame): widen hit event coverage

The event list only fired for melee AttackMob and was dropped on sync
frames, so most damage reached the client without an attacker.

- AttackInfo.withAttacker(GameObject) reads better at the call sites, which
  are mostly inline expressions inside forEach/onDamaged
- attach the attacker at the 27 damage sources that have one: melee, ranged,
  towers, self-destruct and magic. Magic is its own actor; visual-only
  projectiles report the unit that fired them
- fall damage, damage-over-time, snare removal, overcharge, the summoning
  circle's self damage and heals stay without an attacker, so they emit
  nothing
- SyncInfoDto carries the events of the frame it replaces; sync goes out
  every tenth frame and was silently swallowing them

Refs #356

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feature(parameter)

- 마나 1초당 5 -> 4 회복
- 소환 마법 패러미터 수정

* feature(golem)

- 골렘 공격에 넉백 추가

* feature(clouddragon)

- 운룡 변경
- 1초당 1번 공격, 데미지 5
- 15초 쿨타임 가지는 체인 라이트닝 공격
- 벽 수정

* feature(lightningdrop)

- 낙뢰 마법 변경
- 낙뢰를 3회 소환하는 구름 소환
- 시전 즉시, 2초후, 4초후 낙뢰를 소환
- 낙뢰는 전기 데미지를 주고, 아군 전기 속성 유닛이 맞을 시 과충전 상태로 진입. 전기 필드를 생성.
-

* feature(windblade)

- 바람 칼날 넉백 제거

* feature(knockback)

- 넉백류 마법 개선
- 파도 소환과 바람 토템의 매커니즘을 갱신형 push로 변경
- 모든 강제 이동의 거리를 mass에 반비례해서 설정

* fix(wall)
Sessions live only in SessionService's in-memory map, so a restart drops
every one of them silently while the lobby's match tickets stay stuck in
MATCHED. The restarted process answers on the same domain and port and
passes health checks, so the lobby cannot tell a brief outage from a
restart that lost everything.

Publish a per-process UUID: returned on the session readiness response so
the lobby can pin it to the ticket, and written to the servers row on boot
and on every heartbeat so the row always names the live process. One
comparison then settles whether the session still exists.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The lobby keeps match tickets in Redis, so a session that finished here left the
ticket in MATCHED and silently swallowed the player's next queue attempt. DB
state was not enough: only an HTTP call closes the ticket.

Adds this repository's first outbound lobby call. Credentials mirror the lobby's
own convention because this server holds only JWT_PUBLIC_KEY and cannot mint a
token: a pre-issued token is read from a configured path, cached on success so a
file mounted after boot still gets picked up.

Notification failure never breaks teardown. Attempts are bounded and short, 4xx
is not retried, and an unset base URL or token path degrades to a log line, since
the lobby's reconciler is the safety net for a lost notification while a stuck
teardown has none.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
lobby and admin already read the same AccountServer-issued server token
from JWT_FILE_PATH. A second variable for the same file would have implied
the game host needs its own token minted, when the deployed one already
fits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs #379

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(bot): make the self-play game count configurable

The auto-match scheduler created a single bot game only while the server was
completely idle, which is not enough to put the session layer under load.

Replace the "skip if any session is active" check with a target count: every
check interval the scheduler tops the server back up to `bot.auto-match.
target-games` sessions. The default of 1 reproduces the previous behaviour
exactly (skip when active >= 1, otherwise create one game), so nothing changes
unless the knob is raised.

Both the target and the check interval are now bound to environment variables
so a synthetic load can be dialled in without a rebuild, which is what the
upcoming game-loop scheduling work needs in order to be measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018bCNcTUnk6eeaAY98M1CRY

* refactor(bot): bind auto-match settings with properties object

AGENTS.md requires grouped @ConfigurationProperties over per-field
@value, so tests can inject settings without reflection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
UpdateTimeAspect measures how long each GameSystem takes to run. That is
CPU work: a frame that starts late still reports the same per-system
durations, so a loop dropping from 20 FPS to 10 FPS leaves every existing
metric unchanged. Nothing recorded the interval between frames.

Add FrameIntervalAspect, which advises FrameDataSystem.earlyUpdate - the
first statement of WordOnlineLoop.update(), so it runs exactly once per
frame - and records the gap since the previous frame under the name
"Frame". The loop's own update() is package-private and cannot be advised
by Spring AOP, and routing this through an aspect keeps the game package
free of any dependency on statistic.

The previous timestamp lives on GameResultBuilder rather than in a map
owned by the aspect, so it is discarded together with the builder when
saveGameResult removes it instead of leaking an entry per game played.

Supporting changes:

- Widen UpdateTimeStatistic min/max and addInterval to long. The old int
  cast capped intervals at ~2.147s, which never mattered for microsecond
  per-system durations but would corrupt exactly the stalled frames worth
  measuring.
- Key the statistic map by String instead of Class<? extends GameSystem>,
  since a frame-interval entry is not a GameSystem. StatisticService keeps
  its Class parameter and resolves the simple name internally, so rows
  already written for each system keep an identical name value and no
  schema change is needed.
- Add the statistic_update_time definition to the test schema. It existed
  nowhere in the repository except the INSERT statement, so the
  persistence path could not be tested at all.


Claude-Session: https://claude.ai/code/session_018bCNcTUnk6eeaAY98M1CRY

Co-authored-by: Claude <noreply@anthropic.com>
is_running() backed a field initialised to true at declaration, and the session
was registered before Thread.start(), so a session counted as active before its
loop ran. An Error escaping update() also skipped onTerminated, leaving the
session in the registry advertising itself as alive forever. The loop now flips
to RUNNING from inside its own thread and back in a finally block, and creation
waits for the first tick before reporting the session ready.

The list order was ConcurrentHashMap bucket order over random UUID keys, so
"the room near the top" meant nothing reproducible. Sessions now carry their
creation instant, the list is ordered oldest first, and RoomInfoDto exposes the
instant so the admin page can tell a fresh session from a long-running one.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The scheduler resolves its target on every tick: servers.target_bot_sessions
first, bot.auto-match.target-games when the override is NULL or the read
fails. The entity maps the column read-only so the whole-row heartbeat save
cannot write back a stale value and undo an admin change.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
A session existed only in the in-memory map and its statistics were
written only from the loop's finally path, so a deadlocked loop left
no trace while both players stayed stuck. Insert a lifecycle row at
session start, sweep stalled loops from a scheduled watchdog that
flushes abandoned statistics with the loop thread's stack trace, and
record draws instead of dropping them.

Closes #400

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
BotBrain costed a recipe by its magic card alone, but PlayerData.useCards
charges the mana_cost of every card in it. A [Shoot, Fire, Rock] cast really
costs 35 and the bot believed it cost 15, so it queued casts it could not pay
for, the input handler rejected them with FAIL_LACK_OF_CARD, and the same
judgement repeated every reaction cycle until mana caught up.

Move the cost and range lookups into BotSpellStats, which sums the whole
recipe and treats an unpriced card as unaffordable rather than free.

Classify Drop as offensive alongside Shoot and Explode: it is a ranged area
attack, so dropping it on a random point in the half-disc facing the enemy
wasted the cast.

Lower the per-tick and per-action bot logs from info to debug; they buried
everything else in the log while a bot game was running.

Closes #390

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…d lane (#393)

* fix(bot): charge every card in the recipe when costing a cast

BotBrain costed a recipe by its magic card alone, but PlayerData.useCards
charges the mana_cost of every card in it. A [Shoot, Fire, Rock] cast really
costs 35 and the bot believed it cost 15, so it queued casts it could not pay
for, the input handler rejected them with FAIL_LACK_OF_CARD, and the same
judgement repeated every reaction cycle until mana caught up.

Move the cost and range lookups into BotSpellStats, which sums the whole
recipe and treats an unpriced card as unaffordable rather than free.

Classify Drop as offensive alongside Shoot and Explode: it is a ranged area
attack, so dropping it on a random point in the half-disc facing the enemy
wasted the cast.

Lower the per-tick and per-action bot logs from info to debug; they buried
everything else in the log while a bot game was running.

Closes #390

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feature(bot): score casts by converted damage and defend the contested lane

The bot ranked candidates by combo size and a flat per-tier bonus, aimed every
offensive spell at the nearest enemy, and dropped every summon on a random
point in the half-disc facing the enemy. It never assessed what was walking at
it, never used the blast radius, never checked whether a target was already
almost dead, and effectively never attacked the enemy player, which is the win
condition.

Introduce three focused pieces the brain composes:

- ThreatAssessment snapshots targetable enemies with their remaining hit
  points, reports how hard the bot is being pushed, and reports which lane the
  pressure sits in.
- BlastTargetSelector treats every reachable enemy position as a candidate
  blast centre and picks the one converting the most damage, capped at each
  target's remaining hit points and weighted up on the enemy player.
- PlacementPlanner puts summons in the contested lane, intercepting when the
  bot is pushed and advancing when the field is clear.

Scoring is now value per real mana cost, so offence and placement compare on
one scale, and BotEye exposes the enemy player's hit points so the core can be
valued honestly.

Replace the per-tier score bonus with a per-tier exploration rate. The bonus
was added equally to every candidate, so it could never change the pick and
the tiers played identically; discarding the ranking part of the time is what
actually separates an INTRO opponent from an ELITE one.

Damage and radius are read per card type, so they approximate the spell that
finally spawns. Resolving the concrete magic's own parameters needs a
magic_id reverse index and is left out of this change.

Closes #391

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
… queue (#402)

Off-loop threads no longer mutate game state. The STOMP inbound threads, the
bot executor and the ping timeout scheduler queue their work on a per-session
GameActionQueue, and the loop thread drains it at the top of every frame. That
makes the loop thread the only writer, so the session-wide monitor and the
per-method locks it forced onto PlayerData come out.

It also closes a hole the monitor never covered: selectCard, unselectCard and
cancelCard mutated components on a live GameObject from the inbound thread
without holding it at all.

Bot reads stay as they are - BotEye and BotBrain still observe live objects off
the loop thread, so PlayerData.mana stays volatile, cards stays a synchronized
list, and gameObjects stays copy-on-write until #399.

Closes #398

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* refactor(game): serialize input and bot actions through a loop action queue

Off-loop threads no longer mutate game state. The STOMP inbound threads, the
bot executor and the ping timeout scheduler queue their work on a per-session
GameActionQueue, and the loop thread drains it at the top of every frame. That
makes the loop thread the only writer, so the session-wide monitor and the
per-method locks it forced onto PlayerData come out.

It also closes a hole the monitor never covered: selectCard, unselectCard and
cancelCard mutated components on a live GameObject from the inbound thread
without holding it at all.

Bot reads stay as they are - BotEye and BotBrain still observe live objects off
the loop thread, so PlayerData.mana stays volatile, cards stays a synchronized
list, and gameObjects stays copy-on-write until #399.

Closes #398

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(bot): give bot threads an immutable frame snapshot

BotEye copied the game object list but not the objects in it, so the bot
executor thread walked live GameObjects while the loop thread moved them. The
copy-on-write list, BotEye's synchronized block and PlayerData's volatile mana
never closed that - they guarded the container, not the state.

BotEye is now an immutable record taken by the loop thread in BotAgentSystem
and handed to the executor, carrying its own Vector3 copies. BotBrain reads it
instead of GameObject and takes Parameters directly rather than the GameLoop.

With no off-loop reader left, gameObjects goes back to a plain ArrayList,
PlayerData holds plain fields, BotAgent drops its synchronized in favour of a
volatile pendingDecision, and the debug endpoint's deck reset goes through the
action queue like every other off-loop write. No synchronized remains anywhere
under the game package.

Closes #399

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A loop that is too slow to play is invisible to everything the server
already watches. The reaping watchdog only fires once a loop stops
completing frames entirely, and the frame rate itself was not measured
anywhere, so an overloaded box degraded silently until someone looked.

The rate is taken in the watchdog's existing sweep rather than in a
second schedule, and it counts the frame currently in flight once that
one has outrun the last completed frame - otherwise a loop falling
behind keeps reporting the comfortable rate it managed before.

Alerting is per episode, not per session per sweep: a server past its
capacity has dozens of slow loops every few seconds, which would be
several hundred webhook posts a minute and unreadable. An episode opens
with a summary naming the three slowest sessions, repeats at most once
per cooldown, and closes with a recovery line. Delivery is off the
sweep thread through a bounded queue, so a slow or dead webhook drops
alerts instead of stalling the watchdog.

The webhook is the same DISCORD_WEBHOOK_URL the deployments already
set; leaving it unset disables alerting.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
- y 위치 10 -> 2
…#425)

The interval was a hardcoded 10s constant, and the toad spawned nothing
for a full interval after being summoned. Read spawn_interval from the
game object parameters and emit the first tadpole on the summon tick.

LimitedSequenceSpawner keeps its previous behavior for existing callers;
the immediate first spawn is opt-in through a new constructor flag.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@dev-yunseong
dev-yunseong merged commit a284d10 into deploy Aug 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants