merge: 원격 main을 프런트엔드 import 최적화에 통합
This commit is contained in:
@@ -145,7 +145,16 @@ describe('buildTurnCommandTable', () => {
|
|||||||
'che_소집해제',
|
'che_소집해제',
|
||||||
'che_첩보',
|
'che_첩보',
|
||||||
],
|
],
|
||||||
인사: ['che_이동', 'che_강행', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'],
|
인사: [
|
||||||
|
'che_이동',
|
||||||
|
'che_강행',
|
||||||
|
'che_인재탐색',
|
||||||
|
'che_등용',
|
||||||
|
'che_귀환',
|
||||||
|
'che_임관',
|
||||||
|
'che_랜덤임관',
|
||||||
|
'che_장수대상임관',
|
||||||
|
],
|
||||||
계략: ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'],
|
계략: ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'],
|
||||||
국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_하야', 'che_거병', 'che_건국', 'che_선양', 'che_해산'],
|
국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_하야', 'che_거병', 'che_건국', 'che_선양', 'che_해산'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2204,9 +2204,18 @@ export const createImmediateGeneralActionExecutor = async (options: {
|
|||||||
}): Promise<ImmediateGeneralActionExecutor> => {
|
}): Promise<ImmediateGeneralActionExecutor> => {
|
||||||
const env = buildCommandEnv(options.world.getScenarioConfig(), options.world.getUnitSet());
|
const env = buildCommandEnv(options.world.getScenarioConfig(), options.world.getUnitSet());
|
||||||
const commandProfile = options.commandProfile ?? DEFAULT_TURN_COMMAND_PROFILE;
|
const commandProfile = options.commandProfile ?? DEFAULT_TURN_COMMAND_PROFILE;
|
||||||
|
// 등용수락은 예약 화면에 노출되는 명령이 아니라 등용 서신의 응답이
|
||||||
|
// 직접 실행하는 내부 명령이다. 선택 가능 명령 프로필에 없더라도 등용
|
||||||
|
// 서신을 수락할 수 있도록 즉시 행동 정의에는 항상 포함한다.
|
||||||
|
const immediateCommandProfile: TurnCommandProfile = commandProfile.general.includes('che_등용수락')
|
||||||
|
? commandProfile
|
||||||
|
: {
|
||||||
|
...commandProfile,
|
||||||
|
general: [...commandProfile.general, 'che_등용수락'],
|
||||||
|
};
|
||||||
const { general: definitions } = await buildReservedTurnDefinitions({
|
const { general: definitions } = await buildReservedTurnDefinitions({
|
||||||
env,
|
env,
|
||||||
commandProfile,
|
commandProfile: immediateCommandProfile,
|
||||||
defaultActionKey: DEFAULT_ACTION,
|
defaultActionKey: DEFAULT_ACTION,
|
||||||
});
|
});
|
||||||
const generalModuleLoader = new GeneralTurnCommandLoader();
|
const generalModuleLoader = new GeneralTurnCommandLoader();
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ const buildWorld = (
|
|||||||
|
|
||||||
const buildImmediateActionWorld = (options: {
|
const buildImmediateActionWorld = (options: {
|
||||||
general: TurnGeneral;
|
general: TurnGeneral;
|
||||||
|
additionalGenerals?: TurnGeneral[];
|
||||||
cities: TurnWorldSnapshot['cities'];
|
cities: TurnWorldSnapshot['cities'];
|
||||||
nations: TurnWorldSnapshot['nations'];
|
nations: TurnWorldSnapshot['nations'];
|
||||||
map: MapDefinition;
|
map: MapDefinition;
|
||||||
@@ -162,7 +163,7 @@ const buildImmediateActionWorld = (options: {
|
|||||||
ignoreDefaultEvents: false,
|
ignoreDefaultEvents: false,
|
||||||
};
|
};
|
||||||
const snapshot: TurnWorldSnapshot = {
|
const snapshot: TurnWorldSnapshot = {
|
||||||
generals: [options.general],
|
generals: [options.general, ...(options.additionalGenerals ?? [])],
|
||||||
cities: options.cities,
|
cities: options.cities,
|
||||||
nations: options.nations,
|
nations: options.nations,
|
||||||
troops: [],
|
troops: [],
|
||||||
@@ -480,6 +481,81 @@ describe('my information world commands', () => {
|
|||||||
expect(nextIntInclusive).not.toHaveBeenCalled();
|
expect(nextIntInclusive).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads the internal recruitment acceptance action outside the selectable command profile', async () => {
|
||||||
|
const recipient = buildGeneral({
|
||||||
|
id: 8,
|
||||||
|
userId: 'user-8',
|
||||||
|
name: '재야장수',
|
||||||
|
nationId: 0,
|
||||||
|
cityId: 1,
|
||||||
|
officerLevel: 0,
|
||||||
|
});
|
||||||
|
const recruiter = buildGeneral({
|
||||||
|
id: 9,
|
||||||
|
userId: 'user-9',
|
||||||
|
name: '등용장수',
|
||||||
|
nationId: 2,
|
||||||
|
cityId: 2,
|
||||||
|
});
|
||||||
|
const map = {
|
||||||
|
id: 'test',
|
||||||
|
name: 'test',
|
||||||
|
cities: [buildMapCity(1, [2]), buildMapCity(2, [1])],
|
||||||
|
};
|
||||||
|
const fixture = buildImmediateActionWorld({
|
||||||
|
general: recipient,
|
||||||
|
additionalGenerals: [recruiter],
|
||||||
|
cities: [
|
||||||
|
{ id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} },
|
||||||
|
{ id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} },
|
||||||
|
] as TurnWorldSnapshot['cities'],
|
||||||
|
nations: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: '등용국',
|
||||||
|
color: '#222222',
|
||||||
|
typeCode: 'che_중립',
|
||||||
|
level: 1,
|
||||||
|
capitalCityId: 2,
|
||||||
|
chiefGeneralId: recruiter.id,
|
||||||
|
gold: 0,
|
||||||
|
rice: 0,
|
||||||
|
power: 0,
|
||||||
|
meta: { gennum: 1 },
|
||||||
|
},
|
||||||
|
] as TurnWorldSnapshot['nations'],
|
||||||
|
map,
|
||||||
|
});
|
||||||
|
const executor = await createImmediateGeneralActionExecutor({
|
||||||
|
world: fixture.world,
|
||||||
|
reservedTurns: fixture.reservedTurns,
|
||||||
|
scenarioMeta: fixture.scenarioMeta,
|
||||||
|
map,
|
||||||
|
commandProfile: {
|
||||||
|
general: ['che_등용'],
|
||||||
|
nation: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
executor.execute({
|
||||||
|
actionKey: 'che_등용수락',
|
||||||
|
generalId: recipient.id,
|
||||||
|
rng: new RandUtil(new LiteHashDRBG('accept-recruitment-letter')),
|
||||||
|
args: { destNationId: 2, destGeneralId: recruiter.id },
|
||||||
|
})
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({
|
||||||
|
nationId: 2,
|
||||||
|
cityId: 2,
|
||||||
|
officerLevel: 1,
|
||||||
|
});
|
||||||
|
expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({
|
||||||
|
experience: recruiter.experience + 100,
|
||||||
|
dedication: recruiter.dedication + 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves the Ref uprising precheck order and messages after the game starts', async () => {
|
it('preserves the Ref uprising precheck order and messages after the game starts', async () => {
|
||||||
const general = buildGeneral({ nationId: 1, cityId: 1 });
|
const general = buildGeneral({ nationId: 1, cityId: 1 });
|
||||||
const fixture = buildImmediateActionWorld({
|
const fixture = buildImmediateActionWorld({
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||||
|
const frontendPort = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15154);
|
||||||
|
const apiPort = Number(process.env.PLAYWRIGHT_GAME_API_PORT ?? 15155);
|
||||||
|
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
|
||||||
|
const profileId = process.env.PLAYWRIGHT_PROFILE_ID ?? 'default_recruit_commands_live_integration';
|
||||||
|
const scenario = process.env.PLAYWRIGHT_SCENARIO ?? '2';
|
||||||
|
const expectedGameProfile = `${profileId}:${scenario}`;
|
||||||
|
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? expectedGameProfile;
|
||||||
|
const baseURL = `http://127.0.0.1:${frontendPort}${basePath}/`;
|
||||||
|
const gameApiUrl = `http://127.0.0.1:${apiPort}/trpc`;
|
||||||
|
const databaseUrl = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_DATABASE_URL ?? '';
|
||||||
|
const redisUrl = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_REDIS_URL ?? '';
|
||||||
|
const gameSecret = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_GAME_SECRET ?? '';
|
||||||
|
const imageUploadSecretFile = process.env.GAME_IMAGE_UPLOAD_SECRET_FILE ?? '';
|
||||||
|
const databaseSchema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
|
||||||
|
|
||||||
|
if (databaseUrl && databaseSchema !== profileId) {
|
||||||
|
throw new Error(
|
||||||
|
`Default recruit commands live schema must match its profile ID: ${databaseSchema ?? '(missing)'} != ${profileId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (gameProfile !== expectedGameProfile) {
|
||||||
|
throw new Error(
|
||||||
|
`PLAYWRIGHT_GAME_PROFILE must match profile and scenario: ${gameProfile} != ${expectedGameProfile}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: '.',
|
||||||
|
testMatch: ['defaultRecruitCommandsLive.spec.ts'],
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
timeout: 90_000,
|
||||||
|
expect: {
|
||||||
|
timeout: 15_000,
|
||||||
|
},
|
||||||
|
reporter: [['list']],
|
||||||
|
outputDir: resolve(repositoryRoot, 'test-results/default-recruit-commands-live'),
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
locale: 'ko-KR',
|
||||||
|
timezoneId: 'Asia/Seoul',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
command: 'node app/game-api/dist/index.js',
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
url: `http://127.0.0.1:${apiPort}/healthz`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
env: {
|
||||||
|
DATABASE_URL: databaseUrl,
|
||||||
|
REDIS_URL: redisUrl,
|
||||||
|
GAME_TOKEN_SECRET: gameSecret,
|
||||||
|
GAME_IMAGE_UPLOAD_SECRET_FILE: imageUploadSecretFile,
|
||||||
|
GAME_API_ROLE: 'server',
|
||||||
|
GAME_API_HOST: '127.0.0.1',
|
||||||
|
GAME_API_PORT: String(apiPort),
|
||||||
|
PROFILE: profileId,
|
||||||
|
SCENARIO: scenario,
|
||||||
|
GAME_PROFILE_NAME: gameProfile,
|
||||||
|
DAEMON_REQUEST_TIMEOUT_MS: '15000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${frontendPort}`,
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
url: baseURL,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { expect, test, type Browser, type Page } from '@playwright/test';
|
||||||
|
import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js';
|
||||||
|
import { createTurnDaemonRuntime, seedScenarioToDatabase } from '../../game-engine/dist/index.js';
|
||||||
|
import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js';
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_DATABASE_URL;
|
||||||
|
const redisUrl = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_REDIS_URL;
|
||||||
|
const gameTokenSecret = process.env.DEFAULT_RECRUIT_COMMANDS_LIVE_GAME_SECRET;
|
||||||
|
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'default_recruit_commands_live_integration:2';
|
||||||
|
const scenarioId = Number(process.env.PLAYWRIGHT_SCENARIO ?? '2');
|
||||||
|
const hasLiveFixture = Boolean(databaseUrl && redisUrl && gameTokenSecret);
|
||||||
|
const recruiterId = 7_751;
|
||||||
|
const recipientId = 7_752;
|
||||||
|
const followerId = 7_753;
|
||||||
|
const fixtureNationId = 9_917;
|
||||||
|
const recruiterUserId = 'default-recruit-live-recruiter';
|
||||||
|
const recipientUserId = 'default-recruit-live-recipient';
|
||||||
|
const followerUserId = 'default-recruit-live-follower';
|
||||||
|
const fixtureNow = new Date('2099-08-01T00:00:00.000Z');
|
||||||
|
const dueAt = new Date('2099-08-01T00:01:00.000Z');
|
||||||
|
const runThrough = new Date('2099-08-01T00:02:00.000Z');
|
||||||
|
|
||||||
|
const installSession = async (page: Page, userId: string, displayName: string): Promise<void> => {
|
||||||
|
const issuedAt = new Date();
|
||||||
|
const token = encryptGameSessionToken(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
profile,
|
||||||
|
issuedAt: issuedAt.toISOString(),
|
||||||
|
expiresAt: new Date(issuedAt.getTime() + 3_600_000).toISOString(),
|
||||||
|
sessionId: `default-recruit-live-${randomUUID()}`,
|
||||||
|
user: {
|
||||||
|
id: userId,
|
||||||
|
username: userId,
|
||||||
|
displayName,
|
||||||
|
roles: ['user'],
|
||||||
|
canUseGeneralPicture: false,
|
||||||
|
},
|
||||||
|
sanctions: {},
|
||||||
|
identity: {
|
||||||
|
kakaoVerified: true,
|
||||||
|
canCreateGeneral: true,
|
||||||
|
requiresKakaoVerification: false,
|
||||||
|
graceEndsAt: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gameTokenSecret!
|
||||||
|
);
|
||||||
|
await page.addInitScript(
|
||||||
|
({ gameToken, gameProfile }) => {
|
||||||
|
localStorage.setItem('sammo-game-token', gameToken);
|
||||||
|
localStorage.setItem('sammo-game-profile', gameProfile);
|
||||||
|
},
|
||||||
|
{ gameToken: token, gameProfile: profile }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const newPage = async (browser: Browser, userId: string, displayName: string): Promise<Page> => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1365, height: 1000 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
locale: 'ko-KR',
|
||||||
|
timezoneId: 'Asia/Seoul',
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await installSession(page, userId, displayName);
|
||||||
|
await page.route('**/image/**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'image/svg+xml',
|
||||||
|
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return page;
|
||||||
|
};
|
||||||
|
|
||||||
|
const reserveFirstTurn = async (page: Page, commandName: string, targetName: string): Promise<void> => {
|
||||||
|
await page.goto('.');
|
||||||
|
const commandPanel = page.locator('[data-main-target="commands"]');
|
||||||
|
await expect(commandPanel).toBeVisible();
|
||||||
|
await commandPanel.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||||
|
const picker = page.getByTestId('command-picker');
|
||||||
|
await expect(picker).toBeVisible();
|
||||||
|
await picker.getByRole('button', { name: '인사', exact: true }).click();
|
||||||
|
await picker.getByRole('button', { name: commandName, exact: true }).click();
|
||||||
|
const targetOption = picker.locator('select option').filter({ hasText: targetName });
|
||||||
|
await expect(targetOption).toHaveCount(1);
|
||||||
|
const targetValue = await targetOption.getAttribute('value');
|
||||||
|
if (!targetValue) throw new Error(`Missing target value for ${targetName}.`);
|
||||||
|
await picker.locator('select').selectOption(targetValue);
|
||||||
|
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||||
|
await expect(picker).toBeHidden();
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('default recruitment commands through live PostgreSQL, Redis, API, daemon, and Chromium', () => {
|
||||||
|
test.skip(!hasLiveFixture, 'dedicated PostgreSQL, Redis, and a game token secret are required');
|
||||||
|
|
||||||
|
let db: ReturnType<typeof createGamePostgresConnector>['prisma'];
|
||||||
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
|
let runtime: Awaited<ReturnType<typeof createTurnDaemonRuntime>> | undefined;
|
||||||
|
let daemonLoop: Promise<void> | undefined;
|
||||||
|
let nationId = 0;
|
||||||
|
let nationName = '';
|
||||||
|
let capitalCityId = 0;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
const schema = new URL(databaseUrl!).searchParams.get('schema');
|
||||||
|
const profileId = profile.split(':', 1)[0] ?? '';
|
||||||
|
if (schema !== profileId || !schema.endsWith('default_recruit_commands_live_integration')) {
|
||||||
|
throw new Error(`Refusing mismatched or non-dedicated schema: ${schema ?? '(missing)'} != ${profileId}`);
|
||||||
|
}
|
||||||
|
const previousSeed = process.env.INTEGRATION_WORLD_SEED;
|
||||||
|
process.env.INTEGRATION_WORLD_SEED = 'default-recruit-commands-live-seed';
|
||||||
|
try {
|
||||||
|
await seedScenarioToDatabase({
|
||||||
|
scenarioId,
|
||||||
|
databaseUrl: databaseUrl!,
|
||||||
|
now: fixtureNow,
|
||||||
|
gameClockMode: 'manual',
|
||||||
|
installOptions: {
|
||||||
|
turnTermMinutes: 5,
|
||||||
|
joinMode: 'full',
|
||||||
|
npcMode: 0,
|
||||||
|
showImgLevel: 3,
|
||||||
|
serverId: profile,
|
||||||
|
season: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (previousSeed === undefined) delete process.env.INTEGRATION_WORLD_SEED;
|
||||||
|
else process.env.INTEGRATION_WORLD_SEED = previousSeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||||
|
await connector.connect();
|
||||||
|
db = connector.prisma;
|
||||||
|
closeDb = () => connector.disconnect();
|
||||||
|
await db.inputEvent.deleteMany();
|
||||||
|
await db.message.deleteMany();
|
||||||
|
await db.generalTurn.deleteMany({ where: { generalId: { in: [recruiterId, recipientId, followerId] } } });
|
||||||
|
await db.generalTurnRevision.deleteMany({
|
||||||
|
where: { generalId: { in: [recruiterId, recipientId, followerId] } },
|
||||||
|
});
|
||||||
|
await db.general.deleteMany({ where: { id: { in: [recruiterId, recipientId, followerId] } } });
|
||||||
|
await db.general.updateMany({
|
||||||
|
data: { turnTime: new Date('2199-01-01T00:00:00.000Z'), turnTick: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const capitalCity = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||||
|
nationId = fixtureNationId;
|
||||||
|
nationName = '실등용국';
|
||||||
|
capitalCityId = capitalCity.id;
|
||||||
|
await db.nation.upsert({
|
||||||
|
where: { id: fixtureNationId },
|
||||||
|
create: {
|
||||||
|
id: fixtureNationId,
|
||||||
|
name: nationName,
|
||||||
|
color: '#225500',
|
||||||
|
capitalCityId,
|
||||||
|
level: 1,
|
||||||
|
meta: { gennum: 0, scout: 0 },
|
||||||
|
},
|
||||||
|
update: { name: nationName, capitalCityId, level: 1, meta: { gennum: 0, scout: 0 } },
|
||||||
|
});
|
||||||
|
await db.city.update({
|
||||||
|
where: { id: capitalCityId },
|
||||||
|
data: { nationId, supplyState: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseGeneral = {
|
||||||
|
cityId: capitalCityId,
|
||||||
|
troopId: 0,
|
||||||
|
npcState: 0,
|
||||||
|
leadership: 70,
|
||||||
|
strength: 60,
|
||||||
|
intel: 50,
|
||||||
|
officerLevel: 0,
|
||||||
|
experience: 100,
|
||||||
|
dedication: 100,
|
||||||
|
gold: 10_000,
|
||||||
|
rice: 10_000,
|
||||||
|
turnTime: dueAt,
|
||||||
|
turnTick: null,
|
||||||
|
meta: { killturn: 960, belong: 1 },
|
||||||
|
penalty: {},
|
||||||
|
} as const;
|
||||||
|
await db.general.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
...baseGeneral,
|
||||||
|
id: recruiterId,
|
||||||
|
userId: recruiterUserId,
|
||||||
|
name: '실등용제안자',
|
||||||
|
nationId,
|
||||||
|
officerLevel: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...baseGeneral,
|
||||||
|
id: recipientId,
|
||||||
|
userId: recipientUserId,
|
||||||
|
name: '실등용수신자',
|
||||||
|
nationId: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...baseGeneral,
|
||||||
|
id: followerId,
|
||||||
|
userId: followerUserId,
|
||||||
|
name: '실대상임관자',
|
||||||
|
nationId: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await db.generalTurn.createMany({
|
||||||
|
data: [recruiterId, recipientId, followerId].map((generalId) => ({
|
||||||
|
generalId,
|
||||||
|
turnIdx: 0,
|
||||||
|
actionCode: '휴식',
|
||||||
|
arg: {},
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await db.generalTurnRevision.createMany({
|
||||||
|
data: [recruiterId, recipientId, followerId].map((generalId) => ({ generalId, revision: 1 })),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (runtime) {
|
||||||
|
await runtime.lifecycle.stop('default recruit commands live complete');
|
||||||
|
await daemonLoop;
|
||||||
|
await runtime.close();
|
||||||
|
}
|
||||||
|
await closeDb?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows both commands, delivers the recruitment letter, and appoints on acceptance', async ({
|
||||||
|
browser,
|
||||||
|
}, testInfo) => {
|
||||||
|
const recruiterPage = await newPage(browser, recruiterUserId, '실등용제안자');
|
||||||
|
await reserveFirstTurn(recruiterPage, '등용', '실등용수신자');
|
||||||
|
await expect(
|
||||||
|
db.generalTurn.findUniqueOrThrow({
|
||||||
|
where: { generalId_turnIdx: { generalId: recruiterId, turnIdx: 0 } },
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
actionCode: 'che_등용',
|
||||||
|
arg: { destGeneralId: recipientId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const followerPage = await newPage(browser, followerUserId, '실대상임관자');
|
||||||
|
await reserveFirstTurn(followerPage, '장수를 따라 임관', '실등용제안자');
|
||||||
|
await expect(
|
||||||
|
db.generalTurn.findUniqueOrThrow({
|
||||||
|
where: { generalId_turnIdx: { generalId: followerId, turnIdx: 0 } },
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
actionCode: 'che_장수대상임관',
|
||||||
|
arg: { destGeneralID: recruiterId },
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime = await createTurnDaemonRuntime({
|
||||||
|
profile,
|
||||||
|
databaseUrl: databaseUrl!,
|
||||||
|
redisUrl: redisUrl!,
|
||||||
|
enableDatabaseFlush: true,
|
||||||
|
enableLeaseHeartbeat: false,
|
||||||
|
exclusiveFastForward: true,
|
||||||
|
leaseOwnerId: 'default-recruit-commands-live-daemon',
|
||||||
|
});
|
||||||
|
daemonLoop = runtime.lifecycle.start();
|
||||||
|
await expect.poll(() => runtime?.lifecycle.getStatus().lastTurnTime).toBeTruthy();
|
||||||
|
runtime.lifecycle.requestRun('manual', runThrough);
|
||||||
|
await expect
|
||||||
|
.poll(() => {
|
||||||
|
const status = runtime?.lifecycle.getStatus();
|
||||||
|
return status?.lastError ?? status?.lastRunAt ?? null;
|
||||||
|
})
|
||||||
|
.not.toBeNull();
|
||||||
|
expect(runtime.lifecycle.getStatus().lastError).toBeUndefined();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => db.general.findUniqueOrThrow({ where: { id: followerId } }))
|
||||||
|
.toMatchObject({
|
||||||
|
nationId,
|
||||||
|
cityId: capitalCityId,
|
||||||
|
officerLevel: 1,
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
db.message.findFirst({
|
||||||
|
where: { mailbox: recipientId, type: 'private' },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.not.toBeNull();
|
||||||
|
const storedMessage = await db.message.findFirstOrThrow({
|
||||||
|
where: { mailbox: recipientId, type: 'private' },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
});
|
||||||
|
expect(storedMessage.message).toMatchObject({
|
||||||
|
src: { generalId: recruiterId, nationId },
|
||||||
|
dest: { generalId: recipientId, nationId: 0 },
|
||||||
|
option: { action: 'scout' },
|
||||||
|
});
|
||||||
|
const recipientPage = await newPage(browser, recipientUserId, '실등용수신자');
|
||||||
|
const dialogs: string[] = [];
|
||||||
|
recipientPage.on('dialog', async (dialog) => {
|
||||||
|
dialogs.push(dialog.message());
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await recipientPage.goto('.');
|
||||||
|
await expect(recipientPage.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||||
|
const letter = recipientPage.locator('.PrivateTalk .msg-plate').filter({ hasText: '망명 권유 서신' });
|
||||||
|
await expect(letter).toHaveCount(1);
|
||||||
|
await expect(letter).toHaveAttribute('data-id', String(storedMessage.id));
|
||||||
|
const accept = letter.getByRole('button', { name: '수락', exact: true });
|
||||||
|
await expect(accept).toBeEnabled();
|
||||||
|
await accept.click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => db.general.findUniqueOrThrow({ where: { id: recipientId } }))
|
||||||
|
.toMatchObject({
|
||||||
|
nationId,
|
||||||
|
cityId: capitalCityId,
|
||||||
|
officerLevel: 1,
|
||||||
|
});
|
||||||
|
expect(dialogs).toEqual(['수락하시겠습니까?']);
|
||||||
|
const invalidated = await db.message.findUniqueOrThrow({ where: { id: storedMessage.id } });
|
||||||
|
expect(invalidated.validUntil.getTime()).toBeLessThan(storedMessage.validUntil.getTime());
|
||||||
|
await expect(
|
||||||
|
db.inputEvent.findFirstOrThrow({
|
||||||
|
where: { actorUserId: recipientUserId, eventType: 'messageRespond' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({ status: 'SUCCEEDED', attempts: 1, target: 'ENGINE' });
|
||||||
|
await expect(
|
||||||
|
db.message.findFirst({
|
||||||
|
where: {
|
||||||
|
mailbox: recipientId,
|
||||||
|
id: { not: storedMessage.id },
|
||||||
|
},
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
message: expect.objectContaining({ text: `${nationName}으로 등용 제의 수락` }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await recruiterPage.screenshot({ path: testInfo.outputPath('recruit-command-reserved.png'), fullPage: true });
|
||||||
|
await followerPage.screenshot({ path: testInfo.outputPath('follow-appointment-complete.png'), fullPage: true });
|
||||||
|
await recipientPage.screenshot({ path: testInfo.outputPath('recruitment-accepted.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"test:e2e:join-layout": "playwright test joinLayout.spec.ts --config e2e/playwright.config.mjs",
|
"test:e2e:join-layout": "playwright test joinLayout.spec.ts --config e2e/playwright.config.mjs",
|
||||||
"test:e2e:npc-possession-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/npcPossession.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
|
"test:e2e:npc-possession-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/npcPossession.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
|
||||||
"test:e2e:die-on-prestart-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/dieOnPrestart.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
|
"test:e2e:die-on-prestart-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/dieOnPrestart.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
|
||||||
|
"test:e2e:default-recruit-commands-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/defaultRecruitCommands.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"typecheck": "vue-tsc --noEmit"
|
"typecheck": "vue-tsc --noEmit"
|
||||||
|
|||||||
@@ -303,6 +303,43 @@ test('exchanges the gateway token before loading authenticated lobby general dat
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shows the Ref scenario title instead of the stored scenario code for an active profile', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await installFixture(page, {
|
||||||
|
scenarioTitle: '【가상모드27-b】 아시아 명장전(비급)',
|
||||||
|
});
|
||||||
|
await page.setViewportSize({ width: 1365, height: 900 });
|
||||||
|
|
||||||
|
await page.goto('lobby');
|
||||||
|
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
||||||
|
const info = row.locator('.profile-info-cell');
|
||||||
|
const title = row.getByTestId('profile-scenario-title');
|
||||||
|
await expect(title).toHaveText('【가상모드27-b】 아시아 명장전(비급)');
|
||||||
|
await expect(info).not.toContainText('903');
|
||||||
|
await expect(title).toHaveCSS('color', 'oklch(0.75 0.183 55.934)');
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('gateway-active-scenario-title-desktop.png'), fullPage: true });
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await title.scrollIntoViewIfNeeded();
|
||||||
|
const mobileGeometry = await info.evaluate((element) => {
|
||||||
|
const titleElement = element.querySelector('[data-testid="profile-scenario-title"]');
|
||||||
|
if (!titleElement) throw new Error('expected scenario title');
|
||||||
|
const infoRect = element.getBoundingClientRect();
|
||||||
|
const titleRect = titleElement.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
documentWidth: document.documentElement.scrollWidth,
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
info: infoRect.toJSON(),
|
||||||
|
title: titleRect.toJSON(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(mobileGeometry.documentWidth).toBe(mobileGeometry.viewportWidth);
|
||||||
|
expect(mobileGeometry.title.left).toBeGreaterThanOrEqual(mobileGeometry.info.left);
|
||||||
|
expect(mobileGeometry.title.right).toBeLessThanOrEqual(mobileGeometry.info.right);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('gateway-active-scenario-title-mobile.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
test('copies the complete preopen announcement and reveals autorun details without changing layout', async ({
|
test('copies the complete preopen announcement and reveals autorun details without changing layout', async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ const formatGraceEndsAt = (value: string | null | undefined): string => formatSe
|
|||||||
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
|
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
|
||||||
const formatAnnouncementDate = (value: string | null | undefined): string =>
|
const formatAnnouncementDate = (value: string | null | undefined): string =>
|
||||||
formatServerDateTime(value, { fallback: '-' });
|
formatServerDateTime(value, { fallback: '-' });
|
||||||
|
const profileScenarioTitle = (profileName: string): string =>
|
||||||
|
profileDetails.value[profileName]?.scenarioTitle.trim() || '-';
|
||||||
const npcModeText = (mode: number): string => ['불가', '가능', '선택 생성'][mode] ?? '불가';
|
const npcModeText = (mode: number): string => ['불가', '가능', '선택 생성'][mode] ?? '불가';
|
||||||
const autorunDetailText = (info: LobbyInfo): string => {
|
const autorunDetailText = (info: LobbyInfo): string => {
|
||||||
const autorun = info.autorunUser;
|
const autorun = info.autorunUser;
|
||||||
@@ -527,10 +529,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
|||||||
-
|
-
|
||||||
</div>
|
</div>
|
||||||
<div data-testid="profile-scenario-announcement">
|
<div data-testid="profile-scenario-announcement">
|
||||||
<span class="text-orange-400">{{
|
<span
|
||||||
profileDetails[profile.profileName]?.scenarioTitle ||
|
class="text-orange-400"
|
||||||
profile.scenario
|
data-testid="profile-scenario-title"
|
||||||
}}</span
|
>{{ profileScenarioTitle(profile.profileName) }}</span
|
||||||
>{{ ' ' }}
|
>{{ ' ' }}
|
||||||
<span class="text-green-400">
|
<span class="text-green-400">
|
||||||
{{ profileDetails[profile.profileName]?.turnTerm }}분 턴 서버
|
{{ profileDetails[profile.profileName]?.turnTerm }}분 턴 서버
|
||||||
@@ -542,7 +544,8 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
|||||||
서기 {{ profileDetails[profile.profileName]?.year }}년
|
서기 {{ profileDetails[profile.profileName]?.year }}년
|
||||||
{{ profileDetails[profile.profileName]?.month }}월 (<span
|
{{ profileDetails[profile.profileName]?.month }}월 (<span
|
||||||
class="text-orange-400"
|
class="text-orange-400"
|
||||||
>{{ profile.scenario }}</span
|
data-testid="profile-scenario-title"
|
||||||
|
>{{ profileScenarioTitle(profile.profileName) }}</span
|
||||||
>)
|
>)
|
||||||
</div>
|
</div>
|
||||||
<div class="text-zinc-400">
|
<div class="text-zinc-400">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"general": [
|
"general": [
|
||||||
"che_거병",
|
"che_거병",
|
||||||
"che_임관",
|
"che_임관",
|
||||||
|
"che_장수대상임관",
|
||||||
"che_랜덤임관",
|
"che_랜덤임관",
|
||||||
"che_귀환",
|
"che_귀환",
|
||||||
"che_건국",
|
"che_건국",
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"che_화계",
|
"che_화계",
|
||||||
"che_집합",
|
"che_집합",
|
||||||
"che_인재탐색",
|
"che_인재탐색",
|
||||||
|
"che_등용",
|
||||||
"che_징병",
|
"che_징병",
|
||||||
"che_모병",
|
"che_모병",
|
||||||
"che_소집해제",
|
"che_소집해제",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const root = process.cwd();
|
|||||||
const refRoot = resolveRefRoot(root);
|
const refRoot = resolveRefRoot(root);
|
||||||
const phpDir = path.join(refRoot, 'hwe/sammo/Command/General');
|
const phpDir = path.join(refRoot, 'hwe/sammo/Command/General');
|
||||||
const tsDir = path.join(root, 'packages/logic/src/actions/turn/general');
|
const tsDir = path.join(root, 'packages/logic/src/actions/turn/general');
|
||||||
|
const refGameConstPath = path.join(refRoot, 'hwe/sammo/GameConstBase.php');
|
||||||
|
const defaultProfilePath = path.join(root, 'resources/turn-commands/default.json');
|
||||||
const check = process.argv.includes('--check');
|
const check = process.argv.includes('--check');
|
||||||
|
|
||||||
const activeAction = new Map([
|
const activeAction = new Map([
|
||||||
@@ -39,6 +41,31 @@ const readSources = async (dir, extension) => {
|
|||||||
const php = await readSources(phpDir, '.php');
|
const php = await readSources(phpDir, '.php');
|
||||||
const ts = await readSources(tsDir, '.ts');
|
const ts = await readSources(tsDir, '.ts');
|
||||||
const handlerSource = await fs.readFile(path.join(root, 'app/game-engine/src/turn/reservedTurnHandler.ts'), 'utf8');
|
const handlerSource = await fs.readFile(path.join(root, 'app/game-engine/src/turn/reservedTurnHandler.ts'), 'utf8');
|
||||||
|
const refGameConstSource = await fs.readFile(refGameConstPath, 'utf8');
|
||||||
|
const defaultProfile = JSON.parse(await fs.readFile(defaultProfilePath, 'utf8'));
|
||||||
|
|
||||||
|
const generalProfileStart = refGameConstSource.indexOf('public static $availableGeneralCommand');
|
||||||
|
const generalProfileEnd = refGameConstSource.indexOf('public static $availableChiefCommand', generalProfileStart);
|
||||||
|
if (generalProfileStart < 0 || generalProfileEnd < 0) {
|
||||||
|
throw new Error(`Unable to locate Ref availableGeneralCommand in ${refGameConstPath}`);
|
||||||
|
}
|
||||||
|
const refDefaultGeneralCommands = [
|
||||||
|
...refGameConstSource.slice(generalProfileStart, generalProfileEnd).matchAll(/^\s*'([^']+)',?\s*$/gm),
|
||||||
|
].map((match) => match[1]);
|
||||||
|
const coreDefaultGeneralCommands = Array.isArray(defaultProfile.general)
|
||||||
|
? defaultProfile.general.filter((value) => typeof value === 'string')
|
||||||
|
: [];
|
||||||
|
const refDefaultGeneralCommandSet = new Set(refDefaultGeneralCommands);
|
||||||
|
const coreDefaultGeneralCommandSet = new Set(coreDefaultGeneralCommands);
|
||||||
|
const missingDefaultGeneralCommands = refDefaultGeneralCommands.filter(
|
||||||
|
(command) => !coreDefaultGeneralCommandSet.has(command)
|
||||||
|
);
|
||||||
|
const extraDefaultGeneralCommands = coreDefaultGeneralCommands.filter(
|
||||||
|
(command) => !refDefaultGeneralCommandSet.has(command)
|
||||||
|
);
|
||||||
|
const duplicateDefaultGeneralCommands = coreDefaultGeneralCommands.filter(
|
||||||
|
(command, index) => coreDefaultGeneralCommands.indexOf(command) !== index
|
||||||
|
);
|
||||||
|
|
||||||
const phpParent = new Map();
|
const phpParent = new Map();
|
||||||
for (const [key, source] of php) {
|
for (const [key, source] of php) {
|
||||||
@@ -127,6 +154,15 @@ const dynamicChecks = [
|
|||||||
|
|
||||||
console.log(`General command inventory: PHP ${php.size}, TS ${ts.size}`);
|
console.log(`General command inventory: PHP ${php.size}, TS ${ts.size}`);
|
||||||
console.log(`Missing TS: ${missingTs.length}; Extra TS: ${extraTs.length}`);
|
console.log(`Missing TS: ${missingTs.length}; Extra TS: ${extraTs.length}`);
|
||||||
|
console.log(
|
||||||
|
`Default general command inventory: Ref ${refDefaultGeneralCommands.length}, Core ${coreDefaultGeneralCommands.length}`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Default profile missing: ${missingDefaultGeneralCommands.length}; extra: ${extraDefaultGeneralCommands.length}; duplicate: ${duplicateDefaultGeneralCommands.length}`
|
||||||
|
);
|
||||||
|
for (const command of missingDefaultGeneralCommands) console.log(`default missing ${command}`);
|
||||||
|
for (const command of extraDefaultGeneralCommands) console.log(`default extra ${command}`);
|
||||||
|
for (const command of duplicateDefaultGeneralCommands) console.log(`default duplicate ${command}`);
|
||||||
console.log(`Timing mismatches: ${timingMismatches.length}`);
|
console.log(`Timing mismatches: ${timingMismatches.length}`);
|
||||||
console.log(`Active-action mismatches: ${activeMismatches.length}`);
|
console.log(`Active-action mismatches: ${activeMismatches.length}`);
|
||||||
for (const mismatch of timingMismatches) console.log('timing', JSON.stringify(mismatch));
|
for (const mismatch of timingMismatches) console.log('timing', JSON.stringify(mismatch));
|
||||||
@@ -137,6 +173,9 @@ if (
|
|||||||
check &&
|
check &&
|
||||||
(missingTs.length > 0 ||
|
(missingTs.length > 0 ||
|
||||||
extraTs.length > 0 ||
|
extraTs.length > 0 ||
|
||||||
|
missingDefaultGeneralCommands.length > 0 ||
|
||||||
|
extraDefaultGeneralCommands.length > 0 ||
|
||||||
|
duplicateDefaultGeneralCommands.length > 0 ||
|
||||||
timingMismatches.length > 0 ||
|
timingMismatches.length > 0 ||
|
||||||
activeMismatches.length > 0 ||
|
activeMismatches.length > 0 ||
|
||||||
dynamicChecks.some((item) => !item.ok))
|
dynamicChecks.some((item) => !item.ok))
|
||||||
|
|||||||
Reference in New Issue
Block a user