diff --git a/app/game-api/src/realtime/publicEvent.ts b/app/game-api/src/realtime/publicEvent.ts index 3417ccfb..20b086cb 100644 --- a/app/game-api/src/realtime/publicEvent.ts +++ b/app/game-api/src/realtime/publicEvent.ts @@ -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', diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index b2bc5971..8d3cc216 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -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' }, diff --git a/app/game-api/src/services/generalAccess.ts b/app/game-api/src/services/generalAccess.ts index b0e1561f..bba0a5d0 100644 --- a/app/game-api/src/services/generalAccess.ts +++ b/app/game-api/src/services/generalAccess.ts @@ -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([ 'diplomacy.rollbackLetter', 'diplomacy.destroyLetter', 'betting.getList', - 'general.getFrontStatus', 'yearbook.getHistory', 'messages.send', 'turns.getCommandTable', ]); -export const generalAccessLimitBeforeRecordEndpoints = new Set(['general.getFrontStatus']); - export type GeneralAccessState = { generalId: number; refreshScore: number; diff --git a/app/game-api/src/tournament/keys.ts b/app/game-api/src/tournament/keys.ts index f0fefcef..e560483d 100644 --- a/app/game-api/src/tournament/keys.ts +++ b/app/game-api/src/tournament/keys.ts @@ -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), }); diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 496de08f..9a411680 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -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({ diff --git a/app/game-api/test/generalAccessTracking.integration.test.ts b/app/game-api/test/generalAccessTracking.integration.test.ts index 0c138751..1d4e3235 100644 --- a/app/game-api/test/generalAccessTracking.integration.test.ts +++ b/app/game-api/test/generalAccessTracking.integration.test.ts @@ -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', }); diff --git a/app/game-api/test/generalAccessTracking.test.ts b/app/game-api/test/generalAccessTracking.test.ts index 0e701620..c396b45e 100644 --- a/app/game-api/test/generalAccessTracking.test.ts +++ b/app/game-api/test/generalAccessTracking.test.ts @@ -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(); }); diff --git a/app/game-api/test/publicRealtimeEvent.test.ts b/app/game-api/test/publicRealtimeEvent.test.ts index db0a00a4..b409761d 100644 --- a/app/game-api/test/publicRealtimeEvent.test.ts +++ b/app/game-api/test/publicRealtimeEvent.test.ts @@ -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', diff --git a/app/game-api/test/realtimeSse.test.ts b/app/game-api/test/realtimeSse.test.ts index ce214a02..accefabe 100644 --- a/app/game-api/test/realtimeSse.test.ts +++ b/app/game-api/test/realtimeSse.test.ts @@ -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', () => { diff --git a/app/game-api/test/tournamentStoreRevision.integration.test.ts b/app/game-api/test/tournamentStoreRevision.integration.test.ts index cca74c1b..ffa119be 100644 --- a/app/game-api/test/tournamentStoreRevision.integration.test.ts +++ b/app/game-api/test/tournamentStoreRevision.integration.test.ts @@ -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 => { + 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((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' }), + ]); + }); }); diff --git a/app/game-api/test/tournamentStoreRevision.test.ts b/app/game-api/test/tournamentStoreRevision.test.ts index cea7a1dd..9269c0c3 100644 --- a/app/game-api/test/tournamentStoreRevision.test.ts +++ b/app/game-api/test/tournamentStoreRevision.test.ts @@ -35,7 +35,8 @@ class AtomicMemoryRedis { } async publish(channel: string, message: string): Promise { - 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')); diff --git a/app/game-engine/src/turn/ai/generalAi/core.ts b/app/game-engine/src/turn/ai/generalAi/core.ts index 75dbe0ad..17740a19 100644 --- a/app/game-engine/src/turn/ai/generalAi/core.ts +++ b/app/game-engine/src/turn/ai/generalAi/core.ts @@ -238,7 +238,12 @@ export class GeneralAI { private devRate: Record | 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 | null = null; private readonly initialGeneralMeta: Record; @@ -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 | 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, + }; } } diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 395b8e99..ea21c4a5 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -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 }); diff --git a/app/game-engine/src/turn/runtimeClockShift.ts b/app/game-engine/src/turn/runtimeClockShift.ts index 84f8b5ed..ae532fb7 100644 --- a/app/game-engine/src/turn/runtimeClockShift.ts +++ b/app/game-engine/src/turn/runtimeClockShift.ts @@ -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 => { 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(); diff --git a/app/game-engine/src/turn/tournamentAutoStart.ts b/app/game-engine/src/turn/tournamentAutoStart.ts index a80f2d2c..a3172728 100644 --- a/app/game-engine/src/turn/tournamentAutoStart.ts +++ b/app/game-engine/src/turn/tournamentAutoStart.ts @@ -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(await redis.get(keys.state)); + const previousState = safeJsonParse(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]; diff --git a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts index 95743cf4..82b76ed2 100644 --- a/app/game-engine/test/generalAiLegacyDecisionParity.test.ts +++ b/app/game-engine/test/generalAiLegacyDecisionParity.test.ts @@ -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 => ({ + ...baseGeneral(), + ...overrides, + stats: { ...baseGeneral().stats, ...overrides.stats }, + meta: { ...baseGeneral().meta, belong: 1, ...overrides.meta }, +}); + +const makePromotionAi = (options: { + ruler: TurnGeneral; + generals: TurnGeneral[]; + nation?: Partial; + 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 => + Object.fromEntries(entries.map((general) => [general.id, general])); + const asChiefRecord = (entries: TurnGeneral[] = []): Record => + 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, { + updateInstance: () => undefined, + categorizeNationCities: () => undefined, + categorizeNationGeneral: () => undefined, + nationPolicy: { priority: [] }, + buildNationCandidate: (action: string, args: Record, 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; diff --git a/app/game-engine/test/npcGeneralDomesticTurn.test.ts b/app/game-engine/test/npcGeneralDomesticTurn.test.ts index 769cd8ed..2260a50a 100644 --- a/app/game-engine/test/npcGeneralDomesticTurn.test.ts +++ b/app/game-engine/test/npcGeneralDomesticTurn.test.ts @@ -299,4 +299,141 @@ describe('NPC 일반 내정 턴', () => { }) ); }); + + it('NPC 군주 국가턴에서 신규 유저의 수뇌 직책과 외교 권한을 월드 상태에 반영한다', async () => { + const buildGeneral = (overrides: Partial): 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 }); + }); }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 07bdd257..eac9a843 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -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>> = {}; + 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, }, } ); diff --git a/app/game-frontend/src/components/main/CommandSelectForm.vue b/app/game-frontend/src/components/main/CommandSelectForm.vue index 50ab4929..23ee56af 100644 --- a/app/game-frontend/src/components/main/CommandSelectForm.vue +++ b/app/game-frontend/src/components/main/CommandSelectForm.vue @@ -130,7 +130,12 @@ const commandTitle = (command: CommandAvailability) =>