Skip to content
Open
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
14 changes: 11 additions & 3 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
plugins {
alias(libs.plugins.kotlin.jvm) apply false
// 웹 게임 아케이드(#23) KMP sim-core — 루트에서 버전 1회 해소(apply false)해야 서브프로젝트가
// "plugin already on classpath with unknown version" 없이 적용 가능.
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.spring) apply false
alias(libs.plugins.kotlin.jpa) apply false
alias(libs.plugins.kotlin.kapt) apply false
Expand All @@ -8,13 +11,11 @@ plugins {
}

subprojects {
apply(plugin = "org.jetbrains.kotlin.jvm")
apply(plugin = "io.spring.dependency-management")

// ADR-0058: nested submodule(:svc:domain / :svc:app) 의 leaf 이름이 전부 domain/app 으로
// 동일 → 단일 group 이면 com.kgd:domain 좌표 충돌로 한 app 이 두 도메인을 동시에 의존할 때
// (commerce 모듈러 모놀리스) Gradle 이 하나로 합쳐버린다. group 을 부모 경로로 고유화한다.
// 이미지명은 jib-convention 이 Gradle 경로에서 파생하므로 group 변경의 영향 없음.
// (group/version/repositories 는 KMP 모듈 포함 전 모듈 공통)
group = if (parent == null || parent == rootProject) "com.kgd" else "com.kgd.${parent!!.name}"
version = "0.0.1-SNAPSHOT"

Expand All @@ -34,6 +35,13 @@ subprojects {
mavenCentral()
}

// 웹 게임 아케이드(#23): KMP 모듈은 kotlin.multiplatform 을 자체 적용하며 일괄 kotlin.jvm 과
// 상호배타다. 따라서 아래 JVM/Spring 전용 설정에서 제외한다(모듈 자체 build.gradle.kts 가 구성).
if (path in setOf(":game:sim", ":game:web")) return@subprojects

apply(plugin = "org.jetbrains.kotlin.jvm")
apply(plugin = "io.spring.dependency-management")

pluginManager.withPlugin("org.jetbrains.kotlin.jvm") {
extensions.configure<org.gradle.api.plugins.JavaPluginExtension>("java") {
toolchain {
Expand Down
10 changes: 10 additions & 0 deletions commerce/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dependencies {
implementation(project(":warehouse:feature")) // co-deploy (commerce 모듈러 모놀리스)
implementation(project(":fulfillment:feature")) // co-deploy (commerce 모듈러 모놀리스)
implementation(project(":order:feature")) // co-deploy (commerce 모듈러 모놀리스)
implementation(project(":game:feature")) // 웹 게임 아케이드(#23) co-deploy — Redis 전용, Tier B in-JVM(추가 프로세스 0)
// 메인 클래스(@SpringBootApplication) 컴파일 + bootJar 구성용 최소 의존
implementation(libs.spring.boot.starter.web)

Expand All @@ -24,3 +25,12 @@ dependencies {
tasks.bootJar {
archiveBaseName.set("commerce")
}

// 웹 게임 아케이드(#23) — game:web 브라우저 번들(game.js + index.html)을 정적 리소스로 패키징.
// commerce:app 이 /game/ 으로 서빙(API 는 /api/v1/game/**). game:web 변경 시에만 재빌드(up-to-date).
tasks.named<org.gradle.language.jvm.tasks.ProcessResources>("processResources") {
dependsOn(":game:web:jsBrowserDistribution")
from(project(":game:web").layout.buildDirectory.dir("dist/js/productionExecutable")) {
into("static/game")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling

// ADR-0058: commerce 모듈러 모놀리스 — warehouse+fulfillment+order 도메인 폴드 (도메인별 datasource/EMF, 스키마 유지)
@SpringBootApplication(scanBasePackages = ["com.kgd.inventory", "com.kgd.warehouse", "com.kgd.fulfillment", "com.kgd.order", "com.kgd.common.exception", "com.kgd.common.response"])
@SpringBootApplication(scanBasePackages = ["com.kgd.inventory", "com.kgd.warehouse", "com.kgd.fulfillment", "com.kgd.order", "com.kgd.game", "com.kgd.common.exception", "com.kgd.common.response"])
@EnableScheduling
class CommerceApplication

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ class CommerceContextLoadSpec(
ctx.containsBean("inventoryIdempotentEventCleanupScheduler").shouldBeTrue()
ctx.containsBean("fulfillmentIdempotentEventCleanupScheduler").shouldBeTrue()
ctx.containsBean("orderIdempotentEventCleanupScheduler").shouldBeTrue()

// 웹 게임 아케이드(#23) — game:feature(Redis 전용)가 commerce:app 에 충돌 없이 폴드되는지.
// Tier B 리플레이는 game:sim(jvm) 으로 in-JVM(추가 프로세스 0).
listOf(
"gameController", "startSessionService", "submitScoreService",
"redisLeaderboard", "redisSessionStore", "inMemoryGameRegistry", "hmacSessionTokenService",
).forEach { ctx.containsBean(it).shouldBeTrue() }
}
}
}) {
Expand Down
6 changes: 6 additions & 0 deletions game/domain/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// 웹 게임 아케이드(#23) — game:domain: 순수 Kotlin 백엔드 도메인(엔티티/포트/검증 규칙).
// Spring/JPA 의존 금지. game:sim 의 결정적 코어(ReplayLog/GameModule/SimRunner)를 Tier B 검증에 사용.
dependencies {
implementation(project(":common"))
implementation(project(":game:sim")) // KMP jvm variant — ReplayLog/InputEvent/GameModule/SimRunner
}
76 changes: 76 additions & 0 deletions game/domain/src/main/kotlin/com/kgd/game/domain/Model.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.kgd.game.domain

import com.kgd.game.sim.ReplayLog

@JvmInline
value class SessionId(val value: String)

@JvmInline
value class PlayerId(val value: String)

enum class SessionStatus { OPEN, SUBMITTED, EXPIRED }

enum class VerificationStatus { PROVISIONAL, CONFIRMED, REJECTED }

enum class LeaderboardPeriod { DAILY, ALL_TIME }

/** 게스트(미등록) vs 등록 계정. 경쟁 랭킹 등재는 registered=true 만(밴 유효). */
data class Player(
val id: PlayerId,
val nickname: String,
val registered: Boolean,
)

/** 데일리 챌린지 — gameId+date 당 공통 seed(모두 같은 맵 → 절차적 맵 공정 경쟁). */
data class DailyChallenge(
val gameId: String,
val date: String, // ISO yyyy-MM-dd (UTC)
val seed: Int,
)

/** 한 판의 플레이 세션. player-set size=1 (MVP — 후일 match=size N 으로 확장). */
data class GameSession(
val id: SessionId,
val gameId: String,
val playerId: PlayerId?, // 시작 시점엔 게스트(null) 가능
val seed: Int,
val dailyDate: String?, // 데일리 챌린지 날짜(공통 seed), 자유 플레이면 null
val startedEpochMs: Long,
val status: SessionStatus,
) {
fun elapsedMs(nowEpochMs: Long): Long = (nowEpochMs - startedEpochMs).coerceAtLeast(0)
}

/** 클라가 제출하는 결과 — claimedScore 는 신뢰 대상이 아니며 Tier A/B 가 검증한다. */
data class ScoreSubmission(
val sessionId: SessionId,
val claimedScore: Int,
val replay: ReplayLog,
val clientDurationMs: Long,
val nickname: String,
)

/** Tier B 재실행을 위해 보관하는 리플레이. */
data class StoredReplay(
val sessionId: SessionId,
val replay: ReplayLog,
val claimedScore: Int,
val status: VerificationStatus,
)

/** 리더보드 보드 식별자. dateKey = DAILY 면 날짜, ALL_TIME 이면 null. */
data class BoardKey(
val gameId: String,
val period: LeaderboardPeriod,
val dateKey: String?,
)

data class LeaderboardEntry(
val rank: Int,
val playerId: PlayerId,
val nickname: String,
val score: Int,
val status: VerificationStatus,
)

data class GameCatalogItem(val gameId: String, val title: String)
55 changes: 55 additions & 0 deletions game/domain/src/main/kotlin/com/kgd/game/domain/Ports.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.kgd.game.domain

import com.kgd.game.sim.GameModule

/** 시스템 시계 — 도메인이 시스템 의존 없이 시간 정합을 검증하도록 주입. */
interface Clock {
fun nowEpochMs(): Long
}

/** 서버 발급 seed — 클라가 유리한 seed 를 고르지 못하게(seed shopping 방지). */
interface SeedSource {
fun newSeed(): Int
}

/** 세션 서명 토큰(HMAC) 발급/검증 — 무플레이/위조 제출 차단. */
interface SessionTokenService {
fun issue(sessionId: SessionId, seed: Int, startedEpochMs: Long): String
fun verify(token: String, sessionId: SessionId, seed: Int, startedEpochMs: Long): Boolean
}

interface SessionStore {
fun save(session: GameSession)
fun find(id: SessionId): GameSession?
}

interface ReplayStore {
fun save(stored: StoredReplay)
fun updateStatus(sessionId: SessionId, status: VerificationStatus)
}

interface LeaderboardPort {
fun submit(board: BoardKey, playerId: PlayerId, nickname: String, score: Int, status: VerificationStatus)
fun top(board: BoardKey, limit: Int): List<LeaderboardEntry>
/** 1-based 순위. 없으면 null. */
fun rankOf(board: BoardKey, playerId: PlayerId): Int?
fun setStatus(board: BoardKey, playerId: PlayerId, status: VerificationStatus)
}

interface PlayerStore {
fun createGuest(nickname: String): Player
/** MVP 가입-라이트: 닉네임 클레임 → 등록 계정 get-or-create. */
fun registerOrGet(nickname: String): Player
fun find(id: PlayerId): Player?
}

interface DailyChallengePort {
/** gameId+date 의 공통 seed 챌린지를 get-or-create. */
fun current(gameId: String, date: String): DailyChallenge
}

/** gameId → 결정적 게임 모듈/카탈로그. Tier B 리플레이가 gameId 로 모듈을 찾는다. */
interface GameRegistry {
fun module(gameId: String): GameModule<*>?
fun catalog(): List<GameCatalogItem>
}
67 changes: 67 additions & 0 deletions game/domain/src/main/kotlin/com/kgd/game/domain/Verification.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.kgd.game.domain

import com.kgd.game.sim.GameModule
import com.kgd.game.sim.SimRunner

/** Tier A 경량 검증 결과. */
data class TierAResult(val accepted: Boolean, val reason: String? = null) {
companion object {
val OK = TierAResult(true)
fun reject(reason: String) = TierAResult(false, reason)
}
}

/**
* Tier A — 게임 재실행 없는 경량 타당성 검증(순수 규칙).
* 토큰/세션-존재/rate-limit 은 인프라(feature)가, 여기선 시간·점수 정합만 본다.
*/
class ScorePlausibility(
private val minMsPerTick: Long = 30, // tick 당 최소 경과(너무 빠른 클리어 차단)
private val maxScorePerTick: Int = 1, // Snake: 한 tick 에 최대 한 먹이
) {
fun verify(session: GameSession, submission: ScoreSubmission, nowEpochMs: Long): TierAResult {
if (session.status != SessionStatus.OPEN) return TierAResult.reject("session not open")
if (submission.claimedScore < 0) return TierAResult.reject("negative score")
val ticks = submission.replay.totalTicks
if (ticks < 0) return TierAResult.reject("negative ticks")
if (submission.claimedScore > ticks.toLong() * maxScorePerTick) {
return TierAResult.reject("score exceeds tick bound")
}
val serverElapsed = session.elapsedMs(nowEpochMs)
if (serverElapsed < ticks.toLong() * minMsPerTick - GRACE_MS) {
return TierAResult.reject("too fast for ticks")
}
if (submission.clientDurationMs > serverElapsed + GRACE_MS) {
return TierAResult.reject("client duration exceeds server elapsed")
}
return TierAResult.OK
}

companion object {
const val GRACE_MS = 2000L
}
}

/** Tier B 결과. */
data class TierBResult(
val verified: Boolean,
val recomputedScore: Int,
val reason: String? = null,
)

/**
* Tier B — 보관한 seed+입력 로그로 결정적 코어를 재실행해 점수를 재계산.
* commerce:app JVM 안에서 game:sim 의 SimRunner 로 도므로 추가 프로세스 0.
* 점수 위조는 차단하나 '실제로 잘 플레이하는 봇'은 통과 → 이상탐지/검수로 보완(설계 참조).
*/
class ReplayVerifier {
fun <S> verify(module: GameModule<S>, submission: ScoreSubmission): TierBResult {
val result = SimRunner.run(module, submission.replay)
val ok = result.score == submission.claimedScore
return TierBResult(
verified = ok,
recomputedScore = result.score,
reason = if (ok) null else "score mismatch: claimed=${submission.claimedScore} actual=${result.score}",
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.kgd.game.domain

import com.kgd.game.sim.InputCommand
import com.kgd.game.sim.InputEvent
import com.kgd.game.sim.ReplayLog
import com.kgd.game.sim.SimRunner
import com.kgd.game.sim.games.SnakeGame
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe

class VerificationTest : BehaviorSpec({
val snake = SnakeGame()

Given("an honestly played replay") {
val replay = ReplayLog(SnakeGame.ID, seed = 42, totalTicks = 50, inputs = listOf(InputEvent(2, InputCommand.DOWN)))
val honest = SimRunner.run(snake, replay).score

When("Tier B re-runs with the matching claimed score") {
val sub = ScoreSubmission(SessionId("s1"), honest, replay, clientDurationMs = 3000, nickname = "kgd")
val r = ReplayVerifier().verify(snake, sub)
Then("it verifies and recomputes the same score") {
r.verified shouldBe true
r.recomputedScore shouldBe honest
}
}

When("a forged higher score is claimed for the same replay") {
val sub = ScoreSubmission(SessionId("s1"), honest + 999, replay, clientDurationMs = 3000, nickname = "kgd")
val r = ReplayVerifier().verify(snake, sub)
Then("Tier B rejects (recomputed != claimed)") {
r.verified shouldBe false
r.recomputedScore shouldBe honest
}
}
}

Given("Tier A plausibility checks") {
val session = GameSession(
SessionId("s1"), SnakeGame.ID, PlayerId("p1"),
seed = 1, dailyDate = null, startedEpochMs = 0, status = SessionStatus.OPEN,
)
val checker = ScorePlausibility()

When("claimed score exceeds the per-tick bound") {
val sub = ScoreSubmission(
SessionId("s1"), claimedScore = 100,
ReplayLog(SnakeGame.ID, 1, totalTicks = 10, inputs = emptyList()),
clientDurationMs = 5000, nickname = "k",
)
Then("rejected") {
checker.verify(session, sub, nowEpochMs = 10_000).accepted shouldBe false
}
}

When("a plausible score within the time budget") {
val sub = ScoreSubmission(
SessionId("s1"), claimedScore = 3,
ReplayLog(SnakeGame.ID, 1, totalTicks = 100, inputs = emptyList()),
clientDurationMs = 3500, nickname = "k",
)
Then("accepted") {
// 100 ticks * 30ms = 3000ms 최소, 서버 경과 4000ms → OK
checker.verify(session, sub, nowEpochMs = 4_000).accepted shouldBe true
}
}

When("the submission arrives implausibly fast for its tick count") {
val sub = ScoreSubmission(
SessionId("s1"), claimedScore = 2,
ReplayLog(SnakeGame.ID, 1, totalTicks = 100, inputs = emptyList()),
clientDurationMs = 100, nickname = "k",
)
Then("rejected (server elapsed too small for ticks)") {
checker.verify(session, sub, nowEpochMs = 200).accepted shouldBe false
}
}
}
})
28 changes: 28 additions & 0 deletions game/feature/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// 웹 게임 아케이드(#23) — game:feature: commerce 모듈러 모놀리스 라이브러리(비-bootable, ADR-0058).
// MVP 는 Redis 전용(세션/리플레이/리더보드 sorted-set/플레이어/데일리) — JPA·전용 datasource 불필요.
// Tier B 는 game:sim(jvm) 의 SimRunner 로 commerce:app JVM 안에서 재실행(추가 프로세스 0).
plugins {
alias(libs.plugins.kotlin.spring)
alias(libs.plugins.spring.boot)
}

dependencies {
implementation(project(":game:domain"))
implementation(project(":game:sim")) // GameModule/SnakeGame — Tier B + 카탈로그
implementation(project(":common"))
implementation(libs.kotlin.logging)
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.data.redis)
implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.actuator)
implementation(libs.springdoc.openapi.starter.webmvc.ui)
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.kotest.extensions.spring)
testImplementation(libs.testcontainers.junit) // 라이브 E2E — 실제 Redis 컨테이너
}

// 라이브러리 — 실행 가능 JAR 아님(commerce:app 이 조립).
tasks.named<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJar") { enabled = false }
tasks.named<Jar>("jar") { enabled = true }
Loading
Loading