tournament

This commit is contained in:
2026-08-09 10:15:18 +00:00
parent 9c411b0190
commit 164d7e0f73
4 changed files with 123 additions and 31 deletions
+53 -25
View File
@@ -13,6 +13,13 @@ export interface TournamentRewardFinalizer {
close(): Promise<void>;
}
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<string, unknown>, 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<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 => {
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<number, { gold: number; exp: number; label: string; inheritPoint: number }>();
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 {
@@ -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<string, number> = {}): TurnGenera
npcState: 0,
});
const buildWorld = (generals: TurnGeneral[]): InMemoryTurnWorld => {
const buildWorld = (
generals: TurnGeneral[],
options: { worldMeta?: Record<string, unknown>; configConst?: Record<string, unknown> } = {}
): 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 }),
+32 -3
View File
@@ -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 복귀 호스트도 확인합니다.
@@ -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);
});