diff --git a/app/game-engine/src/tournament/finalizer.ts b/app/game-engine/src/tournament/finalizer.ts index 1d808283..cbf00c8b 100644 --- a/app/game-engine/src/tournament/finalizer.ts +++ b/app/game-engine/src/tournament/finalizer.ts @@ -13,6 +13,13 @@ export interface TournamentRewardFinalizer { close(): Promise; } +interface TournamentReward { + gold: number; + exp: number; + label: string; + inheritPoint: number; +} + const resolveTournamentLabel = (type: TournamentType): string => { switch (type) { case 1: @@ -51,6 +58,50 @@ const resolveNumber = (source: Record, keys: string[], fallback return fallback; }; +export const resolveTournamentDevelCost = (world: InMemoryTurnWorld): number => { + const constValues = asRecord(world.getScenarioConfig().const ?? {}); + const configDevelCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0); + // Ref reads the current game_env.develcost when setGift() runs. Core keeps + // that yearly value in world_state.meta; scenario const is only a fallback + // for older snapshots that predate the persisted field. + return resolveNumber(asRecord(world.getState().meta), ['develcost', 'develCost', 'develrate'], configDevelCost); +}; + +export const buildTournamentRewardMap = ( + command: Pick< + Extract, + 'top16' | 'top8' | 'top4' | 'winnerId' | 'runnerUpId' + >, + develCost: number +): Map => { + const rewardMap = new Map(); + const applyTier = (ids: number[], tier: TournamentReward): void => { + for (const id of new Set(ids)) { + const current = rewardMap.get(id) ?? { gold: 0, exp: 0, label: tier.label, inheritPoint: 0 }; + rewardMap.set(id, { + gold: current.gold + tier.gold, + exp: current.exp + tier.exp, + label: tier.label, + inheritPoint: tier.inheritPoint > 0 ? tier.inheritPoint : current.inheritPoint, + }); + } + }; + + applyTier(command.top16, { gold: develCost, exp: 25, label: '16강 진출', inheritPoint: 10 }); + applyTier(command.top8, { gold: develCost * 2, exp: 50, label: '8강 진출', inheritPoint: 0 }); + applyTier(command.top4, { gold: develCost * 3, exp: 50, label: '4강 진출', inheritPoint: 10 }); + // Ref's grp 50 final tier rewards both finalists before grp 60 adds + // the champion-only tier. The later champion tier replaces the label. + applyTier([command.runnerUpId, command.winnerId], { + gold: develCost * 6, + exp: 100, + label: '준우승', + inheritPoint: 50, + }); + applyTier([command.winnerId], { gold: develCost * 8, exp: 200, label: '우승', inheritPoint: 100 }); + return rewardMap; +}; + const pushLogs = (world: InMemoryTurnWorld, logs: ReturnType): void => { if (logs.length === 0) { return; @@ -75,31 +126,8 @@ export const createTournamentRewardFinalizer = async (options: { const { world } = options; const db = commandDb ?? prisma; const { winnerId, runnerUpId } = command; - const rewardMap = new Map(); - - const applyTier = ( - ids: number[], - tier: { gold: number; exp: number; label: string; inheritPoint: number } - ): void => { - for (const id of new Set(ids)) { - const current = rewardMap.get(id) ?? { gold: 0, exp: 0, label: tier.label, inheritPoint: 0 }; - rewardMap.set(id, { - gold: current.gold + tier.gold, - exp: current.exp + tier.exp, - label: tier.label, - inheritPoint: tier.inheritPoint > 0 ? tier.inheritPoint : current.inheritPoint, - }); - } - }; - - const constValues = asRecord(world.getScenarioConfig().const ?? {}); - const develCost = resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0); - - applyTier(command.top16, { gold: develCost, exp: 25, label: '16강 진출', inheritPoint: 10 }); - applyTier(command.top8, { gold: develCost * 2, exp: 50, label: '8강 진출', inheritPoint: 0 }); - applyTier(command.top4, { gold: develCost * 3, exp: 50, label: '4강 진출', inheritPoint: 10 }); - applyTier([runnerUpId], { gold: develCost * 6, exp: 100, label: '준우승', inheritPoint: 50 }); - applyTier([winnerId], { gold: develCost * 8, exp: 200, label: '우승', inheritPoint: 100 }); + const develCost = resolveTournamentDevelCost(world); + const rewardMap = buildTournamentRewardMap(command, develCost); if (rewardMap.size === 0) { return { diff --git a/app/game-engine/test/tournamentCommands.test.ts b/app/game-engine/test/tournamentCommands.test.ts index 2369c36f..c00560af 100644 --- a/app/game-engine/test/tournamentCommands.test.ts +++ b/app/game-engine/test/tournamentCommands.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import type { TurnSchedule } from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { buildTournamentRewardMap, resolveTournamentDevelCost } from '../src/tournament/finalizer.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; import { buildPersistedRankRows } from '../src/turn/rankData.js'; @@ -41,14 +42,17 @@ const buildGeneral = (id: number, meta: Record = {}): TurnGenera npcState: 0, }); -const buildWorld = (generals: TurnGeneral[]): InMemoryTurnWorld => { +const buildWorld = ( + generals: TurnGeneral[], + options: { worldMeta?: Record; configConst?: Record } = {} +): InMemoryTurnWorld => { const state: TurnWorldState = { id: 1, currentYear: 180, currentMonth: 1, tickSeconds: 600, lastTurnTime: new Date('0180-01-01T00:00:00Z'), - meta: {}, + meta: options.worldMeta ?? {}, }; const snapshot: TurnWorldSnapshot = { generals, @@ -62,7 +66,7 @@ const buildWorld = (generals: TurnGeneral[]): InMemoryTurnWorld => { stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, iconPath: '', map: {}, - const: {}, + const: options.configConst ?? {}, environment: { mapName: 'test', unitSet: 'test' }, }, map: { @@ -76,6 +80,31 @@ const buildWorld = (generals: TurnGeneral[]): InMemoryTurnWorld => { }; describe('tournament world commands', () => { + it('uses the current world develcost for Ref-compatible tournament prizes', () => { + const currentWorld = buildWorld([], { + worldMeta: { develcost: 64 }, + configConst: { develCost: 10 }, + }); + expect(resolveTournamentDevelCost(currentWorld)).toBe(64); + + const legacySnapshot = buildWorld([], { configConst: { develCost: 10 } }); + expect(resolveTournamentDevelCost(legacySnapshot)).toBe(10); + + const rewardMap = buildTournamentRewardMap( + { + top16: Array.from({ length: 16 }, (_, index) => index + 1), + top8: Array.from({ length: 8 }, (_, index) => index + 1), + top4: Array.from({ length: 4 }, (_, index) => index + 1), + winnerId: 1, + runnerUpId: 2, + }, + 64 + ); + expect(rewardMap.get(1)?.gold).toBe(64 * 20); + expect(rewardMap.get(2)?.gold).toBe(64 * 12); + expect(Array.from(rewardMap.values()).reduce((sum, reward) => sum + reward.gold, 0)).toBe(64 * 64); + }); + it('updates the persisted tt rank keys for a tournament match', async () => { const world = buildWorld([ buildGeneral(1, { ttg: 10, ttw: 2 }), diff --git a/docs/e2e-caddy-routing.md b/docs/e2e-caddy-routing.md index bf8f6544..138e8424 100644 --- a/docs/e2e-caddy-routing.md +++ b/docs/e2e-caddy-routing.md @@ -1,5 +1,31 @@ # Caddy prefix 계약 +## 환경과 ingress + +| 환경 | 공개 주소 | 연결 계약 | +| ---- | --------- | --------- | +| 공개 | `dev-sam2026.hided.net` | 실제 외부 Core2026 서비스입니다. 로컬 Docker `14999`의 주소가 아닙니다. | +| E2E | `dev-sam-e2e.hided.net` | 외부 Caddy TLS → `172.30.1.54:14999` HTTP → Docker Caddy입니다. | + +외부 Caddy는 E2E 호스트의 모든 경로를 `172.30.1.54:14999`로 전달하고 원래 +`Host` header와 path prefix를 보존합니다. `handle_path`처럼 prefix를 제거하는 +설정을 사용하지 않습니다. Docker Caddy가 아래 활성 경로를 frontend와 API로 +분기합니다. 외부 상태 확인 경로는 `/gateway/api/healthz`입니다. + +E2E Docker stack은 다음 비밀이 아닌 값을 사용합니다. + +```dotenv +DOMAIN=dev-sam-e2e.hided.net +PUBLIC_SCHEME=https +CADDY_SITE_ADDRESS=http://dev-sam-e2e.hided.net +HTTP_PORT=14999 +``` + +이 값에서 Gateway 공개 URL +`https://dev-sam-e2e.hided.net/gateway/`와 Kakao redirect URI +`https://dev-sam-e2e.hided.net/gateway/oauth/callback`을 파생합니다. 도메인을 +바꾼 뒤에는 Caddy뿐 아니라 runtime도 재생성하여 process 환경을 갱신합니다. + ## 활성 경로 | 서비스 | 공개 prefix | frontend | API | @@ -8,8 +34,10 @@ | che | `/che/` | `15002` | `15003` | | hwe | `/hwe/` | `15014` | `15015` | -Upstream host는 `172.30.1.54`입니다. `kwe`, `pwe`, `twe`, `nya`, `pya`는 -resource·profile 이름으로 사용할 수 있지만 활성 Caddy route가 아닙니다. +표의 port는 Docker 내부 Caddy가 연결하는 frontend/API listener입니다. 외부 +Caddy가 이 port들에 직접 연결하지 않습니다. `kwe`, `pwe`, `twe`, `nya`, +`pya`는 resource·profile 이름으로 사용할 수 있지만 활성 Caddy route가 +아닙니다. Caddy는 prefix를 보존해 upstream에 전달합니다. 앱은 root 배포를 가정하지 않고 frontend base, tRPC, SSE, upload와 direct navigation에 같은 prefix를 @@ -95,4 +123,5 @@ path와 wildcard path를 모두 검사합니다. 7. 새로고침 뒤 session·route 복구 Local proxy·mock 성공은 외부 DNS, TLS, Caddy process, host firewall와 -upstream 상태를 증명하지 않습니다. +upstream 상태를 증명하지 않습니다. 도메인 전환 시에는 route 응답과 별도로 +OAuth 시작 응답의 redirect URI 및 callback 복귀 호스트도 확인합니다. diff --git a/tools/integration-tests/test/tournamentLifecycle.test.ts b/tools/integration-tests/test/tournamentLifecycle.test.ts index e67536ec..ffa8f4e2 100644 --- a/tools/integration-tests/test/tournamentLifecycle.test.ts +++ b/tools/integration-tests/test/tournamentLifecycle.test.ts @@ -483,6 +483,9 @@ describe('actual tournament lifecycle', () => { }); expect(finalState?.winnerId).toBeTypeOf('number'); + const currentDevelCost = Number(turnDaemon!.world.getState().meta.develcost); + expect(currentDevelCost).toBeGreaterThan(0); + const bettorAfterSettlement = await gameConnector.prisma.general.findUniqueOrThrow({ where: { id: bettorAId }, select: { gold: true, meta: true }, @@ -505,5 +508,8 @@ describe('actual tournament lifecycle', () => { ]) ); expect(settlementEvents.every((event) => (event.result as { ok?: boolean } | null)?.ok === true)).toBe(true); + const rewardEvent = settlementEvents.find((event) => event.eventType === 'tournamentReward'); + // Ref setGift(): 16*1 + 8*2 + 4*3 + both finalists*6 + winner*8 = 64 develcost. + expect((rewardEvent?.result as { totalGold?: number } | null)?.totalGold).toBe(currentDevelCost * 64); }, 120_000); });