merge: 최신 main을 브라우저 전투 시뮬레이터 작업에 통합
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 합격을 뜻하지 않는다. 이 작업의 수용 추산은 아래 실제 짧은
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user