merge: 최신 main을 브라우저 전투 시뮬레이터 작업에 통합

This commit is contained in:
2026-08-17 01:47:07 +00:00
27 changed files with 1161 additions and 91 deletions
+18
View File
@@ -69,6 +69,24 @@ export const toPublicRealtimeEvent = (
: null;
}
if (event.type === 'tournamentChanged') {
return {
type: 'readModelInvalidated',
invalidation: {
context: false,
lobby: false,
map: false,
commands: false,
contacts: false,
boardAccess: false,
reservedTurns: false,
records: false,
frontStatus: false,
tournament: true,
},
};
}
if (event.type === 'turnCompleted' && !event.changes) {
return {
type: 'readModelInvalidated',
+4 -2
View File
@@ -6,7 +6,6 @@ import { asRecord } from '@sammo-ts/common';
import type { GameApiContext } from '../../context.js';
import {
accessAuthedProcedure,
accessEngineAuthedProcedure,
accessEngineAuthedInputProcedure,
accessLimitAuthedProcedure,
@@ -790,7 +789,10 @@ export const generalRouter = router({
history: trimRecentRecords(history, input.lastWorldHistoryId),
};
}),
getFrontStatus: accessAuthedProcedure.query(async ({ ctx }) => {
// 메인 화면은 SSE invalidation, 탭 복귀와 직접 갱신이 같은 read model을
// 호출한다. 클라이언트가 주장하는 갱신 원인을 신뢰해 구분하지 않고 이
// projection 전체를 무가점으로 두되, 이미 제한된 사용자의 gate는 유지한다.
getFrontStatus: accessLimitAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx);
const worldState = await ctx.db.worldState.findFirst({
orderBy: { id: 'asc' },
@@ -40,7 +40,6 @@ export const generalAccessEndpointWeights = {
'diplomacy.getLetters': 2,
'battle.getGeneralDetail': 1,
'betting.getList': 1,
'general.getFrontStatus': 1,
'yearbook.getHistory': 1,
'world.getGlobalInfo': 1,
'nation.getBattleCenter': 1,
@@ -86,14 +85,11 @@ export const generalAccessLimitEndpoints = new Set<GeneralAccessEndpoint>([
'diplomacy.rollbackLetter',
'diplomacy.destroyLetter',
'betting.getList',
'general.getFrontStatus',
'yearbook.getHistory',
'messages.send',
'turns.getCommandTable',
]);
export const generalAccessLimitBeforeRecordEndpoints = new Set<GeneralAccessEndpoint>(['general.getFrontStatus']);
export type GeneralAccessState = {
generalId: number;
refreshScore: number;
+4
View File
@@ -1,3 +1,5 @@
import { buildGameEventChannel } from '@sammo-ts/common';
export interface TournamentKeys {
stateKey: string;
participantsKey: string;
@@ -5,6 +7,7 @@ export interface TournamentKeys {
bettingKey: string;
sourceRevisionKey: string;
sourceRevisionChannel: string;
realtimeEventChannel: string;
}
export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
@@ -14,4 +17,5 @@ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
bettingKey: `sammo:${profileName}:tournament:betting`,
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
realtimeEventChannel: buildGameEventChannel(profileName),
});
+1 -11
View File
@@ -9,7 +9,6 @@ import { IdempotentTurnDaemonTransport } from './daemon/idempotentTransport.js';
import { DuplicateInputEventError, executeInputEvent } from './inputEventBoundary.js';
import {
formatGeneralAccessLimitMessage,
generalAccessLimitBeforeRecordEndpoints,
generalAccessLimitEndpoints,
getGeneralAccessState,
recordGeneralAccessWeight,
@@ -103,17 +102,8 @@ const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input,
return next();
}
const endpoint = path as GeneralAccessEndpoint;
if (generalAccessLimitBeforeRecordEndpoints.has(endpoint)) {
const state = await getGeneralAccessState(ctx);
if (state?.level === 2) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: formatGeneralAccessLimitMessage(state),
});
}
}
await recordGeneralAccessWeight(ctx, weight);
if (generalAccessLimitEndpoints.has(endpoint) && !generalAccessLimitBeforeRecordEndpoints.has(endpoint)) {
if (generalAccessLimitEndpoints.has(endpoint)) {
const state = await getGeneralAccessState(ctx);
if (state?.level === 2) {
throw new TRPCError({
@@ -383,6 +383,10 @@ integration('general access tracking persistence', () => {
} as unknown as GameApiContext;
const boundaryCaller = endpointBoundaryRouter.createCaller(context);
const dashboardCaller = appRouter.createCaller(context);
await expect(dashboardCaller.general.getFrontStatus()).resolves.toBeDefined();
await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull();
await expect(boundaryCaller.world.getGeneralDirectory({ accepted: false as true })).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
@@ -101,7 +101,6 @@ describe('general access tracking', () => {
'diplomacy.getLetters': 2,
'battle.getGeneralDetail': 1,
'betting.getList': 1,
'general.getFrontStatus': 1,
'yearbook.getHistory': 1,
'world.getGlobalInfo': 1,
'nation.getBattleCenter': 1,
@@ -131,6 +130,7 @@ describe('general access tracking', () => {
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', {}, 'che')).toBe(1);
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'che' }, 'che')).toBe(1);
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'hwe' }, 'che')).toBeNull();
expect(resolveGeneralAccessEndpointWeight('general.getFrontStatus', {}, 'che')).toBeUndefined();
expect(resolveGeneralAccessEndpointWeight('unknown.path', {}, 'che')).toBeUndefined();
});
@@ -52,6 +52,7 @@ describe('public realtime event privacy boundary', () => {
reservedTurns: true,
records: true,
frontStatus: false,
tournament: false,
},
});
const serialized = JSON.stringify(publicEvent);
@@ -108,10 +109,33 @@ describe('public realtime event privacy boundary', () => {
reservedTurns: true,
records: true,
frontStatus: true,
tournament: true,
},
});
});
it('redacts tournament state changes to one global boolean invalidation', () => {
const publicEvent = toPublicRealtimeEvent({ type: 'tournamentChanged' }, [viewer]);
expect(publicEvent).toEqual({
type: 'readModelInvalidated',
invalidation: {
context: false,
lobby: false,
map: false,
commands: false,
contacts: false,
boardAccess: false,
reservedTurns: false,
records: false,
frontStatus: false,
tournament: true,
},
});
expect(JSON.stringify(publicEvent)).not.toMatch(/revision|source|channel|time|generalId/u);
expect(shouldReloadRealtimeViewerIdentity({ type: 'tournamentChanged' }, viewer)).toBe(false);
});
it('filters message events per viewer and removes mailbox, sender, message, and time fields', () => {
const event: RealtimeEvent = {
type: 'messageCreated',
+6
View File
@@ -42,6 +42,12 @@ describe('parseRealtimeEvent', () => {
expect(parseRealtimeEvent('not-json')).toBeNull();
expect(parseRealtimeEvent(JSON.stringify({}))).toBeNull();
});
it('accepts the minimal tournament state wake-up', () => {
expect(parseRealtimeEvent(JSON.stringify({ type: 'tournamentChanged' }))).toEqual({
type: 'tournamentChanged',
});
});
});
describe('buildGameEventChannel', () => {
@@ -10,12 +10,27 @@ const integration = describe.skipIf(!process.env.REDIS_URL);
integration('TournamentStore Redis source revision', () => {
let connector: RedisConnector;
let subscriber: RedisConnector;
const profile = `test:tournament-revision:${randomUUID()}`;
const keys = buildTournamentKeys(profile);
const sourceMessages: string[] = [];
const realtimeMessages: string[] = [];
const waitForLength = async (values: readonly string[], length: number): Promise<void> => {
const deadline = Date.now() + 1_000;
while (values.length < length && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
expect(values).toHaveLength(length);
};
beforeAll(async () => {
connector = createRedisConnector(resolveRedisConfigFromEnv());
subscriber = createRedisConnector(resolveRedisConfigFromEnv());
await connector.connect();
await subscriber.connect();
await subscriber.client.subscribe(keys.sourceRevisionChannel, (message) => sourceMessages.push(message));
await subscriber.client.subscribe(keys.realtimeEventChannel, (message) => realtimeMessages.push(message));
});
afterAll(async () => {
@@ -27,6 +42,11 @@ integration('TournamentStore Redis source revision', () => {
keys.bettingKey,
keys.sourceRevisionKey,
]);
if (subscriber) {
await subscriber.client.unsubscribe(keys.sourceRevisionChannel);
await subscriber.client.unsubscribe(keys.realtimeEventChannel);
await subscriber.disconnect();
}
await connector.disconnect();
});
@@ -44,4 +64,37 @@ integration('TournamentStore Redis source revision', () => {
await expect(store.getSourceRevision()).resolves.toBe('20');
await expect(store.getMatches()).resolves.toHaveLength(1);
});
it('publishes the main wake-up only when the atomic state write changes stage', async () => {
const store = new TournamentStore(connector.client, keys);
const sourceBefore = sourceMessages.length;
const realtimeBefore = realtimeMessages.length;
const baseState = {
stage: 1,
phase: 0,
type: 0 as const,
auto: true,
openYear: 185,
openMonth: 2,
termSeconds: 10,
nextAt: '2026-08-17T00:00:00.000Z',
};
await store.setState(baseState);
await waitForLength(sourceMessages, sourceBefore + 1);
await waitForLength(realtimeMessages, realtimeBefore + 1);
await store.setState({ ...baseState, phase: 1 });
await waitForLength(sourceMessages, sourceBefore + 2);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(realtimeMessages).toHaveLength(realtimeBefore + 1);
await store.setState({ ...baseState, stage: 2, phase: 0 });
await waitForLength(sourceMessages, sourceBefore + 3);
await waitForLength(realtimeMessages, realtimeBefore + 2);
expect(realtimeMessages.slice(realtimeBefore)).toEqual([
JSON.stringify({ type: 'tournamentChanged' }),
JSON.stringify({ type: 'tournamentChanged' }),
]);
});
});
@@ -35,7 +35,8 @@ class AtomicMemoryRedis {
}
async publish(channel: string, message: string): Promise<number> {
this.events.push(`publish:${JSON.parse(message).sourceRevision as string}`);
const payload = JSON.parse(message) as { sourceRevision?: string; type?: string };
this.events.push(`publish:${payload.sourceRevision ?? payload.type ?? 'unknown'}`);
this.published.push({ channel, message });
return 1;
}
@@ -71,6 +72,29 @@ describe('TournamentStore source revision', () => {
expect(redis.published).toEqual([]);
});
it('wakes the main realtime channel only for shared state writes', async () => {
const redis = new AtomicMemoryRedis();
const keys = buildTournamentKeys('che:default');
const store = new TournamentStore(redis, keys);
await store.setState({
stage: 1,
phase: 0,
type: 0,
auto: true,
openYear: 185,
openMonth: 2,
termSeconds: 10,
nextAt: '2026-08-17T00:00:00.000Z',
});
expect(redis.events).toEqual(['commit:1', 'publish:1', 'publish:tournamentChanged']);
expect(redis.published).toEqual([
{ channel: keys.sourceRevisionChannel, message: JSON.stringify({ sourceRevision: '1' }) },
{ channel: keys.realtimeEventChannel, message: JSON.stringify({ type: 'tournamentChanged' }) },
]);
});
it('serializes concurrent writes into monotonic per-profile revisions', async () => {
const redis = new AtomicMemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('pwe:default'));
+131 -7
View File
@@ -238,7 +238,12 @@ export class GeneralAI {
private devRate: Record<string, number> | null = null;
private categorizedCities = false;
private categorizedGenerals = false;
private promotionPatches: Array<{ generalId: number; officerLevel: number; officerCity: number }> = [];
private promotionPatches: Array<{
generalId: number;
officerLevel: number;
officerCity: number;
permission?: string;
}> = [];
private promotionNationMeta: Record<string, unknown> | null = null;
private readonly initialGeneralMeta: Record<string, unknown>;
@@ -433,7 +438,7 @@ export class GeneralAI {
}
consumePromotionPatches(): {
generals: Array<{ generalId: number; officerLevel: number; officerCity: number }>;
generals: Array<{ generalId: number; officerLevel: number; officerCity: number; permission?: string }>;
nationMeta: Record<string, unknown> | null;
} {
const result = {
@@ -1068,6 +1073,7 @@ export class GeneralAI {
}
const minChiefLevel = this.nation.level >= 6 ? 5 : this.nation.level >= 4 ? 7 : this.nation.level >= 2 ? 9 : 11;
let chiefSet = readMetaNumber(asRecord(this.nation.meta), 'chief_set', 0);
const initialChiefSet = chiefSet;
const generals = this.worldRef
.listGenerals()
.filter((candidate) => candidate.nationId === this.nation!.id)
@@ -1080,12 +1086,102 @@ export class GeneralAI {
});
const effectiveOfficerLevel = new Map(generals.map((candidate) => [candidate.id, candidate.officerLevel]));
let userChiefCount = 0;
const worldKillturn = readMetaNumber(asRecord(this.world.meta), 'killturn', 0);
const minUserKillturn = worldKillturn - Math.trunc(240 / this.turnTermMinutes);
const minNpcKillturn = 36;
for (let chiefLevel = minChiefLevel; chiefLevel <= 12; chiefLevel += 1) {
const chief = this.chiefGenerals[chiefLevel];
if (!chief) {
continue;
}
const penalty = asRecord(chief.penalty);
const killturn = readRequiredMetaNumber(asRecord(chief.meta), 'killturn', `generalId=${chief.id}`);
if (chief.npcState < 2 && killturn >= minUserKillturn && penalty.noAmbassador !== true) {
userChiefCount += 1;
chief.meta = { ...chief.meta, permission: 'ambassador' };
this.promotionPatches.push({
generalId: chief.id,
officerLevel: chief.officerLevel,
officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0),
permission: 'ambassador',
});
}
}
const minBelong = Math.min(readMetaNumber(asRecord(this.general.meta), 'belong', 0) - 1, 3);
const availableUserChiefCount = Object.values(this.userGenerals).filter((candidate) => {
const penalty = asRecord(candidate.penalty);
const killturn = readRequiredMetaNumber(asRecord(candidate.meta), 'killturn', `generalId=${candidate.id}`);
return (
killturn >= minUserKillturn &&
readMetaNumber(asRecord(candidate.meta), 'belong', 0) >= minBelong &&
penalty.noChief !== true
);
}).length;
if (userChiefCount === 0 && availableUserChiefCount > 0 && (chiefSet & (1 << 11)) === 0) {
const userCandidates = Object.values(this.userGenerals).sort((left, right) => {
const leftPenalty = asRecord(left.penalty);
const rightPenalty = asRecord(right.penalty);
if ((leftPenalty.noChief === true) !== (rightPenalty.noChief === true)) {
return leftPenalty.noChief === true ? 1 : -1;
}
if ((leftPenalty.noAmbassador === true) !== (rightPenalty.noAmbassador === true)) {
return leftPenalty.noAmbassador === true ? 1 : -1;
}
return right.stats.leadership - left.stats.leadership;
});
for (const candidate of userCandidates) {
const penalty = asRecord(candidate.penalty);
const killturn = readRequiredMetaNumber(
asRecord(candidate.meta),
'killturn',
`generalId=${candidate.id}`
);
if (
penalty.noChief === true ||
killturn < minUserKillturn ||
readMetaNumber(asRecord(candidate.meta), 'belong', 0) < minBelong ||
candidate.officerLevel > 4
) {
continue;
}
const permission = penalty.noAmbassador === true ? undefined : 'ambassador';
candidate.officerLevel = 11;
candidate.meta = {
...candidate.meta,
officer_city: 0,
...(permission ? { permission } : {}),
};
this.promotionPatches.push({
generalId: candidate.id,
officerLevel: 11,
officerCity: 0,
...(permission ? { permission } : {}),
});
effectiveOfficerLevel.set(candidate.id, 11);
chiefSet |= 1 << 11;
userChiefCount += 1;
break;
}
}
for (let chiefLevel = 11; chiefLevel >= minChiefLevel; chiefLevel -= 1) {
if ((chiefSet & (1 << chiefLevel)) !== 0 || this.general.officerLevel === chiefLevel) {
continue;
}
const oldChief = generals.find((candidate) => candidate.officerLevel === chiefLevel);
if (oldChief) {
const oldChiefKillturn = readRequiredMetaNumber(
asRecord(oldChief.meta),
'killturn',
`generalId=${oldChief.id}`
);
if (oldChief.npcState < 2 && oldChiefKillturn >= minChiefLevel) {
continue;
}
const newChiefProbability = this.rng.nextBool(0.1) ? 1 : 0;
// GeneralAI.php performs a second nextBool(0) call on the
// rejection path. Preserve that consumption for the shared
@@ -1095,7 +1191,7 @@ export class GeneralAI {
}
}
const nextChief = generals.find((candidate) => {
if ((effectiveOfficerLevel.get(candidate.id) ?? candidate.officerLevel) > 4 || candidate.npcState < 2) {
if ((effectiveOfficerLevel.get(candidate.id) ?? candidate.officerLevel) > 4) {
return false;
}
const killturn = readRequiredMetaNumber(
@@ -1103,7 +1199,13 @@ export class GeneralAI {
'killturn',
`generalId=${candidate.id}`
);
if (killturn < 36) {
if (candidate.npcState < 2 && killturn < minUserKillturn) {
return false;
}
if (candidate.npcState >= 2 && killturn < minNpcKillturn) {
return false;
}
if (asRecord(candidate.penalty).noChief === true) {
return false;
}
if (chiefLevel !== 11 && chiefLevel % 2 === 0 && candidate.stats.strength < this.aiConst.chiefStatMin) {
@@ -1116,6 +1218,9 @@ export class GeneralAI {
) {
return false;
}
if (candidate.npcState < 2 && userChiefCount >= 3) {
return false;
}
return true;
});
if (!nextChief) {
@@ -1124,18 +1229,37 @@ export class GeneralAI {
if (oldChief) {
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
}
this.promotionPatches.push({ generalId: nextChief.id, officerLevel: chiefLevel, officerCity: 0 });
const permission =
nextChief.npcState < 2 && asRecord(nextChief.penalty).noAmbassador !== true ? 'ambassador' : undefined;
if (nextChief.npcState < 2) {
userChiefCount += 1;
}
this.promotionPatches.push({
generalId: nextChief.id,
officerLevel: chiefLevel,
officerCity: 0,
...(permission ? { permission } : {}),
});
if (process.env.CORE_AI_TRACE_SEQUENCE === '1') {
process.stdout.write(
`AI_PROMOTION_TRACE ${JSON.stringify({ engine: 'core', mode: 'lord', actor: this.general.id, chiefLevel, picked: nextChief.id })}\n`
);
}
nextChief.meta = {
...nextChief.meta,
officer_city: 0,
...(permission ? { permission } : {}),
};
effectiveOfficerLevel.set(nextChief.id, chiefLevel);
chiefSet |= 1 << chiefLevel;
}
if (this.promotionPatches.length > 0) {
this.promotionNationMeta = { ...this.nation.meta, chief_set: chiefSet };
if (chiefSet !== initialChiefSet) {
this.promotionNationMeta = {
...this.nation.meta,
...(this.promotionNationMeta ?? {}),
chief_set: chiefSet,
};
}
}
@@ -1690,7 +1690,13 @@ export const createReservedTurnHandler = async (options: {
const patch = {
officerLevel: entry.officerLevel,
...(promotedGeneral
? { meta: { ...promotedGeneral.meta, officer_city: entry.officerCity } }
? {
meta: {
...promotedGeneral.meta,
officer_city: entry.officerCity,
...(entry.permission ? { permission: entry.permission } : {}),
},
}
: {}),
};
patches.generals.push({ id: entry.generalId, patch });
@@ -1,7 +1,12 @@
import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra';
import { randomUUID } from 'node:crypto';
import { writeTournamentProjection, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common';
import {
buildGameEventChannel,
writeTournamentProjection,
type TurnDaemonCommand,
type TurnDaemonCommandResult,
} from '@sammo-ts/common';
import type { GatewayAdminActionRecord, GatewayAdminActionResult } from './gatewayAdminActions.js';
@@ -74,8 +79,10 @@ const shiftTournamentClock = async (
): Promise<boolean> => {
const stateKey = `sammo:${profileName}:tournament:state`;
const sourceKeys = {
stateKey,
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
realtimeEventChannel: buildGameEventChannel(profileName),
};
const lockKey = `${stateKey}:mutation-lock`;
const token = randomUUID();
@@ -1,4 +1,10 @@
import { asRecord, LiteHashDRBG, RandUtil, writeTournamentProjection } from '@sammo-ts/common';
import {
asRecord,
buildGameEventChannel,
LiteHashDRBG,
RandUtil,
writeTournamentProjection,
} from '@sammo-ts/common';
import type { RedisConnector } from '@sammo-ts/infra';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
@@ -69,12 +75,13 @@ export const createTournamentAutoStartHandler = (options: {
now?: () => Date;
}): TurnCalendarHandler => {
const keys = {
state: `sammo:${options.profileName}:tournament:state`,
stateKey: `sammo:${options.profileName}:tournament:state`,
participants: `sammo:${options.profileName}:tournament:participants`,
matches: `sammo:${options.profileName}:tournament:matches`,
betting: `sammo:${options.profileName}:tournament:betting`,
sourceRevisionKey: `sammo:${options.profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${options.profileName}:tournament:source-changed`,
realtimeEventChannel: buildGameEventChannel(options.profileName),
};
return {
onMonthChanged: async (context) => {
@@ -85,7 +92,7 @@ export const createTournamentAutoStartHandler = (options: {
if (!world || !redis || config.tournamentTrig !== true) {
return;
}
const previousState = safeJsonParse<TournamentState>(await redis.get(keys.state));
const previousState = safeJsonParse<TournamentState>(await redis.get(keys.stateKey));
if (previousState && previousState.stage > 0) {
return;
}
@@ -145,7 +152,7 @@ export const createTournamentAutoStartHandler = (options: {
{ key: keys.participants, value: [] },
{ key: keys.matches, value: [] },
{ key: keys.betting, value: [] },
{ key: keys.state, value: nextState },
{ key: keys.stateKey, value: nextState },
]);
const [typeText, generalTypeText] = TOURNAMENT_TEXT[type] ?? TOURNAMENT_TEXT[0];
@@ -3,6 +3,7 @@ import type { City, General, Nation } from '@sammo-ts/logic';
import { createRefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js';
import { GeneralAI } from '../src/turn/ai/generalAi.js';
import type { TurnGeneral } from '../src/turn/types.js';
import {
calculateRecentWarTurn,
resolveLegacyAiStats,
@@ -23,10 +24,7 @@ import {
doNPC전방발령,
doNPC후방발령,
} from '../src/turn/ai/generalAi/nation/assignments/npcAssignments.js';
import {
do부대구출발령,
do부대후방발령,
} from '../src/turn/ai/generalAi/nation/assignments/troopAssignments.js';
import { do부대구출발령, do부대후방발령 } from '../src/turn/ai/generalAi/nation/assignments/troopAssignments.js';
type Candidate = {
action: string;
@@ -397,6 +395,311 @@ const makeAi = (
} as unknown as GeneralAI;
};
const makePromotionGeneral = (overrides: Partial<TurnGeneral>): TurnGeneral => ({
...baseGeneral(),
...overrides,
stats: { ...baseGeneral().stats, ...overrides.stats },
meta: { ...baseGeneral().meta, belong: 1, ...overrides.meta },
});
const makePromotionAi = (options: {
ruler: TurnGeneral;
generals: TurnGeneral[];
nation?: Partial<Nation>;
userGenerals?: TurnGeneral[];
chiefGenerals?: TurnGeneral[];
npcWarGenerals?: TurnGeneral[];
npcCivilGenerals?: TurnGeneral[];
userWarGenerals?: TurnGeneral[];
userCivilGenerals?: TurnGeneral[];
rng?: ScriptedRng;
currentMonth?: number;
}): GeneralAI => {
const nation = {
...baseNation(),
level: 1,
...options.nation,
meta: { chief_set: 0, ...options.nation?.meta },
};
const asGeneralRecord = (entries: TurnGeneral[] = []): Record<number, TurnGeneral> =>
Object.fromEntries(entries.map((general) => [general.id, general]));
const asChiefRecord = (entries: TurnGeneral[] = []): Record<number, TurnGeneral> =>
Object.fromEntries(entries.map((general) => [general.officerLevel, general]));
return Object.assign(Object.create(GeneralAI.prototype), {
general: options.ruler,
nation,
world: {
id: 1,
currentYear: 190,
currentMonth: options.currentMonth ?? 3,
tickSeconds: 600,
lastTurnTime: new Date('0190-03-01T00:00:00Z'),
meta: { killturn: 100 },
},
worldRef: {
listGenerals: () => options.generals,
},
turnTermMinutes: 10,
aiConst: { chiefStatMin: 70 },
rng: options.rng ?? makeRng(),
userGenerals: asGeneralRecord(options.userGenerals),
chiefGenerals: asChiefRecord(options.chiefGenerals),
npcWarGenerals: asGeneralRecord(options.npcWarGenerals),
npcCivilGenerals: asGeneralRecord(options.npcCivilGenerals),
userWarGenerals: asGeneralRecord(options.userWarGenerals),
userCivilGenerals: asGeneralRecord(options.userCivilGenerals),
promotionPatches: [],
promotionNationMeta: null,
}) as GeneralAI;
};
const chooseNpcPromotion = (ai: GeneralAI): void =>
(ai as unknown as { chooseNpcPromotion: () => void }).chooseNpcPromotion();
const chooseNonLordPromotion = (ai: GeneralAI): void =>
(ai as unknown as { chooseNonLordPromotion: () => void }).chooseNonLordPromotion();
describe('legacy NPC user-chief promotion parity', () => {
it('appoints the first active user as advisor when the NPC ruler tenure threshold is already met', () => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
npcState: 2,
meta: { killturn: 100, belong: 2 },
});
const user = makePromotionGeneral({
id: 2,
name: '신규유저',
npcState: 0,
stats: { leadership: 40, strength: 40, intelligence: 40 },
meta: { killturn: 100, belong: 1 },
});
const ai = makePromotionAi({
ruler,
generals: [ruler, user],
userGenerals: [user],
chiefGenerals: [ruler],
});
chooseNpcPromotion(ai);
expect(ai.consumePromotionPatches()).toEqual({
generals: [{ generalId: 2, officerLevel: 11, officerCity: 0, permission: 'ambassador' }],
nationMeta: expect.objectContaining({ chief_set: 1 << 11 }),
});
});
it('waits for belong 3 before forcing a user over a stronger NPC under an established NPC ruler', () => {
const run = (belong: number) => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
npcState: 2,
meta: { killturn: 100, belong: 4 },
});
const npc = makePromotionGeneral({
id: 2,
name: '강한NPC',
npcState: 2,
stats: { leadership: 100, strength: 100, intelligence: 100 },
meta: { killturn: 100, belong: 4 },
});
const user = makePromotionGeneral({
id: 3,
name: '유저후보',
npcState: 0,
stats: { leadership: 80, strength: 80, intelligence: 80 },
meta: { killturn: 100, belong },
});
const ai = makePromotionAi({
ruler,
generals: [ruler, npc, user],
userGenerals: [user],
chiefGenerals: [ruler],
});
chooseNpcPromotion(ai);
return ai.consumePromotionPatches().generals;
};
expect(run(1)).toEqual([{ generalId: 2, officerLevel: 11, officerCity: 0 }]);
expect(run(3)).toEqual([{ generalId: 3, officerLevel: 11, officerCity: 0, permission: 'ambassador' }]);
});
it('prefers an ambassador-eligible user over a higher-leadership no-ambassador user', () => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
npcState: 2,
meta: { killturn: 100, belong: 2 },
});
const blockedAmbassador = makePromotionGeneral({
id: 2,
npcState: 0,
stats: { leadership: 95, strength: 80, intelligence: 80 },
meta: { killturn: 100, belong: 1 },
penalty: { noAmbassador: true },
});
const eligible = makePromotionGeneral({
id: 3,
npcState: 0,
stats: { leadership: 70, strength: 80, intelligence: 80 },
meta: { killturn: 100, belong: 1 },
});
const ai = makePromotionAi({
ruler,
generals: [ruler, blockedAmbassador, eligible],
userGenerals: [blockedAmbassador, eligible],
chiefGenerals: [ruler],
});
chooseNpcPromotion(ai);
expect(ai.consumePromotionPatches().generals).toEqual([
{ generalId: 3, officerLevel: 11, officerCity: 0, permission: 'ambassador' },
]);
});
it('does not appoint a user carrying the no-chief penalty', () => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
npcState: 2,
meta: { killturn: 100, belong: 2 },
});
const blocked = makePromotionGeneral({
id: 2,
npcState: 0,
stats: { leadership: 100, strength: 100, intelligence: 100 },
meta: { killturn: 100, belong: 1 },
penalty: { noChief: true },
});
const ai = makePromotionAi({
ruler,
generals: [ruler, blocked],
userGenerals: [blocked],
chiefGenerals: [ruler],
});
chooseNpcPromotion(ai);
expect(ai.consumePromotionPatches()).toEqual({ generals: [], nationMeta: null });
});
it('does not appoint a fourth user chief in the ordinary NPC-ruler fill pass', () => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
npcState: 2,
meta: { killturn: 100, belong: 4 },
});
const existingChiefs = [11, 10, 9].map((officerLevel, index) =>
makePromotionGeneral({
id: index + 2,
npcState: 0,
officerLevel,
meta: { killturn: 100, belong: 4, officer_city: 0 },
})
);
const candidate = makePromotionGeneral({
id: 5,
npcState: 0,
stats: { leadership: 100, strength: 100, intelligence: 100 },
meta: { killturn: 100, belong: 4 },
});
const ai = makePromotionAi({
ruler,
generals: [ruler, ...existingChiefs, candidate],
nation: { level: 6 },
userGenerals: [...existingChiefs, candidate],
chiefGenerals: [ruler, ...existingChiefs],
});
chooseNpcPromotion(ai);
const promotion = ai.consumePromotionPatches();
const result = promotion.generals;
expect(result.filter((entry) => entry.generalId === candidate.id)).toEqual([]);
expect(result).toHaveLength(3);
expect(promotion.nationMeta).toBeNull();
expect(result).toEqual(
expect.arrayContaining(
existingChiefs.map((chief) => ({
generalId: chief.id,
officerLevel: chief.officerLevel,
officerCity: 0,
permission: 'ambassador',
}))
)
);
});
it('lets an NPC non-ruler fill an open seat with a user immediately when no NPC pool exists', () => {
const actor = makePromotionGeneral({
id: 1,
officerLevel: 10,
npcState: 2,
meta: { killturn: 100, belong: 4 },
});
const user = makePromotionGeneral({
id: 2,
npcState: 0,
stats: { leadership: 40, strength: 40, intelligence: 40 },
meta: { killturn: 100, belong: 1 },
});
const ai = makePromotionAi({
ruler: actor,
generals: [actor, user],
userWarGenerals: [user],
chiefGenerals: [actor],
});
chooseNonLordPromotion(ai);
expect(ai.consumePromotionPatches().generals).toEqual([{ generalId: 2, officerLevel: 11, officerCity: 0 }]);
});
it('runs automatic appointments only on the quarterly NPC nation turn', () => {
const run = (currentMonth: number) => {
const ruler = makePromotionGeneral({
id: 1,
officerLevel: 12,
npcState: 2,
meta: { killturn: 100, belong: 2 },
});
const user = makePromotionGeneral({
id: 2,
npcState: 0,
meta: { killturn: 100, belong: 1 },
});
const ai = makePromotionAi({
ruler,
generals: [ruler, user],
userGenerals: [user],
chiefGenerals: [ruler],
currentMonth,
});
Object.assign(ai as unknown as Record<string, unknown>, {
updateInstance: () => undefined,
categorizeNationCities: () => undefined,
categorizeNationGeneral: () => undefined,
nationPolicy: { priority: [] },
buildNationCandidate: (action: string, args: Record<string, unknown>, reason: string) => ({
action,
args,
reason,
}),
});
ai.chooseNationTurn({ action: '휴식', args: {} });
return ai.consumePromotionPatches().generals;
};
expect(run(2)).toEqual([]);
expect(run(3)).toEqual([{ generalId: 2, officerLevel: 11, officerCity: 0, permission: 'ambassador' }]);
});
});
/**
* Expected branches are extracted from ref/sam hwe/sammo/GeneralAI.php
* at ng_compare@fe9ae978. These tests intentionally assert final command
@@ -1082,8 +1385,7 @@ describe('legacy NPC AI final-decision parity', () => {
nation: { rice: 100_000 },
generalActionModules: singleActionModuleStack({
eventHandlers: {},
onCalcStat: (_context, statName, value) =>
statName === 'leadership' ? Number(value) + 30 : value,
onCalcStat: (_context, statName, value) => (statName === 'leadership' ? Number(value) + 30 : value),
}),
});
ai.maxResourceActionAmount = 100_000;
@@ -299,4 +299,141 @@ describe('NPC 일반 내정 턴', () => {
})
);
});
it('NPC 군주 국가턴에서 신규 유저의 수뇌 직책과 외교 권한을 월드 상태에 반영한다', async () => {
const buildGeneral = (overrides: Partial<TurnGeneral>): TurnGeneral => {
const base: TurnGeneral = {
id: 1,
name: 'NPC군주',
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 80, strength: 80, intelligence: 80 },
turnTime: mockDate,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 100, belong: 2 },
officerLevel: 12,
experience: 0,
dedication: 0,
injury: 0,
gold: 2_000,
rice: 2_000,
crew: 0,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 2,
};
return {
...base,
...overrides,
stats: { ...base.stats, ...overrides.stats },
meta: { ...base.meta, ...overrides.meta },
};
};
const ruler = buildGeneral({});
const user = buildGeneral({
id: 2,
name: '신규유저',
npcState: 0,
officerLevel: 1,
meta: { killturn: 100, belong: 1 },
});
const city = {
id: 1,
name: '소성A',
nationId: 1,
level: 1,
state: 0,
population: 10_000,
populationMax: 20_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
supplyState: 1,
frontState: 0,
defence: 500,
defenceMax: 1_000,
wall: 500,
wallMax: 1_000,
meta: { trust: 98 },
};
const nation = {
id: 1,
name: 'NPC국가',
color: '#FF0000',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 50_000,
rice: 50_000,
power: 0,
level: 1,
typeCode: 'che_def',
meta: { chief_set: 0 },
};
const snapshot: TurnWorldSnapshot = {
generals: [ruler, user],
cities: [city] as any,
nations: [nation] as any,
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
map: MINIMAL_MAP as any,
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: { npcMessageFreqByDay: 144 },
environment: { mapName: 'npc_domestic_map', unitSet: 'default' },
},
scenarioMeta: { startYear: 189 } as any,
unitSet: {} as any,
};
const state: TurnWorldState = {
id: 1,
currentYear: 189,
currentMonth: 3,
tickSeconds: 600,
lastTurnTime: mockDate,
meta: { seed: 1, killturn: 100 },
};
const reservedTurnStore = new InMemoryReservedTurnStore(createMockPrisma() as any, {
maxGeneralTurns: 10,
maxNationTurns: 10,
});
await reservedTurnStore.loadAll();
const wrapper = { world: null as InMemoryTurnWorld | null };
const handler = await createReservedTurnHandler({
reservedTurns: reservedTurnStore,
scenarioConfig: snapshot.scenarioConfig,
scenarioMeta: snapshot.scenarioMeta,
map: MINIMAL_MAP as any,
unitSet: snapshot.unitSet,
getWorld: () => wrapper.world,
});
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
generalTurnHandler: handler,
});
wrapper.world = world;
world.executeGeneralTurn(ruler);
expect(world.getGeneralById(user.id)).toMatchObject({
officerLevel: 11,
meta: expect.objectContaining({ officer_city: 0, permission: 'ambassador' }),
});
expect(world.getNationById(nation.id)?.meta).toMatchObject({ chief_set: 1 << 11 });
});
});
+194 -4
View File
@@ -1,6 +1,6 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { expect, test, type Page, type Route } from '@playwright/test';
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, message: string) => ({
@@ -97,6 +97,7 @@ const readModelInvalidation = (
reservedTurns: boolean;
records: boolean;
frontStatus: boolean;
tournament: boolean;
}>
) => ({
context: false,
@@ -108,6 +109,7 @@ const readModelInvalidation = (
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
...overrides,
});
@@ -619,6 +621,97 @@ const gridColumnCount = async (page: Page, selector: string) =>
.first()
.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length);
const raisedButtonState = async (target: Locator) =>
target.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
top: rect.top,
bottom: rect.bottom,
height: rect.height,
backgroundColor: style.backgroundColor,
borderTopWidth: style.borderTopWidth,
borderLeftWidth: style.borderLeftWidth,
borderBottomWidth: style.borderBottomWidth,
borderBottomColor: style.borderBottomColor,
borderRadius: style.borderRadius,
marginTop: style.marginTop,
paddingTop: style.paddingTop,
paddingBottom: style.paddingBottom,
classNames: [...element.classList],
};
});
const pointerDownButtonState = async (page: Page, target: Locator) => {
const box = await target.boundingBox();
if (!box) throw new Error('raised button is not measurable');
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
const state = await raisedButtonState(target);
await page.mouse.move(1, 1);
await page.mouse.up();
return state;
};
const persistEnlargedRaisedButtonProbe = async (page: Page, source: Locator, name: string) => {
if (!artifactRoot) return;
const target = resolve(artifactRoot);
await mkdir(target, { recursive: true });
const probeId = `raised-button-probe-${name}`;
const hostId = `${probeId}-host`;
await source.evaluate(
(element, ids) => {
const host = document.createElement('div');
host.id = ids.hostId;
Object.assign(host.style, {
position: 'fixed',
inset: '20px auto auto 20px',
width: '390px',
height: '170px',
padding: '10px',
background: '#000',
zIndex: '2147483647',
overflow: 'hidden',
});
const stage = document.createElement('div');
Object.assign(stage.style, {
display: 'flow-root',
width: '90px',
transform: 'scale(4)',
transformOrigin: 'top left',
});
const probe = element.cloneNode(true) as HTMLElement;
probe.id = ids.probeId;
probe.classList.remove('active');
probe.removeAttribute('disabled');
probe.style.width = '90px';
stage.append(probe);
host.append(stage);
document.body.append(host);
},
{ hostId, probeId }
);
const host = page.locator(`#${hostId}`);
const probe = page.locator(`#${probeId}`);
const states: Record<string, Awaited<ReturnType<typeof raisedButtonState>>> = {};
states.default = await raisedButtonState(probe);
await host.screenshot({ path: resolve(target, `${name}-large-default.png`) });
await probe.hover();
states.hover = await raisedButtonState(probe);
await host.screenshot({ path: resolve(target, `${name}-large-hover.png`) });
const box = await probe.boundingBox();
if (!box) throw new Error('enlarged raised button is not measurable');
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
states.pointerDown = await raisedButtonState(probe);
await host.screenshot({ path: resolve(target, `${name}-large-pointer-down.png`) });
await page.mouse.move(1, 1);
await page.mouse.up();
await writeFile(resolve(target, `${name}-large-states.json`), `${JSON.stringify(states, null, 2)}\n`);
await host.evaluate((element) => element.remove());
};
const persistArtifact = async (page: Page, name: string) => {
if (!artifactRoot) return;
const target = resolve(artifactRoot);
@@ -1033,7 +1126,7 @@ test('pure NPC message senders are not rendered as reply targets', async ({ page
await persistArtifact(page, `${basePath.slice(1)}-npc-reply-targets-desktop-1200`);
});
test('main reserved-turn picker renders the Ref general category order', async ({ page }) => {
test('main reserved-turn picker renders the Ref category order and raised button depth', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 1,
permission: 0,
@@ -1081,16 +1174,68 @@ test('main reserved-turn picker renders the Ref general category order', async (
expect(desktopGeometry.columns.split(' ')).toHaveLength(3);
expect(desktopGeometry.rows).toBe(2);
expect(desktopGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
expect(desktopGeometry.categoryButton).toEqual({ height: 32, paddingTop: '6px', paddingBottom: '6px' });
expect(desktopGeometry.categoryButton).toEqual({ height: 35.5, paddingTop: '5.25px', paddingBottom: '5.25px' });
expect(desktopGeometry.commandButton).toEqual(desktopGeometry.categoryButton);
const strategyCategory = picker.getByRole('button', { name: '계략', exact: true });
await page.mouse.move(1, 1);
const categoryDefault = await raisedButtonState(strategyCategory);
expect(categoryDefault).toMatchObject({
height: 35.5,
backgroundColor: 'rgb(23, 61, 39)',
borderTopWidth: '0px',
borderLeftWidth: '1px',
borderBottomWidth: '4px',
borderBottomColor: 'rgb(21, 55, 35)',
borderRadius: '5.25px',
marginTop: '0px',
classNames: expect.arrayContaining(['legacy-button', 'legacy-button--lumen']),
});
await strategyCategory.hover();
const categoryHover = await raisedButtonState(strategyCategory);
expect(categoryHover).toMatchObject({ height: 34.5, borderBottomWidth: '3px', marginTop: '1px' });
expect(categoryHover.top).toBe(categoryDefault.top + 1);
expect(categoryHover.bottom).toBe(categoryDefault.bottom);
const categoryPointerDown = await pointerDownButtonState(page, strategyCategory);
expect(categoryPointerDown).toMatchObject({ height: 33.5, borderBottomWidth: '2px', marginTop: '2px' });
expect(categoryPointerDown.top).toBe(categoryDefault.top + 2);
expect(categoryPointerDown.bottom).toBe(categoryDefault.bottom);
await page.keyboard.press('Tab');
await strategyCategory.focus();
await expect(strategyCategory).toBeFocused();
await expect.poll(() => strategyCategory.evaluate((element) => element.matches(':focus-visible'))).toBe(true);
await strategyCategory.click();
await expect(strategyCategory).toHaveClass(/active/);
await expect(picker.locator('.command-item')).toHaveText(['화계']);
await page.mouse.move(1, 1);
const commandButton = picker.locator('.command-item').first();
const commandDefault = await raisedButtonState(commandButton);
expect(commandDefault).toMatchObject({
height: 35.5,
backgroundColor: 'rgb(48, 32, 22)',
borderTopWidth: '0px',
borderLeftWidth: '1px',
borderBottomWidth: '4px',
borderBottomColor: 'rgb(43, 29, 20)',
borderRadius: '5.25px',
marginTop: '0px',
classNames: expect.arrayContaining(['legacy-button', 'legacy-button--lumen']),
});
await commandButton.hover();
const commandHover = await raisedButtonState(commandButton);
expect(commandHover).toMatchObject({ height: 34.5, borderBottomWidth: '3px', marginTop: '1px' });
expect(commandHover.top).toBe(commandDefault.top + 1);
expect(commandHover.bottom).toBe(commandDefault.bottom);
const commandPointerDown = await pointerDownButtonState(page, commandButton);
expect(commandPointerDown).toMatchObject({ height: 33.5, borderBottomWidth: '2px', marginTop: '2px' });
expect(commandPointerDown.top).toBe(commandDefault.top + 2);
expect(commandPointerDown.bottom).toBe(commandDefault.bottom);
await page.keyboard.press('Tab');
await commandButton.focus();
await expect(commandButton).toBeFocused();
await expect.poll(() => commandButton.evaluate((element) => element.matches(':focus-visible'))).toBe(true);
await persistEnlargedRaisedButtonProbe(page, strategyCategory, 'turn-selector-category');
await persistEnlargedRaisedButtonProbe(page, commandButton, 'turn-selector-command');
await persistArtifact(page, `${basePath.slice(1)}-main-reserved-ref-categories-desktop-1200`);
await page.setViewportSize({ width: 500, height: 900 });
@@ -1118,7 +1263,11 @@ test('main reserved-turn picker renders the Ref general category order', async (
};
});
expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
expect(mobileGeometry.categoryButton).toEqual({ height: 32, paddingTop: '6px', paddingBottom: '6px' });
expect(mobileGeometry.categoryButton).toEqual({
height: 35.5,
paddingTop: '5.25px',
paddingBottom: '5.25px',
});
expect(mobileGeometry.commandButton).toEqual(mobileGeometry.categoryButton);
await persistArtifact(page, `${basePath.slice(1)}-main-reserved-ref-categories-mobile-500`);
});
@@ -1327,6 +1476,9 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
await page.locator('[data-main-target="commands"] .select-command').click();
const picker = page.getByTestId('command-picker');
await expect(picker).toBeVisible();
// The trigger can end up directly above a newly opened category button.
// Measure the default grid after leaving the intentional Lumen hover state.
await page.mouse.move(1, 1);
const pickerGeometry = await picker.evaluate((element) => {
const rect = element.getBoundingClientRect();
const editor = element.closest('.reserved-command-editor')?.getBoundingClientRect();
@@ -2150,6 +2302,22 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
await expect(page.locator('.general-title')).toContainText('메뉴검증장수');
await expect
.poll(() =>
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
)
.toBe(true);
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u);
const operationsBeforeTournament = state.operations.length;
state.stage = 1;
await emitReadModelInvalidation(page, readModelInvalidation({ tournament: true }));
await expect
.poll(() => state.operations.slice(operationsBeforeTournament), { timeout: 3_000 })
.toEqual(['dashboard.getContextBundleDelta', 'tournament.getState']);
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
await expect(page.locator('[data-navigation-id="tournament"]')).toHaveClass(/highlight/u);
await page.evaluate(() => {
const general = document.querySelector('[data-main-target="general"]');
@@ -2202,6 +2370,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
},
});
}
@@ -2270,6 +2439,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
reservedTurns: false,
records: false,
frontStatus: true,
tournament: false,
},
}
);
@@ -2427,6 +2597,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
},
}
);
@@ -2601,6 +2772,23 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
const followerPage = pages[followerIndex];
if (!leaderPage || !followerPage) throw new Error('realtime leader election failed');
const operationsBeforeTournament = state.operations.length;
state.stage = 1;
await emitReadModelInvalidation(leaderPage, readModelInvalidation({ tournament: true }));
await expect
.poll(() => state.operations.slice(operationsBeforeTournament), { timeout: 3_000 })
.toEqual(['dashboard.getContextBundleDelta', 'tournament.getState']);
await Promise.all(
pages.map((currentPage) =>
expect(currentPage.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중')
)
);
await Promise.all(
pages.map((currentPage) =>
expect(currentPage.locator('[data-navigation-id="tournament"]')).toHaveClass(/highlight/u)
)
);
const callsBeforeSharedRefresh = state.generalMeCalls;
state.generalName = '탭공유갱신장수';
await leaderPage.evaluate(() => {
@@ -2617,6 +2805,7 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
},
}
);
@@ -2644,6 +2833,7 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
},
}
);
@@ -130,7 +130,12 @@ const commandTitle = (command: CommandAvailability) =>
<button
v-for="category in categories"
:key="category.id"
:class="['category-btn', { active: selectedCategory === category.id }]"
:class="[
'category-btn',
'legacy-button',
'legacy-button--lumen',
{ active: selectedCategory === category.id },
]"
@click="selectedCategory = category.id"
>
{{ category.label }}
@@ -143,6 +148,8 @@ const commandTitle = (command: CommandAvailability) =>
:key="command.key"
:class="[
'command-item',
'legacy-button',
'legacy-button--lumen',
command.status === 'available' ? 'ok' : '',
command.status === 'blocked' ? 'blocked' : '',
command.status === 'blocked' && props.allowBlocked ? 'reservable' : '',
@@ -173,26 +180,19 @@ const commandTitle = (command: CommandAvailability) =>
gap: 0;
}
.category-btn,
.command-item {
min-height: 32px;
padding-block: 6px;
}
.category-btn {
border: 0;
border-right: 1px solid #666;
border-bottom: 1px solid #666;
.category-btn.legacy-button--lumen {
--legacy-button-bg: #173d27;
--legacy-button-border: #153723;
--legacy-button-color: #fff;
min-height: 0;
padding-inline: 4px;
background: #173d27;
color: #fff;
font-size: 12px;
cursor: pointer;
}
.category-btn.active {
background: #28633f;
color: #ffe38a;
.category-btn.legacy-button--lumen.active {
--legacy-button-bg: #28633f;
--legacy-button-border: #245939;
--legacy-button-color: #ffe38a;
}
.command-grid {
@@ -201,19 +201,17 @@ const commandTitle = (command: CommandAvailability) =>
gap: 0;
}
.command-item {
border: 0;
border-right: 1px solid #666;
border-bottom: 1px solid #666;
.command-item.legacy-button--lumen {
--legacy-button-bg: #302016 var(--sammo-texture-walnut);
--legacy-button-border: #2b1d14;
--legacy-button-color: #fff;
min-height: 0;
padding-inline: 5px;
display: flex;
align-items: center;
justify-content: center;
background: #302016 var(--sammo-texture-walnut);
color: #fff;
text-align: center;
font-size: 12px;
cursor: pointer;
}
.command-item.ok {
+20 -2
View File
@@ -46,6 +46,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>['turns'][number];
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
type TournamentState = Awaited<ReturnType<typeof trpc.tournament.getState.query>>;
type ContextBundleDelta = Awaited<ReturnType<typeof trpc.dashboard.getContextBundleDelta.query>>;
type DashboardReadModelPatch = {
contextSnapshot?: GeneralContext;
@@ -72,6 +73,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
generalRecords?: RecentRecord[];
worldHistory?: RecentRecord[];
frontStatus?: FrontStatus | null;
tournamentStage?: number;
};
type DashboardTabMessage =
{ kind: 'patch'; patch: DashboardReadModelPatch } | { kind: 'status'; status: 'idle' | 'connected' };
@@ -112,6 +114,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const generalRecords = ref<RecentRecord[]>([]);
const worldHistory = ref<RecentRecord[]>([]);
const frontStatus = ref<FrontStatus | null>(null);
const tournamentStage = ref(0);
const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null);
let lastGeneralRecordId = 0;
let lastWorldHistoryId = 0;
@@ -430,6 +433,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
} else if (patch.frontStatus !== undefined) {
updateFrontStatus(patch.frontStatus);
}
if (patch.tournamentStage !== undefined) {
tournamentStage.value = patch.tournamentStage;
}
if (patch.contextRevision !== undefined) {
contextRevision = patch.contextRevision;
contextSourceRevision = patch.contextSourceRevision ?? null;
@@ -476,6 +482,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
patch.generalRecords = toRaw(generalRecords.value);
patch.worldHistory = toRaw(worldHistory.value);
patch.frontStatus = toRaw(frontStatus.value);
patch.tournamentStage = tournamentStage.value;
return patch;
};
@@ -585,7 +592,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
frontStatusError.value = resolveErrorMessage(err);
return null;
});
const [layout, lobby, map, messageData, contacts, generalTurns, records, nextFrontStatus] =
const tournamentPromise = trpc.tournament.getState.query().catch(() => undefined);
const [layout, lobby, map, messageData, contacts, generalTurns, records, nextFrontStatus, tournamentState] =
await Promise.all([
layoutPromise,
trpc.lobby.info.query(),
@@ -595,6 +603,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
generalTurnsPromise,
recordsPromise,
frontStatusPromise,
tournamentPromise,
]);
general.value = structurallyShare(general.value, context.general);
@@ -617,6 +626,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (nextFrontStatus) {
updateFrontStatus(nextFrontStatus);
}
if (tournamentState !== undefined) {
tournamentStage.value = tournamentState?.stage ?? 0;
}
if (initializedMailboxGeneralId !== id) {
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
initializedMailboxGeneralId = id;
@@ -705,14 +717,18 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
return null;
})
: Promise.resolve(undefined);
const tournamentPromise: Promise<TournamentState | undefined> = plan.tournament
? trpc.tournament.getState.query().catch(() => undefined)
: Promise.resolve(undefined);
const [lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([
const [lobby, map, contacts, generalTurns, records, nextFrontStatus, tournamentState] = await Promise.all([
lobbyPromise,
mapPromise,
contactsPromise,
reservedPromise,
recordsPromise,
frontPromise,
tournamentPromise,
]);
const patch: DashboardReadModelPatch = { ...contextPatch };
@@ -733,6 +749,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
patch.worldHistory = nextWorldHistory;
}
if (nextFrontStatus) patch.frontStatus = nextFrontStatus;
if (tournamentState !== undefined) patch.tournamentStage = tournamentState?.stage ?? 0;
applyDashboardPatch(patch);
publishDashboardPatch(patch);
} catch (err) {
@@ -1241,6 +1258,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
generalRecords,
worldHistory,
frontStatus,
tournamentStage,
surveyNotice,
messageDraftText,
targetMailbox,
+2 -4
View File
@@ -27,7 +27,6 @@ const session = useSessionStore();
const dashboard = useMainDashboardStore();
const isMobile = useMediaQuery('(max-width: 939.98px)');
const tournamentStage = ref(0);
const npcMode = ref(0);
const {
@@ -52,6 +51,7 @@ const {
generalRecords,
worldHistory,
frontStatus,
tournamentStage,
surveyNotice,
messageDraftText,
targetMailbox,
@@ -107,12 +107,10 @@ const repeatGeneralTurns = (amount: number) => {
};
const loadMainData = async () => {
const [, state, worldState] = await Promise.all([
const [, worldState] = await Promise.all([
dashboard.loadMainData(),
trpc.tournament.getState.query().catch(() => null),
trpc.world.getState.query().catch(() => null),
]);
tournamentStage.value = state?.stage ?? 0;
npcMode.value = worldState?.config.npcMode ?? 0;
};
@@ -25,6 +25,7 @@ void test('last-turn-time-only events do not schedule any dashboard query', () =
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
});
});
@@ -46,6 +47,7 @@ void test('selects only the read models affected by the current identity', () =>
reservedTurns: true,
records: true,
frontStatus: false,
tournament: false,
});
});
@@ -79,6 +81,7 @@ void test('routes defence, tax-rate, and current-city-state events to their exac
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
});
const taxRate = resolveDashboardRefreshPlan(
@@ -100,6 +103,7 @@ void test('routes defence, tax-rate, and current-city-state events to their exac
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
});
const cityState = resolveDashboardRefreshPlan(
@@ -161,6 +165,7 @@ void test('refreshes only front status for a global survey projection change', (
reservedTurns: false,
records: false,
frontStatus: true,
tournament: false,
});
});
@@ -174,8 +179,8 @@ void test('targets a submitted survey projection to its own general', () => {
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus, false);
});
void test('keeps the access bundle projection-free for map, records, and front-status-only plans', () => {
for (const slice of ['map', 'records', 'frontStatus'] as const) {
void test('keeps the access bundle projection-free for independent read-model plans', () => {
for (const slice of ['map', 'records', 'frontStatus', 'tournament'] as const) {
const plan = { ...createEmptyRealtimeReadModelInvalidation(), [slice]: true };
assert.deepEqual(resolveDashboardContextBundleInclude(plan), {
context: false,
+20 -6
View File
@@ -272,9 +272,14 @@ transaction을 잡은 채 `turnDaemon.requestCommand()`의 별도 ENGINE transac
토너먼트 state/participants/matches/bets는 현재 Redis가 원본이므로 PostgreSQL
revision과 원자적으로 묶을 수 없다. `TournamentStore`가 state write와 Redis domain
revision 증가를 같은 Redis transaction 또는 Lua script로 수행한다. 저장 뒤 별도
`publish()` 두 호출로 끝내지 않는다. 장기 durability 요구가 생기면 tournament state
자체를 PostgreSQL 소유로 옮기는 별도 migration으로 다룬다.
revision 증가를 같은 Lua script로 수행한다. source revision은 참가자·대진·베팅을
포함한 모든 projection write에서 증가하지만 메인 화면 wake-up은 Lua 안에서 이전/다음
`state.stage`를 비교해 실제 단계가 바뀔 때만 결정한다. commit 뒤
`tournamentChanged`를 공용 game event channel에 best-effort publish하며, API는 이를
식별자·revision 없는 public `tournament: true` invalidation으로 바꾼다. 참가 등록,
대진 결과, 같은 stage 안의 phase/timer/정산 flag write는 300 viewer를 깨우지 않는다.
장기 durability 요구가 생기면 tournament state 자체를 PostgreSQL 소유로 옮기는 별도
migration으로 다룬다.
### 국가 베팅과 direct writer
@@ -352,7 +357,9 @@ test가 완료되어 post-deploy one-off로 안전하게 활성화할 수 있다
meta/head 누락, DB/Redis 오류에는 shared cache를 완전히 우회해 full compute한다.
- 개인 `spyList`, `shownByGeneralList`, `myCity`, `myNation`은 request에서 계속 조합한다.
- tournament는 API store, 월 자동 개막과 runtime clock shift 모두 payload와 profile source
revision을 같은 Lua invocation으로 갱신하고 commit 뒤에만 best-effort publish한다.
revision을 같은 Lua invocation으로 갱신한다. stage transition만 main realtime channel에
best-effort publish하고, 같은 stage의 phase/participant/match/bet write는 source revision만
진행한다.
- records는 기존 `lastGeneralRecordId`/`lastWorldHistoryId` 증분 조회를 유지하되 해당
domain이 선택되지 않으면 query하지 않는다.
@@ -374,6 +381,13 @@ manual refresh, visible 복귀와 realtime 재활성화는 cadence를 기다리
snapshot을 한 번 읽는다. 숫자는 실제 혼합 부하 결과에 따라 조정하며, 부하만 낮추기
위해 5초를 초과하지 않는다.
메인 dashboard의 `tournamentStage`는 store가 소유한다. visible leader 탭이
`tournament: true`를 받으면 기존 access-only gate 뒤 `tournament.getState`만 읽어 stage
patch를 만들고, 같은 profile/account의 follower 탭은 BroadcastChannel patch를 적용한다.
따라서 상단 `토너먼트:` 문구, 국가 메뉴와 모바일 메뉴의 stage 강조가 한 값으로 함께
갱신된다. Redis/API 오류에는 현재 stage를 거짓 0으로 덮지 않고 다음 event, 사용자
`갱 신`, visible 복귀 snapshot으로 복구한다.
## 구현 단계와 commit 경계
### Phase A: 저위험 read 절감
@@ -414,13 +428,13 @@ snapshot을 한 번 읽는다. 숫자는 실제 혼합 부하 결과에 따라
5. 결과에 따라 pool, cadence, cache와 worker concurrency를 조정하고 전체 benchmark를
다시 실행한다.
### 2026-08-16 구현 상태
### 2026-08-17 구현 상태
| Phase | 상태 | 현재 근거 |
| --- | --- | --- |
| A | 완료 | all-false access gate, frontend 강제 context 제거, Chromium realtime trace |
| B | 완료 | typed journal, PostgreSQL revision/outbox/meta, engine/API 원자 writer, retry dispatcher, 86 mutation inventory |
| C | 완료 | dashboard revision-first, auth/global dependency, durable map cache, 모든 tournament Redis writer 원자화, coverage v1 activation/rollback integration |
| C | 완료 | dashboard revision-first, auth/global dependency, durable map cache, 모든 tournament Redis writer 원자화, stage-only main realtime invalidation, coverage v1 activation/rollback integration |
| D | 부분 완료 | E1 1,200장수 1개월 deterministic profile과 300 SSE/HTTP 짧은 calibration 완료. E2 actual daemon DB flush 및 30분 M1/R1은 미실행 |
`부분 완료`는 capacity 합격을 뜻하지 않는다. 이 작업의 수용 추산은 아래 실제 짧은
+17 -1
View File
@@ -51,6 +51,8 @@ export interface RealtimeReadModelInvalidation {
reservedTurns: boolean;
records: boolean;
frontStatus: boolean;
/** Shared tournament stage shown by the main dashboard changed. */
tournament: boolean;
}
export interface RealtimeViewerIdentity {
@@ -69,6 +71,7 @@ export const createEmptyRealtimeReadModelInvalidation = (): RealtimeReadModelInv
reservedTurns: false,
records: false,
frontStatus: false,
tournament: false,
});
export const createFullRealtimeReadModelInvalidation = (): RealtimeReadModelInvalidation => ({
@@ -81,6 +84,7 @@ export const createFullRealtimeReadModelInvalidation = (): RealtimeReadModelInva
reservedTurns: true,
records: true,
frontStatus: true,
tournament: true,
});
export const mergeRealtimeReadModelInvalidations = (
@@ -96,6 +100,7 @@ export const mergeRealtimeReadModelInvalidations = (
reservedTurns: left.reservedTurns || right.reservedTurns,
records: left.records || right.records,
frontStatus: left.frontStatus || right.frontStatus,
tournament: left.tournament || right.tournament,
});
export const hasRealtimeReadModelInvalidation = (invalidation: RealtimeReadModelInvalidation): boolean =>
@@ -141,6 +146,7 @@ export const resolveRealtimeReadModelInvalidation = (
frontStatusGeneralChanged ||
ownFrontStatusNationChanged ||
ownFrontStatusActorChanged,
tournament: false,
};
};
@@ -212,6 +218,11 @@ export interface MessagesChangedEvent {
mailboxes: number[];
}
/** Redis-owned tournament stage changed after its atomic source revision commit. */
export interface TournamentChangedEvent {
type: 'tournamentChanged';
}
export interface ReadModelInvalidatedEvent {
type: 'readModelInvalidated';
invalidation: RealtimeReadModelInvalidation;
@@ -224,4 +235,9 @@ export interface MessagesInvalidatedEvent {
/** Events safe to expose to an authenticated browser over SSE. */
export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent;
export type RealtimeEvent = TurnCompletedEvent | ReadModelChangedEvent | MessageCreatedEvent | MessagesChangedEvent;
export type RealtimeEvent =
| TurnCompletedEvent
| ReadModelChangedEvent
| MessageCreatedEvent
| MessagesChangedEvent
| TournamentChangedEvent;
@@ -1,4 +1,5 @@
import { asRecord } from '../util/parse.js';
import { buildGameEventChannel } from '../realtime/keys.js';
import { writeTournamentProjection } from './sourceRevision.js';
interface TournamentState {
@@ -44,6 +45,7 @@ const buildTournamentKeys = (profileName: string) => ({
bettingKey: `sammo:${profileName}:tournament:betting`,
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
realtimeEventChannel: buildGameEventChannel(profileName),
});
const resolveTermSeconds = (tickSeconds: number): number => {
@@ -1,6 +1,8 @@
export interface TournamentSourceKeys {
stateKey: string;
sourceRevisionKey: string;
sourceRevisionChannel: string;
realtimeEventChannel: string;
}
export interface TournamentProjectionRedis {
@@ -24,11 +26,25 @@ if current then
return redis.error_reply('tournament source revision exhausted')
end
end
local stage_changed = false
for index = 1, #KEYS - 1 do
local next_ok, next_value = pcall(cjson.decode, ARGV[index])
if next_ok and type(next_value) == 'table' and next_value['stage'] ~= nil then
local previous = redis.call('GET', KEYS[index])
local previous_stage = nil
if previous then
local previous_ok, previous_value = pcall(cjson.decode, previous)
if previous_ok and type(previous_value) == 'table' then
previous_stage = previous_value['stage']
end
end
local next_stage = next_value['stage']
stage_changed = (not previous) or previous_stage ~= next_stage
end
redis.call('SET', KEYS[index], ARGV[index])
end
local revision = redis.call('INCR', revision_key)
return tostring(revision)
return tostring(revision) .. ':' .. (stage_changed and '1' or '0')
`;
export const parseTournamentSourceRevision = (value: unknown): string | null => {
@@ -54,11 +70,16 @@ export const writeTournamentProjection = async (
throw new Error('Tournament projection write keys must be unique.');
}
const writesState = writes.some(({ key }) => key === keys.stateKey);
const result = await redis.eval(WRITE_TOURNAMENT_PROJECTION_SCRIPT, {
keys: [...writes.map(({ key }) => key), keys.sourceRevisionKey],
arguments: writes.map(({ value }) => JSON.stringify(value)),
});
const sourceRevision = parseTournamentSourceRevision(result);
const scriptResult = typeof result === 'string' ? /^(\d+):([01])$/u.exec(result) : null;
const sourceRevision = parseTournamentSourceRevision(scriptResult?.[1] ?? result);
// Plain revision results remain accepted for rolling deployments and small
// Redis fakes; only the current Lua contract can suppress same-stage writes.
const stageChanged = writesState && (scriptResult ? scriptResult[2] === '1' : true);
if (sourceRevision === null) {
throw new Error('토너먼트 source revision 갱신 결과가 올바르지 않습니다.');
}
@@ -69,6 +90,13 @@ export const writeTournamentProjection = async (
} catch {
// Payload and revision are committed; publication remains best effort.
}
if (stageChanged) {
try {
await redis.publish(keys.realtimeEventChannel, JSON.stringify({ type: 'tournamentChanged' }));
} catch {
// The source-revision wake-up and main SSE wake-up are independent best-effort fan-out.
}
}
}
return sourceRevision;
};
@@ -5,14 +5,14 @@ import { writeTournamentProjection } from '../src/tournament/sourceRevision.js';
describe('tournament source revision', () => {
it('passes every payload and one profile revision key to a single atomic script', async () => {
const calls: Array<{ keys: string[]; arguments: string[] }> = [];
const published: string[] = [];
const published: Array<{ channel: string; message: string }> = [];
const redis = {
eval: async (_script: string, options: { keys: string[]; arguments: string[] }) => {
calls.push(options);
return '7';
return '7:1';
},
publish: async (_channel: string, message: string) => {
published.push(message);
publish: async (channel: string, message: string) => {
published.push({ channel, message });
return 1;
},
};
@@ -20,7 +20,12 @@ describe('tournament source revision', () => {
await expect(
writeTournamentProjection(
redis,
{ sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' },
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[
{ key: 'state', value: { stage: 1 } },
{ key: 'matches', value: [] },
@@ -34,18 +39,35 @@ describe('tournament source revision', () => {
arguments: [JSON.stringify({ stage: 1 }), '[]'],
},
]);
expect(published).toEqual([JSON.stringify({ sourceRevision: '7' })]);
expect(published).toEqual([
{ channel: 'changed', message: JSON.stringify({ sourceRevision: '7' }) },
{ channel: 'realtime', message: JSON.stringify({ type: 'tournamentChanged' }) },
]);
});
it('rejects empty or duplicate writes before evaluating Redis', async () => {
const redis = { eval: async () => '1' };
await expect(
writeTournamentProjection(redis, { sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' }, [])
writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[]
)
).rejects.toThrow('at least one');
await expect(
writeTournamentProjection(
redis,
{ sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' },
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[
{ key: 'state', value: 1 },
{ key: 'state', value: 2 },
@@ -53,4 +75,79 @@ describe('tournament source revision', () => {
)
).rejects.toThrow('unique');
});
it('does not wake the main dashboard for participant-only writes', async () => {
const published: string[] = [];
const redis = {
eval: async () => '8',
publish: async (channel: string) => {
published.push(channel);
return 1;
},
};
await writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[{ key: 'participants', value: [{ id: 7 }] }]
);
expect(published).toEqual(['changed']);
});
it('does not wake the main dashboard when tournament state keeps the same stage', async () => {
const published: string[] = [];
const redis = {
eval: async () => '10:0',
publish: async (channel: string) => {
published.push(channel);
return 1;
},
};
await expect(
writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[{ key: 'state', value: { stage: 1, phase: 2 } }]
)
).resolves.toBe('10');
expect(published).toEqual(['changed']);
});
it('keeps both post-commit wake-up channels independently best effort', async () => {
const published: string[] = [];
const redis = {
eval: async () => '9',
publish: async (channel: string) => {
published.push(channel);
if (channel === 'changed') throw new Error('source subscriber unavailable');
return 1;
},
};
await expect(
writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[{ key: 'state', value: { stage: 1 } }]
)
).resolves.toBe('9');
expect(published).toEqual(['changed', 'realtime']);
});
});