Skip to content

fix: gate solo sessions and report cancel failure - #75

Draft
dev-yunseong wants to merge 1 commit into
mainfrom
fix/62
Draft

fix: gate solo sessions and report cancel failure#75
dev-yunseong wants to merge 1 commit into
mainfrom
fix/62

Conversation

@dev-yunseong

@dev-yunseong dev-yunseong commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

요약

PVE/연습 세션 진입에 덱 검증과 대기중/플레이중 상태 검사를 추가하고, PVE는 해당 시나리오가 해금(ACTIVE/FINISHED)되어 있는지 서버에서 검증한 뒤에만 세션을 만든다. 대기열 취소는 실제 ZREM 개수를 확인해 이미 매칭된 경우 409를 반환하며 markOnline을 건너뛴다. 스케줄러 틱마다 최대 20쌍까지 반복 매칭한다.

대상 이슈

Closes #62
Closes #63
Closes #65
Closes #66

검증

Ran ./gradlew build in the worktree: compileJava/compileKotlin/compileTestJava/compileTestKotlin/bootJar all succeeded; :test reported "30 tests completed, 3 failed". The 3 failures are DataControllerTest (x2) and CardControllerTest — @SpringBootTest context load failures (ConfigurationPropertiesBindException -> NumberFormatException, missing env like PORT/DB). Confirmed pre-existing by git stash -u and re-running ./gradlew test on clean origin/main state: same 3 failures, BUILD FAILED; then git stash pop. My new tests: ./gradlew test --tests com.wordonline.matching.matching.service.GameMatchServiceTest -> BUILD SUCCESSFUL, XML result tests="5" failures="0" errors="0". Committed 58958bc and pushed fix/62 to origin (new branch, push output confirmed).

테스트

— 5 plain Mockito/JUnit5 tests (new src/test/kotlin source dir, compiled by the existing kotlin-jvm plugin; no new dependencies): PVE rejected when scenario locked, practice rejected when OnMatching, practice rejected without valid deck, removeFromQueue false + no markOnline when ZREM count 0, true + markOnline when count 1. The tryMatching loop is not unit-tested (needs live Redis + game server).

리뷰어 참고

Root-cause placement: the unlock check lives in AdventureService.isScenarioUnlocked (runs the same idempotent activation the GET /api/adventures read path runs, then reads the user_scenarios row), so a brand-new user who never fetched /api/adventures is not falsely blocked on scenario 1, and any future PVE caller routes through the same gate. New derived query UserScenarioRepository.findByUserIdAndScenarioId.

#65 busy check reuses the existing UserService.getStatus, which already folds isInQueue + sessionRecoveryStore + live-room lookup into OnMatching/OnPlaying — no new duplicate logic. Side effect worth reviewing: practice/PVE start now depends on the game-server room-list call inside getStatus succeeding; if the game server is unreachable the request fails, but createSession would fail anyway. Deck validation reuses the existing validateSelectedDeck (still IllegalStateException -> 500 via GlobalExceptionHandler, same as the PVP path today; I did not change that mapping). New rejections use IllegalArgumentException -> 400.

#63: MatchingQueueRepository.remove now returns the ZREM count (Mono instead of Mono); the two other call sites (enqueue failure path, tryMatching finally block) use awaitSingleOrNull and are unaffected. DELETE /api/match/queue/me now returns 409 when nothing was removed — client-visible API change, Unity client needs to handle 409. markOnline is skipped in that case so a user already marked OnPlaying is not clobbered.

#66: implemented the throughput half only — up to MAX_PAIRS_PER_TICK = 20 pairs per 5s tick (marked with a ponytail: comment). The "notify the user on queue expiry" half is not implemented: there is no push channel in this module, and the existing GET /api/match/queue/me/exist polling endpoint already returns 404 for an expired entry. A real push notification would be a new mechanism, not a mechanical patch. Pairs are still created sequentially inside one coroutine, so 20 pairs can outlast a 5s tick and overlap the next tick; that overlap was already possible before this change and is safe because dequeueBestPair removes both users from Redis atomically-per-pair before session creation.


멀티 에이전트 코드 리뷰에서 검증된 결함을 수정한 것. 격리된 워크트리에서 작업하고 모듈 빌드로 확인함.

Solo session entry points trusted the client: PVE played any scenarioId
without checking whether it was unlocked, and both PVE and practice skipped
the deck and busy-state preconditions the PVP path already enforces. Queue
cancellation always answered 200 even when the user had just been dequeued
into a live match, and the scheduler matched only one pair per five-second
tick regardless of queue depth.

Closes #62
Closes #63
Closes #65
Closes #66

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment