tournament
This commit is contained in:
@@ -13,6 +13,13 @@ export interface TournamentRewardFinalizer {
|
|||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TournamentReward {
|
||||||
|
gold: number;
|
||||||
|
exp: number;
|
||||||
|
label: string;
|
||||||
|
inheritPoint: number;
|
||||||
|
}
|
||||||
|
|
||||||
const resolveTournamentLabel = (type: TournamentType): string => {
|
const resolveTournamentLabel = (type: TournamentType): string => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -51,6 +58,50 @@ const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback
|
|||||||
return 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<TurnDaemonCommand, { type: 'tournamentReward' }>,
|
||||||
|
'top16' | 'top8' | 'top4' | 'winnerId' | 'runnerUpId'
|
||||||
|
>,
|
||||||
|
develCost: number
|
||||||
|
): Map<number, TournamentReward> => {
|
||||||
|
const rewardMap = new Map<number, TournamentReward>();
|
||||||
|
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<ActionLogger['flush']>): void => {
|
const pushLogs = (world: InMemoryTurnWorld, logs: ReturnType<ActionLogger['flush']>): void => {
|
||||||
if (logs.length === 0) {
|
if (logs.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -75,31 +126,8 @@ export const createTournamentRewardFinalizer = async (options: {
|
|||||||
const { world } = options;
|
const { world } = options;
|
||||||
const db = commandDb ?? prisma;
|
const db = commandDb ?? prisma;
|
||||||
const { winnerId, runnerUpId } = command;
|
const { winnerId, runnerUpId } = command;
|
||||||
const rewardMap = new Map<number, { gold: number; exp: number; label: string; inheritPoint: number }>();
|
const develCost = resolveTournamentDevelCost(world);
|
||||||
|
const rewardMap = buildTournamentRewardMap(command, develCost);
|
||||||
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 });
|
|
||||||
|
|
||||||
if (rewardMap.size === 0) {
|
if (rewardMap.size === 0) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import type { TurnSchedule } from '@sammo-ts/logic';
|
import type { TurnSchedule } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
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 type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||||
import { buildPersistedRankRows } from '../src/turn/rankData.js';
|
import { buildPersistedRankRows } from '../src/turn/rankData.js';
|
||||||
@@ -41,14 +42,17 @@ const buildGeneral = (id: number, meta: Record<string, number> = {}): TurnGenera
|
|||||||
npcState: 0,
|
npcState: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildWorld = (generals: TurnGeneral[]): InMemoryTurnWorld => {
|
const buildWorld = (
|
||||||
|
generals: TurnGeneral[],
|
||||||
|
options: { worldMeta?: Record<string, unknown>; configConst?: Record<string, unknown> } = {}
|
||||||
|
): InMemoryTurnWorld => {
|
||||||
const state: TurnWorldState = {
|
const state: TurnWorldState = {
|
||||||
id: 1,
|
id: 1,
|
||||||
currentYear: 180,
|
currentYear: 180,
|
||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
|
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
|
||||||
meta: {},
|
meta: options.worldMeta ?? {},
|
||||||
};
|
};
|
||||||
const snapshot: TurnWorldSnapshot = {
|
const snapshot: TurnWorldSnapshot = {
|
||||||
generals,
|
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 },
|
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
|
||||||
iconPath: '',
|
iconPath: '',
|
||||||
map: {},
|
map: {},
|
||||||
const: {},
|
const: options.configConst ?? {},
|
||||||
environment: { mapName: 'test', unitSet: 'test' },
|
environment: { mapName: 'test', unitSet: 'test' },
|
||||||
},
|
},
|
||||||
map: {
|
map: {
|
||||||
@@ -76,6 +80,31 @@ const buildWorld = (generals: TurnGeneral[]): InMemoryTurnWorld => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('tournament world commands', () => {
|
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 () => {
|
it('updates the persisted tt rank keys for a tournament match', async () => {
|
||||||
const world = buildWorld([
|
const world = buildWorld([
|
||||||
buildGeneral(1, { ttg: 10, ttw: 2 }),
|
buildGeneral(1, { ttg: 10, ttw: 2 }),
|
||||||
|
|||||||
@@ -1,5 +1,31 @@
|
|||||||
# Caddy prefix 계약
|
# 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 |
|
| 서비스 | 공개 prefix | frontend | API |
|
||||||
@@ -8,8 +34,10 @@
|
|||||||
| che | `/che/` | `15002` | `15003` |
|
| che | `/che/` | `15002` | `15003` |
|
||||||
| hwe | `/hwe/` | `15014` | `15015` |
|
| hwe | `/hwe/` | `15014` | `15015` |
|
||||||
|
|
||||||
Upstream host는 `172.30.1.54`입니다. `kwe`, `pwe`, `twe`, `nya`, `pya`는
|
표의 port는 Docker 내부 Caddy가 연결하는 frontend/API listener입니다. 외부
|
||||||
resource·profile 이름으로 사용할 수 있지만 활성 Caddy route가 아닙니다.
|
Caddy가 이 port들에 직접 연결하지 않습니다. `kwe`, `pwe`, `twe`, `nya`,
|
||||||
|
`pya`는 resource·profile 이름으로 사용할 수 있지만 활성 Caddy route가
|
||||||
|
아닙니다.
|
||||||
|
|
||||||
Caddy는 prefix를 보존해 upstream에 전달합니다. 앱은 root 배포를 가정하지
|
Caddy는 prefix를 보존해 upstream에 전달합니다. 앱은 root 배포를 가정하지
|
||||||
않고 frontend base, tRPC, SSE, upload와 direct navigation에 같은 prefix를
|
않고 frontend base, tRPC, SSE, upload와 direct navigation에 같은 prefix를
|
||||||
@@ -95,4 +123,5 @@ path와 wildcard path를 모두 검사합니다.
|
|||||||
7. 새로고침 뒤 session·route 복구
|
7. 새로고침 뒤 session·route 복구
|
||||||
|
|
||||||
Local proxy·mock 성공은 외부 DNS, TLS, Caddy process, host firewall와
|
Local proxy·mock 성공은 외부 DNS, TLS, Caddy process, host firewall와
|
||||||
upstream 상태를 증명하지 않습니다.
|
upstream 상태를 증명하지 않습니다. 도메인 전환 시에는 route 응답과 별도로
|
||||||
|
OAuth 시작 응답의 redirect URI 및 callback 복귀 호스트도 확인합니다.
|
||||||
|
|||||||
@@ -483,6 +483,9 @@ describe('actual tournament lifecycle', () => {
|
|||||||
});
|
});
|
||||||
expect(finalState?.winnerId).toBeTypeOf('number');
|
expect(finalState?.winnerId).toBeTypeOf('number');
|
||||||
|
|
||||||
|
const currentDevelCost = Number(turnDaemon!.world.getState().meta.develcost);
|
||||||
|
expect(currentDevelCost).toBeGreaterThan(0);
|
||||||
|
|
||||||
const bettorAfterSettlement = await gameConnector.prisma.general.findUniqueOrThrow({
|
const bettorAfterSettlement = await gameConnector.prisma.general.findUniqueOrThrow({
|
||||||
where: { id: bettorAId },
|
where: { id: bettorAId },
|
||||||
select: { gold: true, meta: true },
|
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);
|
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);
|
}, 120_000);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user