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; : 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) { if (event.type === 'turnCompleted' && !event.changes) {
return { return {
type: 'readModelInvalidated', type: 'readModelInvalidated',
+4 -2
View File
@@ -6,7 +6,6 @@ import { asRecord } from '@sammo-ts/common';
import type { GameApiContext } from '../../context.js'; import type { GameApiContext } from '../../context.js';
import { import {
accessAuthedProcedure,
accessEngineAuthedProcedure, accessEngineAuthedProcedure,
accessEngineAuthedInputProcedure, accessEngineAuthedInputProcedure,
accessLimitAuthedProcedure, accessLimitAuthedProcedure,
@@ -790,7 +789,10 @@ export const generalRouter = router({
history: trimRecentRecords(history, input.lastWorldHistoryId), 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 me = await getMyGeneral(ctx);
const worldState = await ctx.db.worldState.findFirst({ const worldState = await ctx.db.worldState.findFirst({
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
@@ -40,7 +40,6 @@ export const generalAccessEndpointWeights = {
'diplomacy.getLetters': 2, 'diplomacy.getLetters': 2,
'battle.getGeneralDetail': 1, 'battle.getGeneralDetail': 1,
'betting.getList': 1, 'betting.getList': 1,
'general.getFrontStatus': 1,
'yearbook.getHistory': 1, 'yearbook.getHistory': 1,
'world.getGlobalInfo': 1, 'world.getGlobalInfo': 1,
'nation.getBattleCenter': 1, 'nation.getBattleCenter': 1,
@@ -86,14 +85,11 @@ export const generalAccessLimitEndpoints = new Set<GeneralAccessEndpoint>([
'diplomacy.rollbackLetter', 'diplomacy.rollbackLetter',
'diplomacy.destroyLetter', 'diplomacy.destroyLetter',
'betting.getList', 'betting.getList',
'general.getFrontStatus',
'yearbook.getHistory', 'yearbook.getHistory',
'messages.send', 'messages.send',
'turns.getCommandTable', 'turns.getCommandTable',
]); ]);
export const generalAccessLimitBeforeRecordEndpoints = new Set<GeneralAccessEndpoint>(['general.getFrontStatus']);
export type GeneralAccessState = { export type GeneralAccessState = {
generalId: number; generalId: number;
refreshScore: number; refreshScore: number;
+4
View File
@@ -1,3 +1,5 @@
import { buildGameEventChannel } from '@sammo-ts/common';
export interface TournamentKeys { export interface TournamentKeys {
stateKey: string; stateKey: string;
participantsKey: string; participantsKey: string;
@@ -5,6 +7,7 @@ export interface TournamentKeys {
bettingKey: string; bettingKey: string;
sourceRevisionKey: string; sourceRevisionKey: string;
sourceRevisionChannel: string; sourceRevisionChannel: string;
realtimeEventChannel: string;
} }
export const buildTournamentKeys = (profileName: string): TournamentKeys => ({ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
@@ -14,4 +17,5 @@ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
bettingKey: `sammo:${profileName}:tournament:betting`, bettingKey: `sammo:${profileName}:tournament:betting`,
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`, sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`, 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 { DuplicateInputEventError, executeInputEvent } from './inputEventBoundary.js';
import { import {
formatGeneralAccessLimitMessage, formatGeneralAccessLimitMessage,
generalAccessLimitBeforeRecordEndpoints,
generalAccessLimitEndpoints, generalAccessLimitEndpoints,
getGeneralAccessState, getGeneralAccessState,
recordGeneralAccessWeight, recordGeneralAccessWeight,
@@ -103,17 +102,8 @@ const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input,
return next(); return next();
} }
const endpoint = path as GeneralAccessEndpoint; 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); await recordGeneralAccessWeight(ctx, weight);
if (generalAccessLimitEndpoints.has(endpoint) && !generalAccessLimitBeforeRecordEndpoints.has(endpoint)) { if (generalAccessLimitEndpoints.has(endpoint)) {
const state = await getGeneralAccessState(ctx); const state = await getGeneralAccessState(ctx);
if (state?.level === 2) { if (state?.level === 2) {
throw new TRPCError({ throw new TRPCError({
@@ -383,6 +383,10 @@ integration('general access tracking persistence', () => {
} as unknown as GameApiContext; } as unknown as GameApiContext;
const boundaryCaller = endpointBoundaryRouter.createCaller(context); 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({ await expect(boundaryCaller.world.getGeneralDirectory({ accepted: false as true })).rejects.toMatchObject({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
}); });
@@ -101,7 +101,6 @@ describe('general access tracking', () => {
'diplomacy.getLetters': 2, 'diplomacy.getLetters': 2,
'battle.getGeneralDetail': 1, 'battle.getGeneralDetail': 1,
'betting.getList': 1, 'betting.getList': 1,
'general.getFrontStatus': 1,
'yearbook.getHistory': 1, 'yearbook.getHistory': 1,
'world.getGlobalInfo': 1, 'world.getGlobalInfo': 1,
'nation.getBattleCenter': 1, 'nation.getBattleCenter': 1,
@@ -131,6 +130,7 @@ describe('general access tracking', () => {
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', {}, 'che')).toBe(1); expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', {}, 'che')).toBe(1);
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'che' }, 'che')).toBe(1); expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'che' }, 'che')).toBe(1);
expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'hwe' }, 'che')).toBeNull(); expect(resolveGeneralAccessEndpointWeight('yearbook.getHistory', { serverID: 'hwe' }, 'che')).toBeNull();
expect(resolveGeneralAccessEndpointWeight('general.getFrontStatus', {}, 'che')).toBeUndefined();
expect(resolveGeneralAccessEndpointWeight('unknown.path', {}, 'che')).toBeUndefined(); expect(resolveGeneralAccessEndpointWeight('unknown.path', {}, 'che')).toBeUndefined();
}); });
@@ -52,6 +52,7 @@ describe('public realtime event privacy boundary', () => {
reservedTurns: true, reservedTurns: true,
records: true, records: true,
frontStatus: false, frontStatus: false,
tournament: false,
}, },
}); });
const serialized = JSON.stringify(publicEvent); const serialized = JSON.stringify(publicEvent);
@@ -108,10 +109,33 @@ describe('public realtime event privacy boundary', () => {
reservedTurns: true, reservedTurns: true,
records: true, records: true,
frontStatus: 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', () => { it('filters message events per viewer and removes mailbox, sender, message, and time fields', () => {
const event: RealtimeEvent = { const event: RealtimeEvent = {
type: 'messageCreated', type: 'messageCreated',
+6
View File
@@ -42,6 +42,12 @@ describe('parseRealtimeEvent', () => {
expect(parseRealtimeEvent('not-json')).toBeNull(); expect(parseRealtimeEvent('not-json')).toBeNull();
expect(parseRealtimeEvent(JSON.stringify({}))).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', () => { describe('buildGameEventChannel', () => {
@@ -10,12 +10,27 @@ const integration = describe.skipIf(!process.env.REDIS_URL);
integration('TournamentStore Redis source revision', () => { integration('TournamentStore Redis source revision', () => {
let connector: RedisConnector; let connector: RedisConnector;
let subscriber: RedisConnector;
const profile = `test:tournament-revision:${randomUUID()}`; const profile = `test:tournament-revision:${randomUUID()}`;
const keys = buildTournamentKeys(profile); 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 () => { beforeAll(async () => {
connector = createRedisConnector(resolveRedisConfigFromEnv()); connector = createRedisConnector(resolveRedisConfigFromEnv());
subscriber = createRedisConnector(resolveRedisConfigFromEnv());
await connector.connect(); 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 () => { afterAll(async () => {
@@ -27,6 +42,11 @@ integration('TournamentStore Redis source revision', () => {
keys.bettingKey, keys.bettingKey,
keys.sourceRevisionKey, keys.sourceRevisionKey,
]); ]);
if (subscriber) {
await subscriber.client.unsubscribe(keys.sourceRevisionChannel);
await subscriber.client.unsubscribe(keys.realtimeEventChannel);
await subscriber.disconnect();
}
await connector.disconnect(); await connector.disconnect();
}); });
@@ -44,4 +64,37 @@ integration('TournamentStore Redis source revision', () => {
await expect(store.getSourceRevision()).resolves.toBe('20'); await expect(store.getSourceRevision()).resolves.toBe('20');
await expect(store.getMatches()).resolves.toHaveLength(1); 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> { 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 }); this.published.push({ channel, message });
return 1; return 1;
} }
@@ -71,6 +72,29 @@ describe('TournamentStore source revision', () => {
expect(redis.published).toEqual([]); 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 () => { it('serializes concurrent writes into monotonic per-profile revisions', async () => {
const redis = new AtomicMemoryRedis(); const redis = new AtomicMemoryRedis();
const store = new TournamentStore(redis, buildTournamentKeys('pwe:default')); 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 devRate: Record<string, number> | null = null;
private categorizedCities = false; private categorizedCities = false;
private categorizedGenerals = 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 promotionNationMeta: Record<string, unknown> | null = null;
private readonly initialGeneralMeta: Record<string, unknown>; private readonly initialGeneralMeta: Record<string, unknown>;
@@ -433,7 +438,7 @@ export class GeneralAI {
} }
consumePromotionPatches(): { consumePromotionPatches(): {
generals: Array<{ generalId: number; officerLevel: number; officerCity: number }>; generals: Array<{ generalId: number; officerLevel: number; officerCity: number; permission?: string }>;
nationMeta: Record<string, unknown> | null; nationMeta: Record<string, unknown> | null;
} { } {
const result = { 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; 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); let chiefSet = readMetaNumber(asRecord(this.nation.meta), 'chief_set', 0);
const initialChiefSet = chiefSet;
const generals = this.worldRef const generals = this.worldRef
.listGenerals() .listGenerals()
.filter((candidate) => candidate.nationId === this.nation!.id) .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])); 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) { for (let chiefLevel = 11; chiefLevel >= minChiefLevel; chiefLevel -= 1) {
if ((chiefSet & (1 << chiefLevel)) !== 0 || this.general.officerLevel === chiefLevel) { if ((chiefSet & (1 << chiefLevel)) !== 0 || this.general.officerLevel === chiefLevel) {
continue; continue;
} }
const oldChief = generals.find((candidate) => candidate.officerLevel === chiefLevel); const oldChief = generals.find((candidate) => candidate.officerLevel === chiefLevel);
if (oldChief) { 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; const newChiefProbability = this.rng.nextBool(0.1) ? 1 : 0;
// GeneralAI.php performs a second nextBool(0) call on the // GeneralAI.php performs a second nextBool(0) call on the
// rejection path. Preserve that consumption for the shared // rejection path. Preserve that consumption for the shared
@@ -1095,7 +1191,7 @@ export class GeneralAI {
} }
} }
const nextChief = generals.find((candidate) => { 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; return false;
} }
const killturn = readRequiredMetaNumber( const killturn = readRequiredMetaNumber(
@@ -1103,7 +1199,13 @@ export class GeneralAI {
'killturn', 'killturn',
`generalId=${candidate.id}` `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; return false;
} }
if (chiefLevel !== 11 && chiefLevel % 2 === 0 && candidate.stats.strength < this.aiConst.chiefStatMin) { if (chiefLevel !== 11 && chiefLevel % 2 === 0 && candidate.stats.strength < this.aiConst.chiefStatMin) {
@@ -1116,6 +1218,9 @@ export class GeneralAI {
) { ) {
return false; return false;
} }
if (candidate.npcState < 2 && userChiefCount >= 3) {
return false;
}
return true; return true;
}); });
if (!nextChief) { if (!nextChief) {
@@ -1124,18 +1229,37 @@ export class GeneralAI {
if (oldChief) { if (oldChief) {
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 }); 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') { if (process.env.CORE_AI_TRACE_SEQUENCE === '1') {
process.stdout.write( process.stdout.write(
`AI_PROMOTION_TRACE ${JSON.stringify({ engine: 'core', mode: 'lord', actor: this.general.id, chiefLevel, picked: nextChief.id })}\n` `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); effectiveOfficerLevel.set(nextChief.id, chiefLevel);
chiefSet |= 1 << chiefLevel; chiefSet |= 1 << chiefLevel;
} }
if (this.promotionPatches.length > 0) { if (chiefSet !== initialChiefSet) {
this.promotionNationMeta = { ...this.nation.meta, chief_set: chiefSet }; this.promotionNationMeta = {
...this.nation.meta,
...(this.promotionNationMeta ?? {}),
chief_set: chiefSet,
};
} }
} }
@@ -1690,7 +1690,13 @@ export const createReservedTurnHandler = async (options: {
const patch = { const patch = {
officerLevel: entry.officerLevel, officerLevel: entry.officerLevel,
...(promotedGeneral ...(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 }); patches.generals.push({ id: entry.generalId, patch });
@@ -1,7 +1,12 @@
import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra'; import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra';
import { randomUUID } from 'node:crypto'; 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'; import type { GatewayAdminActionRecord, GatewayAdminActionResult } from './gatewayAdminActions.js';
@@ -74,8 +79,10 @@ const shiftTournamentClock = async (
): Promise<boolean> => { ): Promise<boolean> => {
const stateKey = `sammo:${profileName}:tournament:state`; const stateKey = `sammo:${profileName}:tournament:state`;
const sourceKeys = { const sourceKeys = {
stateKey,
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`, sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`, sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
realtimeEventChannel: buildGameEventChannel(profileName),
}; };
const lockKey = `${stateKey}:mutation-lock`; const lockKey = `${stateKey}:mutation-lock`;
const token = randomUUID(); 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 type { RedisConnector } from '@sammo-ts/infra';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
@@ -69,12 +75,13 @@ export const createTournamentAutoStartHandler = (options: {
now?: () => Date; now?: () => Date;
}): TurnCalendarHandler => { }): TurnCalendarHandler => {
const keys = { const keys = {
state: `sammo:${options.profileName}:tournament:state`, stateKey: `sammo:${options.profileName}:tournament:state`,
participants: `sammo:${options.profileName}:tournament:participants`, participants: `sammo:${options.profileName}:tournament:participants`,
matches: `sammo:${options.profileName}:tournament:matches`, matches: `sammo:${options.profileName}:tournament:matches`,
betting: `sammo:${options.profileName}:tournament:betting`, betting: `sammo:${options.profileName}:tournament:betting`,
sourceRevisionKey: `sammo:${options.profileName}:tournament:source-revision`, sourceRevisionKey: `sammo:${options.profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${options.profileName}:tournament:source-changed`, sourceRevisionChannel: `sammo:${options.profileName}:tournament:source-changed`,
realtimeEventChannel: buildGameEventChannel(options.profileName),
}; };
return { return {
onMonthChanged: async (context) => { onMonthChanged: async (context) => {
@@ -85,7 +92,7 @@ export const createTournamentAutoStartHandler = (options: {
if (!world || !redis || config.tournamentTrig !== true) { if (!world || !redis || config.tournamentTrig !== true) {
return; return;
} }
const previousState = safeJsonParse<TournamentState>(await redis.get(keys.state)); const previousState = safeJsonParse<TournamentState>(await redis.get(keys.stateKey));
if (previousState && previousState.stage > 0) { if (previousState && previousState.stage > 0) {
return; return;
} }
@@ -145,7 +152,7 @@ export const createTournamentAutoStartHandler = (options: {
{ key: keys.participants, value: [] }, { key: keys.participants, value: [] },
{ key: keys.matches, value: [] }, { key: keys.matches, value: [] },
{ key: keys.betting, value: [] }, { key: keys.betting, value: [] },
{ key: keys.state, value: nextState }, { key: keys.stateKey, value: nextState },
]); ]);
const [typeText, generalTypeText] = TOURNAMENT_TEXT[type] ?? TOURNAMENT_TEXT[0]; 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 { createRefOrderedActionStack } from '@sammo-ts/logic/actionModules/bundle.js';
import { GeneralAI } from '../src/turn/ai/generalAi.js'; import { GeneralAI } from '../src/turn/ai/generalAi.js';
import type { TurnGeneral } from '../src/turn/types.js';
import { import {
calculateRecentWarTurn, calculateRecentWarTurn,
resolveLegacyAiStats, resolveLegacyAiStats,
@@ -23,10 +24,7 @@ import {
doNPC전방발령, doNPC전방발령,
doNPC후방발령, doNPC후방발령,
} from '../src/turn/ai/generalAi/nation/assignments/npcAssignments.js'; } from '../src/turn/ai/generalAi/nation/assignments/npcAssignments.js';
import { import { do부대구출발령, do부대후방발령 } from '../src/turn/ai/generalAi/nation/assignments/troopAssignments.js';
do부대구출발령,
do부대후방발령,
} from '../src/turn/ai/generalAi/nation/assignments/troopAssignments.js';
type Candidate = { type Candidate = {
action: string; action: string;
@@ -397,6 +395,311 @@ const makeAi = (
} as unknown as GeneralAI; } 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 * Expected branches are extracted from ref/sam hwe/sammo/GeneralAI.php
* at ng_compare@fe9ae978. These tests intentionally assert final command * 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 }, nation: { rice: 100_000 },
generalActionModules: singleActionModuleStack({ generalActionModules: singleActionModuleStack({
eventHandlers: {}, eventHandlers: {},
onCalcStat: (_context, statName, value) => onCalcStat: (_context, statName, value) => (statName === 'leadership' ? Number(value) + 30 : value),
statName === 'leadership' ? Number(value) + 30 : value,
}), }),
}); });
ai.maxResourceActionAmount = 100_000; 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 { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path'; 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 response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, message: string) => ({ const errorResponse = (path: string, message: string) => ({
@@ -97,6 +97,7 @@ const readModelInvalidation = (
reservedTurns: boolean; reservedTurns: boolean;
records: boolean; records: boolean;
frontStatus: boolean; frontStatus: boolean;
tournament: boolean;
}> }>
) => ({ ) => ({
context: false, context: false,
@@ -108,6 +109,7 @@ const readModelInvalidation = (
reservedTurns: false, reservedTurns: false,
records: false, records: false,
frontStatus: false, frontStatus: false,
tournament: false,
...overrides, ...overrides,
}); });
@@ -619,6 +621,97 @@ const gridColumnCount = async (page: Page, selector: string) =>
.first() .first()
.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length); .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) => { const persistArtifact = async (page: Page, name: string) => {
if (!artifactRoot) return; if (!artifactRoot) return;
const target = resolve(artifactRoot); 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`); 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 = { const state: NavigationFixture = {
officerLevel: 1, officerLevel: 1,
permission: 0, 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.columns.split(' ')).toHaveLength(3);
expect(desktopGeometry.rows).toBe(2); expect(desktopGeometry.rows).toBe(2);
expect(desktopGeometry.horizontalOverflow).toBeLessThanOrEqual(0); 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); expect(desktopGeometry.commandButton).toEqual(desktopGeometry.categoryButton);
const strategyCategory = picker.getByRole('button', { name: '계략', exact: true }); 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(); 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 strategyCategory.focus();
await expect(strategyCategory).toBeFocused(); await expect(strategyCategory).toBeFocused();
await expect.poll(() => strategyCategory.evaluate((element) => element.matches(':focus-visible'))).toBe(true);
await strategyCategory.click(); await strategyCategory.click();
await expect(strategyCategory).toHaveClass(/active/); await expect(strategyCategory).toHaveClass(/active/);
await expect(picker.locator('.command-item')).toHaveText(['화계']); 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 persistArtifact(page, `${basePath.slice(1)}-main-reserved-ref-categories-desktop-1200`);
await page.setViewportSize({ width: 500, height: 900 }); 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.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); expect(mobileGeometry.commandButton).toEqual(mobileGeometry.categoryButton);
await persistArtifact(page, `${basePath.slice(1)}-main-reserved-ref-categories-mobile-500`); 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(); await page.locator('[data-main-target="commands"] .select-command').click();
const picker = page.getByTestId('command-picker'); const picker = page.getByTestId('command-picker');
await expect(picker).toBeVisible(); 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 pickerGeometry = await picker.evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const editor = element.closest('.reserved-command-editor')?.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 page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page); await waitForMain(page);
await expect(page.locator('.general-title')).toContainText('메뉴검증장수'); 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(() => { await page.evaluate(() => {
const general = document.querySelector('[data-main-target="general"]'); 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, reservedTurns: false,
records: false, records: false,
frontStatus: 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, reservedTurns: false,
records: false, records: false,
frontStatus: true, frontStatus: true,
tournament: false,
}, },
} }
); );
@@ -2427,6 +2597,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
reservedTurns: false, reservedTurns: false,
records: false, records: false,
frontStatus: 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]; const followerPage = pages[followerIndex];
if (!leaderPage || !followerPage) throw new Error('realtime leader election failed'); 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; const callsBeforeSharedRefresh = state.generalMeCalls;
state.generalName = '탭공유갱신장수'; state.generalName = '탭공유갱신장수';
await leaderPage.evaluate(() => { await leaderPage.evaluate(() => {
@@ -2617,6 +2805,7 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
reservedTurns: false, reservedTurns: false,
records: false, records: false,
frontStatus: 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, reservedTurns: false,
records: false, records: false,
frontStatus: false, frontStatus: false,
tournament: false,
}, },
} }
); );
@@ -130,7 +130,12 @@ const commandTitle = (command: CommandAvailability) =>
<button <button
v-for="category in categories" v-for="category in categories"
:key="category.id" :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" @click="selectedCategory = category.id"
> >
{{ category.label }} {{ category.label }}
@@ -143,6 +148,8 @@ const commandTitle = (command: CommandAvailability) =>
:key="command.key" :key="command.key"
:class="[ :class="[
'command-item', 'command-item',
'legacy-button',
'legacy-button--lumen',
command.status === 'available' ? 'ok' : '', command.status === 'available' ? 'ok' : '',
command.status === 'blocked' ? 'blocked' : '', command.status === 'blocked' ? 'blocked' : '',
command.status === 'blocked' && props.allowBlocked ? 'reservable' : '', command.status === 'blocked' && props.allowBlocked ? 'reservable' : '',
@@ -173,26 +180,19 @@ const commandTitle = (command: CommandAvailability) =>
gap: 0; gap: 0;
} }
.category-btn, .category-btn.legacy-button--lumen {
.command-item { --legacy-button-bg: #173d27;
min-height: 32px; --legacy-button-border: #153723;
padding-block: 6px; --legacy-button-color: #fff;
} min-height: 0;
.category-btn {
border: 0;
border-right: 1px solid #666;
border-bottom: 1px solid #666;
padding-inline: 4px; padding-inline: 4px;
background: #173d27;
color: #fff;
font-size: 12px; font-size: 12px;
cursor: pointer;
} }
.category-btn.active { .category-btn.legacy-button--lumen.active {
background: #28633f; --legacy-button-bg: #28633f;
color: #ffe38a; --legacy-button-border: #245939;
--legacy-button-color: #ffe38a;
} }
.command-grid { .command-grid {
@@ -201,19 +201,17 @@ const commandTitle = (command: CommandAvailability) =>
gap: 0; gap: 0;
} }
.command-item { .command-item.legacy-button--lumen {
border: 0; --legacy-button-bg: #302016 var(--sammo-texture-walnut);
border-right: 1px solid #666; --legacy-button-border: #2b1d14;
border-bottom: 1px solid #666; --legacy-button-color: #fff;
min-height: 0;
padding-inline: 5px; padding-inline: 5px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #302016 var(--sammo-texture-walnut);
color: #fff;
text-align: center; text-align: center;
font-size: 12px; font-size: 12px;
cursor: pointer;
} }
.command-item.ok { .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 ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>['turns'][number];
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number]; type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>; 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 ContextBundleDelta = Awaited<ReturnType<typeof trpc.dashboard.getContextBundleDelta.query>>;
type DashboardReadModelPatch = { type DashboardReadModelPatch = {
contextSnapshot?: GeneralContext; contextSnapshot?: GeneralContext;
@@ -72,6 +73,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
generalRecords?: RecentRecord[]; generalRecords?: RecentRecord[];
worldHistory?: RecentRecord[]; worldHistory?: RecentRecord[];
frontStatus?: FrontStatus | null; frontStatus?: FrontStatus | null;
tournamentStage?: number;
}; };
type DashboardTabMessage = type DashboardTabMessage =
{ kind: 'patch'; patch: DashboardReadModelPatch } | { kind: 'status'; status: 'idle' | 'connected' }; { kind: 'patch'; patch: DashboardReadModelPatch } | { kind: 'status'; status: 'idle' | 'connected' };
@@ -112,6 +114,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
const generalRecords = ref<RecentRecord[]>([]); const generalRecords = ref<RecentRecord[]>([]);
const worldHistory = ref<RecentRecord[]>([]); const worldHistory = ref<RecentRecord[]>([]);
const frontStatus = ref<FrontStatus | null>(null); const frontStatus = ref<FrontStatus | null>(null);
const tournamentStage = ref(0);
const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null); const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null);
let lastGeneralRecordId = 0; let lastGeneralRecordId = 0;
let lastWorldHistoryId = 0; let lastWorldHistoryId = 0;
@@ -430,6 +433,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
} else if (patch.frontStatus !== undefined) { } else if (patch.frontStatus !== undefined) {
updateFrontStatus(patch.frontStatus); updateFrontStatus(patch.frontStatus);
} }
if (patch.tournamentStage !== undefined) {
tournamentStage.value = patch.tournamentStage;
}
if (patch.contextRevision !== undefined) { if (patch.contextRevision !== undefined) {
contextRevision = patch.contextRevision; contextRevision = patch.contextRevision;
contextSourceRevision = patch.contextSourceRevision ?? null; contextSourceRevision = patch.contextSourceRevision ?? null;
@@ -476,6 +482,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
patch.generalRecords = toRaw(generalRecords.value); patch.generalRecords = toRaw(generalRecords.value);
patch.worldHistory = toRaw(worldHistory.value); patch.worldHistory = toRaw(worldHistory.value);
patch.frontStatus = toRaw(frontStatus.value); patch.frontStatus = toRaw(frontStatus.value);
patch.tournamentStage = tournamentStage.value;
return patch; return patch;
}; };
@@ -585,7 +592,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
frontStatusError.value = resolveErrorMessage(err); frontStatusError.value = resolveErrorMessage(err);
return null; 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([ await Promise.all([
layoutPromise, layoutPromise,
trpc.lobby.info.query(), trpc.lobby.info.query(),
@@ -595,6 +603,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
generalTurnsPromise, generalTurnsPromise,
recordsPromise, recordsPromise,
frontStatusPromise, frontStatusPromise,
tournamentPromise,
]); ]);
general.value = structurallyShare(general.value, context.general); general.value = structurallyShare(general.value, context.general);
@@ -617,6 +626,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (nextFrontStatus) { if (nextFrontStatus) {
updateFrontStatus(nextFrontStatus); updateFrontStatus(nextFrontStatus);
} }
if (tournamentState !== undefined) {
tournamentStage.value = tournamentState?.stage ?? 0;
}
if (initializedMailboxGeneralId !== id) { if (initializedMailboxGeneralId !== id) {
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId; targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
initializedMailboxGeneralId = id; initializedMailboxGeneralId = id;
@@ -705,14 +717,18 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
return null; return null;
}) })
: Promise.resolve(undefined); : 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, lobbyPromise,
mapPromise, mapPromise,
contactsPromise, contactsPromise,
reservedPromise, reservedPromise,
recordsPromise, recordsPromise,
frontPromise, frontPromise,
tournamentPromise,
]); ]);
const patch: DashboardReadModelPatch = { ...contextPatch }; const patch: DashboardReadModelPatch = { ...contextPatch };
@@ -733,6 +749,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
patch.worldHistory = nextWorldHistory; patch.worldHistory = nextWorldHistory;
} }
if (nextFrontStatus) patch.frontStatus = nextFrontStatus; if (nextFrontStatus) patch.frontStatus = nextFrontStatus;
if (tournamentState !== undefined) patch.tournamentStage = tournamentState?.stage ?? 0;
applyDashboardPatch(patch); applyDashboardPatch(patch);
publishDashboardPatch(patch); publishDashboardPatch(patch);
} catch (err) { } catch (err) {
@@ -1241,6 +1258,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
generalRecords, generalRecords,
worldHistory, worldHistory,
frontStatus, frontStatus,
tournamentStage,
surveyNotice, surveyNotice,
messageDraftText, messageDraftText,
targetMailbox, targetMailbox,
+2 -4
View File
@@ -27,7 +27,6 @@ const session = useSessionStore();
const dashboard = useMainDashboardStore(); const dashboard = useMainDashboardStore();
const isMobile = useMediaQuery('(max-width: 939.98px)'); const isMobile = useMediaQuery('(max-width: 939.98px)');
const tournamentStage = ref(0);
const npcMode = ref(0); const npcMode = ref(0);
const { const {
@@ -52,6 +51,7 @@ const {
generalRecords, generalRecords,
worldHistory, worldHistory,
frontStatus, frontStatus,
tournamentStage,
surveyNotice, surveyNotice,
messageDraftText, messageDraftText,
targetMailbox, targetMailbox,
@@ -107,12 +107,10 @@ const repeatGeneralTurns = (amount: number) => {
}; };
const loadMainData = async () => { const loadMainData = async () => {
const [, state, worldState] = await Promise.all([ const [, worldState] = await Promise.all([
dashboard.loadMainData(), dashboard.loadMainData(),
trpc.tournament.getState.query().catch(() => null),
trpc.world.getState.query().catch(() => null), trpc.world.getState.query().catch(() => null),
]); ]);
tournamentStage.value = state?.stage ?? 0;
npcMode.value = worldState?.config.npcMode ?? 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, reservedTurns: false,
records: false, records: false,
frontStatus: false, frontStatus: false,
tournament: false,
}); });
}); });
@@ -46,6 +47,7 @@ void test('selects only the read models affected by the current identity', () =>
reservedTurns: true, reservedTurns: true,
records: true, records: true,
frontStatus: false, frontStatus: false,
tournament: false,
}); });
}); });
@@ -79,6 +81,7 @@ void test('routes defence, tax-rate, and current-city-state events to their exac
reservedTurns: false, reservedTurns: false,
records: false, records: false,
frontStatus: false, frontStatus: false,
tournament: false,
}); });
const taxRate = resolveDashboardRefreshPlan( const taxRate = resolveDashboardRefreshPlan(
@@ -100,6 +103,7 @@ void test('routes defence, tax-rate, and current-city-state events to their exac
reservedTurns: false, reservedTurns: false,
records: false, records: false,
frontStatus: false, frontStatus: false,
tournament: false,
}); });
const cityState = resolveDashboardRefreshPlan( const cityState = resolveDashboardRefreshPlan(
@@ -161,6 +165,7 @@ void test('refreshes only front status for a global survey projection change', (
reservedTurns: false, reservedTurns: false,
records: false, records: false,
frontStatus: true, 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); 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', () => { void test('keeps the access bundle projection-free for independent read-model plans', () => {
for (const slice of ['map', 'records', 'frontStatus'] as const) { for (const slice of ['map', 'records', 'frontStatus', 'tournament'] as const) {
const plan = { ...createEmptyRealtimeReadModelInvalidation(), [slice]: true }; const plan = { ...createEmptyRealtimeReadModelInvalidation(), [slice]: true };
assert.deepEqual(resolveDashboardContextBundleInclude(plan), { assert.deepEqual(resolveDashboardContextBundleInclude(plan), {
context: false, context: false,
+20 -6
View File
@@ -272,9 +272,14 @@ transaction을 잡은 채 `turnDaemon.requestCommand()`의 별도 ENGINE transac
토너먼트 state/participants/matches/bets는 현재 Redis가 원본이므로 PostgreSQL 토너먼트 state/participants/matches/bets는 현재 Redis가 원본이므로 PostgreSQL
revision과 원자적으로 묶을 수 없다. `TournamentStore`가 state write와 Redis domain revision과 원자적으로 묶을 수 없다. `TournamentStore`가 state write와 Redis domain
revision 증가를 같은 Redis transaction 또는 Lua script로 수행한다. 저장 뒤 별도 revision 증가를 같은 Lua script로 수행한다. source revision은 참가자·대진·베팅을
`publish()` 두 호출로 끝내지 않는다. 장기 durability 요구가 생기면 tournament state 포함한 모든 projection write에서 증가하지만 메인 화면 wake-up은 Lua 안에서 이전/다음
자체를 PostgreSQL 소유로 옮기는 별도 migration으로 다룬다. `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 ### 국가 베팅과 direct writer
@@ -352,7 +357,9 @@ test가 완료되어 post-deploy one-off로 안전하게 활성화할 수 있다
meta/head 누락, DB/Redis 오류에는 shared cache를 완전히 우회해 full compute한다. meta/head 누락, DB/Redis 오류에는 shared cache를 완전히 우회해 full compute한다.
- 개인 `spyList`, `shownByGeneralList`, `myCity`, `myNation`은 request에서 계속 조합한다. - 개인 `spyList`, `shownByGeneralList`, `myCity`, `myNation`은 request에서 계속 조합한다.
- tournament는 API store, 월 자동 개막과 runtime clock shift 모두 payload와 profile source - 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` 증분 조회를 유지하되 해당 - records는 기존 `lastGeneralRecordId`/`lastWorldHistoryId` 증분 조회를 유지하되 해당
domain이 선택되지 않으면 query하지 않는다. domain이 선택되지 않으면 query하지 않는다.
@@ -374,6 +381,13 @@ manual refresh, visible 복귀와 realtime 재활성화는 cadence를 기다리
snapshot을 한 번 읽는다. 숫자는 실제 혼합 부하 결과에 따라 조정하며, 부하만 낮추기 snapshot을 한 번 읽는다. 숫자는 실제 혼합 부하 결과에 따라 조정하며, 부하만 낮추기
위해 5초를 초과하지 않는다. 위해 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 경계 ## 구현 단계와 commit 경계
### Phase A: 저위험 read 절감 ### Phase A: 저위험 read 절감
@@ -414,13 +428,13 @@ snapshot을 한 번 읽는다. 숫자는 실제 혼합 부하 결과에 따라
5. 결과에 따라 pool, cadence, cache와 worker concurrency를 조정하고 전체 benchmark를 5. 결과에 따라 pool, cadence, cache와 worker concurrency를 조정하고 전체 benchmark를
다시 실행한다. 다시 실행한다.
### 2026-08-16 구현 상태 ### 2026-08-17 구현 상태
| Phase | 상태 | 현재 근거 | | Phase | 상태 | 현재 근거 |
| --- | --- | --- | | --- | --- | --- |
| A | 완료 | all-false access gate, frontend 강제 context 제거, Chromium realtime trace | | 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 | | 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은 미실행 | | D | 부분 완료 | E1 1,200장수 1개월 deterministic profile과 300 SSE/HTTP 짧은 calibration 완료. E2 actual daemon DB flush 및 30분 M1/R1은 미실행 |
`부분 완료`는 capacity 합격을 뜻하지 않는다. 이 작업의 수용 추산은 아래 실제 짧은 `부분 완료`는 capacity 합격을 뜻하지 않는다. 이 작업의 수용 추산은 아래 실제 짧은
+17 -1
View File
@@ -51,6 +51,8 @@ export interface RealtimeReadModelInvalidation {
reservedTurns: boolean; reservedTurns: boolean;
records: boolean; records: boolean;
frontStatus: boolean; frontStatus: boolean;
/** Shared tournament stage shown by the main dashboard changed. */
tournament: boolean;
} }
export interface RealtimeViewerIdentity { export interface RealtimeViewerIdentity {
@@ -69,6 +71,7 @@ export const createEmptyRealtimeReadModelInvalidation = (): RealtimeReadModelInv
reservedTurns: false, reservedTurns: false,
records: false, records: false,
frontStatus: false, frontStatus: false,
tournament: false,
}); });
export const createFullRealtimeReadModelInvalidation = (): RealtimeReadModelInvalidation => ({ export const createFullRealtimeReadModelInvalidation = (): RealtimeReadModelInvalidation => ({
@@ -81,6 +84,7 @@ export const createFullRealtimeReadModelInvalidation = (): RealtimeReadModelInva
reservedTurns: true, reservedTurns: true,
records: true, records: true,
frontStatus: true, frontStatus: true,
tournament: true,
}); });
export const mergeRealtimeReadModelInvalidations = ( export const mergeRealtimeReadModelInvalidations = (
@@ -96,6 +100,7 @@ export const mergeRealtimeReadModelInvalidations = (
reservedTurns: left.reservedTurns || right.reservedTurns, reservedTurns: left.reservedTurns || right.reservedTurns,
records: left.records || right.records, records: left.records || right.records,
frontStatus: left.frontStatus || right.frontStatus, frontStatus: left.frontStatus || right.frontStatus,
tournament: left.tournament || right.tournament,
}); });
export const hasRealtimeReadModelInvalidation = (invalidation: RealtimeReadModelInvalidation): boolean => export const hasRealtimeReadModelInvalidation = (invalidation: RealtimeReadModelInvalidation): boolean =>
@@ -141,6 +146,7 @@ export const resolveRealtimeReadModelInvalidation = (
frontStatusGeneralChanged || frontStatusGeneralChanged ||
ownFrontStatusNationChanged || ownFrontStatusNationChanged ||
ownFrontStatusActorChanged, ownFrontStatusActorChanged,
tournament: false,
}; };
}; };
@@ -212,6 +218,11 @@ export interface MessagesChangedEvent {
mailboxes: number[]; mailboxes: number[];
} }
/** Redis-owned tournament stage changed after its atomic source revision commit. */
export interface TournamentChangedEvent {
type: 'tournamentChanged';
}
export interface ReadModelInvalidatedEvent { export interface ReadModelInvalidatedEvent {
type: 'readModelInvalidated'; type: 'readModelInvalidated';
invalidation: RealtimeReadModelInvalidation; invalidation: RealtimeReadModelInvalidation;
@@ -224,4 +235,9 @@ export interface MessagesInvalidatedEvent {
/** Events safe to expose to an authenticated browser over SSE. */ /** Events safe to expose to an authenticated browser over SSE. */
export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent; 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 { asRecord } from '../util/parse.js';
import { buildGameEventChannel } from '../realtime/keys.js';
import { writeTournamentProjection } from './sourceRevision.js'; import { writeTournamentProjection } from './sourceRevision.js';
interface TournamentState { interface TournamentState {
@@ -44,6 +45,7 @@ const buildTournamentKeys = (profileName: string) => ({
bettingKey: `sammo:${profileName}:tournament:betting`, bettingKey: `sammo:${profileName}:tournament:betting`,
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`, sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`, sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
realtimeEventChannel: buildGameEventChannel(profileName),
}); });
const resolveTermSeconds = (tickSeconds: number): number => { const resolveTermSeconds = (tickSeconds: number): number => {
@@ -1,6 +1,8 @@
export interface TournamentSourceKeys { export interface TournamentSourceKeys {
stateKey: string;
sourceRevisionKey: string; sourceRevisionKey: string;
sourceRevisionChannel: string; sourceRevisionChannel: string;
realtimeEventChannel: string;
} }
export interface TournamentProjectionRedis { export interface TournamentProjectionRedis {
@@ -24,11 +26,25 @@ if current then
return redis.error_reply('tournament source revision exhausted') return redis.error_reply('tournament source revision exhausted')
end end
end end
local stage_changed = false
for index = 1, #KEYS - 1 do 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]) redis.call('SET', KEYS[index], ARGV[index])
end end
local revision = redis.call('INCR', revision_key) 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 => { 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.'); 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, { const result = await redis.eval(WRITE_TOURNAMENT_PROJECTION_SCRIPT, {
keys: [...writes.map(({ key }) => key), keys.sourceRevisionKey], keys: [...writes.map(({ key }) => key), keys.sourceRevisionKey],
arguments: writes.map(({ value }) => JSON.stringify(value)), 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) { if (sourceRevision === null) {
throw new Error('토너먼트 source revision 갱신 결과가 올바르지 않습니다.'); throw new Error('토너먼트 source revision 갱신 결과가 올바르지 않습니다.');
} }
@@ -69,6 +90,13 @@ export const writeTournamentProjection = async (
} catch { } catch {
// Payload and revision are committed; publication remains best effort. // 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; return sourceRevision;
}; };
@@ -5,14 +5,14 @@ import { writeTournamentProjection } from '../src/tournament/sourceRevision.js';
describe('tournament source revision', () => { describe('tournament source revision', () => {
it('passes every payload and one profile revision key to a single atomic script', async () => { it('passes every payload and one profile revision key to a single atomic script', async () => {
const calls: Array<{ keys: string[]; arguments: string[] }> = []; const calls: Array<{ keys: string[]; arguments: string[] }> = [];
const published: string[] = []; const published: Array<{ channel: string; message: string }> = [];
const redis = { const redis = {
eval: async (_script: string, options: { keys: string[]; arguments: string[] }) => { eval: async (_script: string, options: { keys: string[]; arguments: string[] }) => {
calls.push(options); calls.push(options);
return '7'; return '7:1';
}, },
publish: async (_channel: string, message: string) => { publish: async (channel: string, message: string) => {
published.push(message); published.push({ channel, message });
return 1; return 1;
}, },
}; };
@@ -20,7 +20,12 @@ describe('tournament source revision', () => {
await expect( await expect(
writeTournamentProjection( writeTournamentProjection(
redis, redis,
{ sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' }, {
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[ [
{ key: 'state', value: { stage: 1 } }, { key: 'state', value: { stage: 1 } },
{ key: 'matches', value: [] }, { key: 'matches', value: [] },
@@ -34,18 +39,35 @@ describe('tournament source revision', () => {
arguments: [JSON.stringify({ stage: 1 }), '[]'], 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 () => { it('rejects empty or duplicate writes before evaluating Redis', async () => {
const redis = { eval: async () => '1' }; const redis = { eval: async () => '1' };
await expect( await expect(
writeTournamentProjection(redis, { sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' }, []) writeTournamentProjection(
redis,
{
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[]
)
).rejects.toThrow('at least one'); ).rejects.toThrow('at least one');
await expect( await expect(
writeTournamentProjection( writeTournamentProjection(
redis, redis,
{ sourceRevisionKey: 'revision', sourceRevisionChannel: 'changed' }, {
stateKey: 'state',
sourceRevisionKey: 'revision',
sourceRevisionChannel: 'changed',
realtimeEventChannel: 'realtime',
},
[ [
{ key: 'state', value: 1 }, { key: 'state', value: 1 },
{ key: 'state', value: 2 }, { key: 'state', value: 2 },
@@ -53,4 +75,79 @@ describe('tournament source revision', () => {
) )
).rejects.toThrow('unique'); ).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']);
});
}); });