fix: 빙의 전용 화면과 가오픈 갱신을 보정한다
This commit is contained in:
@@ -1022,10 +1022,10 @@ export const generalRouter = router({
|
||||
loadCurrentGameTime(ctx.db, now),
|
||||
]);
|
||||
// vote_poll timestamps are logical game-wall values. During PREOPEN the
|
||||
// logical clock is held at the future open anchor, so comparing them with
|
||||
// JavaScript wall time inside a timestamp-without-time-zone predicate can
|
||||
// hide an otherwise active poll. Resolve the same tick-first clock contract
|
||||
// used by voting instead; hasVoted only affects the notice, not activity.
|
||||
// logical clock advances through negative ticks before the opening anchor,
|
||||
// so comparing them with JavaScript wall time inside a timestamp-without-time-zone
|
||||
// predicate can hide an otherwise active poll. Resolve the same tick-first
|
||||
// clock contract used by voting; hasVoted only affects the notice, not activity.
|
||||
const latestVote = openPolls.find((poll) => isFrontStatusPollActive(poll, gameTime)) ?? null;
|
||||
|
||||
const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId);
|
||||
|
||||
@@ -302,6 +302,7 @@ export const joinRouter = router({
|
||||
}
|
||||
|
||||
const config = asRecord(worldState.config);
|
||||
const blockGeneralCreate = Math.floor(asNumber(config.blockGeneralCreate, 0));
|
||||
const configConst = asRecord(config.const);
|
||||
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
|
||||
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
|
||||
@@ -348,7 +349,8 @@ export const joinRouter = router({
|
||||
return {
|
||||
rules: {
|
||||
stat: resolveJoinStat(worldState),
|
||||
allowCustomName: (Math.floor(asNumber(config.blockGeneralCreate, 0)) & 2) === 0,
|
||||
allowDirectCreation: (blockGeneralCreate & 1) === 0,
|
||||
allowCustomName: (blockGeneralCreate & 2) === 0,
|
||||
},
|
||||
user: {
|
||||
id: ctx.auth?.user.id ?? '',
|
||||
|
||||
@@ -87,6 +87,7 @@ export const lobbyRouter = router({
|
||||
: null,
|
||||
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
|
||||
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
|
||||
directGeneralCreationEnabled: (Math.floor(asNumber(rawConfig.blockGeneralCreate, 0)) & 1) === 0,
|
||||
npcPossessionEnabled: worldState.config.npcMode === 1,
|
||||
scenarioTitle: typeof scenarioTitle === 'string' ? scenarioTitle : '',
|
||||
myGeneral,
|
||||
|
||||
@@ -238,6 +238,7 @@ integration('generic general creation through the durable turn daemon', () => {
|
||||
it('commits one complete ref-shaped general and survives a daemon reload', async () => {
|
||||
const auth = buildAuth(userId, '생성사용자', 4242);
|
||||
const config = await appRouter.createCaller(buildContext('create-general-config', auth)).join.getConfig();
|
||||
expect(config.rules.allowDirectCreation).toBe(true);
|
||||
expect(config.personalities.map(({ key }) => key)).not.toContain('che_은둔');
|
||||
expect(config.inherit.turnTimeZones[1]).toBe('00:05.000 ~ 00:09.999');
|
||||
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
|
||||
@@ -7,8 +7,8 @@ const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient
|
||||
({
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
clockBaseTime: new Date('2026-08-21T09:50:00.000Z'),
|
||||
clockTick: 36_000_000n,
|
||||
clockBaseTime: new Date('2026-08-21T11:00:00.000Z'),
|
||||
clockTick: 0n,
|
||||
clockMode: mode,
|
||||
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
|
||||
tickSeconds: 600,
|
||||
@@ -17,14 +17,14 @@ const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient
|
||||
}) as unknown as DatabaseClient;
|
||||
|
||||
describe('current game time projection', () => {
|
||||
it('holds a realtime clock at its persisted tick until the future wall anchor', async () => {
|
||||
it('projects negative realtime ticks until the future opening anchor', async () => {
|
||||
const db = buildDatabase();
|
||||
|
||||
const preopen = await loadCurrentGameTime(db, new Date('2026-08-21T10:30:00.000Z'));
|
||||
expect(preopen).toMatchObject({
|
||||
now: new Date('2026-08-21T10:00:00.000Z'),
|
||||
now: new Date('2026-08-21T10:30:00.000Z'),
|
||||
wallNow: new Date('2026-08-21T10:30:00.000Z'),
|
||||
tick: 36_000_000,
|
||||
tick: -108_000_000,
|
||||
mode: 'realtime',
|
||||
running: false,
|
||||
startsAt: new Date('2026-08-21T11:00:00.000Z'),
|
||||
@@ -32,8 +32,8 @@ describe('current game time projection', () => {
|
||||
|
||||
const opened = await loadCurrentGameTime(db, new Date('2026-08-21T11:00:05.000Z'));
|
||||
expect(opened).toMatchObject({
|
||||
now: new Date('2026-08-21T10:00:05.000Z'),
|
||||
tick: 36_300_000,
|
||||
now: new Date('2026-08-21T11:00:05.000Z'),
|
||||
tick: 300_000,
|
||||
running: true,
|
||||
startsAt: null,
|
||||
});
|
||||
@@ -43,8 +43,8 @@ describe('current game time projection', () => {
|
||||
const result = await loadCurrentGameTime(buildDatabase('manual'), new Date('2026-08-21T12:00:00.000Z'));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
now: new Date('2026-08-21T10:00:00.000Z'),
|
||||
tick: 36_000_000,
|
||||
now: new Date('2026-08-21T11:00:00.000Z'),
|
||||
tick: 0,
|
||||
running: false,
|
||||
startsAt: null,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
@@ -47,6 +47,10 @@ const buildContext = (
|
||||
}) as unknown as GameApiContext;
|
||||
|
||||
describe('lobby season state', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([0, 1, 2, 3])('returns legacy isunited state %i', async (isunited) => {
|
||||
const result = await appRouter
|
||||
.createCaller(buildContext({ isUnited: isunited === 0 ? 2 : 0, isunited }))
|
||||
@@ -96,15 +100,18 @@ describe('lobby season state', () => {
|
||||
expect(result.turnEngineRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes the future realtime wall anchor without advancing the preopen clock', async () => {
|
||||
it('exposes the future realtime wall anchor with a negative preopen tick', async () => {
|
||||
const wallAnchor = new Date('2099-08-21T11:00:00.000Z');
|
||||
const wallNow = new Date('2099-08-21T10:30:00.000Z');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(wallNow);
|
||||
const result = await appRouter
|
||||
.createCaller(
|
||||
buildContext(
|
||||
{},
|
||||
{
|
||||
baseTime: new Date('2026-08-21T09:00:00.000Z'),
|
||||
tick: 36_000_000n,
|
||||
baseTime: wallAnchor,
|
||||
tick: 0n,
|
||||
mode: 'realtime',
|
||||
wallAnchor,
|
||||
}
|
||||
@@ -112,13 +119,22 @@ describe('lobby season state', () => {
|
||||
)
|
||||
.lobby.info();
|
||||
|
||||
expect(result.serverTime).toBe('2026-08-21T10:00:00.000Z');
|
||||
expect(result.serverTime).toBe(wallNow.toISOString());
|
||||
expect(result.clockMode).toBe('realtime');
|
||||
expect(result.clockRunning).toBe(false);
|
||||
expect(result.clockStartsAt).toBe(wallAnchor.toISOString());
|
||||
expect(new Date(result.serverWallTime).getTime()).toBeLessThan(wallAnchor.getTime());
|
||||
});
|
||||
|
||||
it('reports direct creation and possession as independent acquisition modes', async () => {
|
||||
const result = await appRouter
|
||||
.createCaller(buildContext({}, {}, { blockGeneralCreate: 1, npcMode: 1 }))
|
||||
.lobby.info();
|
||||
|
||||
expect(result.directGeneralCreationEnabled).toBe(false);
|
||||
expect(result.npcPossessionEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves zero as the first official game index', async () => {
|
||||
const result = await appRouter.createCaller(buildContext({ gameIdx: 0 })).lobby.info();
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ const buildContext = (
|
||||
tickSeconds: 3600,
|
||||
...(options.preopenClock
|
||||
? {
|
||||
clockBaseTime: new Date('2026-08-27T16:00:00.000Z'),
|
||||
clockTick: -180_000_000n,
|
||||
clockBaseTime: new Date('2026-08-27T11:00:00.000Z'),
|
||||
clockTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date('2026-08-27T11:00:00.000Z'),
|
||||
}
|
||||
@@ -142,7 +142,7 @@ describe('general.getFrontStatus', () => {
|
||||
});
|
||||
|
||||
it('keeps a PREOPEN logical-time survey active after the authenticated general voted', async () => {
|
||||
vi.setSystemTime(new Date('2026-08-27T05:56:00.000Z'));
|
||||
vi.setSystemTime(new Date('2026-08-27T10:56:00.000Z'));
|
||||
const context = buildContext({ hasVoted: true, preopenClock: true });
|
||||
|
||||
await expect(appRouter.createCaller(context).general.getFrontStatus()).resolves.toMatchObject({
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import {
|
||||
buildNpcSelectionTokenSeed,
|
||||
createTurnDaemonRuntime,
|
||||
seedScenarioToDatabase,
|
||||
type TurnDaemonRuntime,
|
||||
} from '@sammo-ts/game-engine';
|
||||
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
|
||||
import {
|
||||
acquireGameSchemaAdvisoryXactLock,
|
||||
createGamePostgresConnector,
|
||||
@@ -34,6 +28,7 @@ const rejectedUserId = 'npc-possession-integration-rejected';
|
||||
const delayedUserId = 'npc-possession-integration-delayed';
|
||||
const cleanupUserId = 'npc-possession-integration-cleanup';
|
||||
const raceUserId = 'npc-possession-integration-race';
|
||||
const preopenUserId = 'npc-possession-integration-preopen';
|
||||
const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : '';
|
||||
|
||||
const assertDedicatedDatabase = (rawUrl: string): void => {
|
||||
@@ -93,6 +88,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
const delayedAuth = buildAuth(delayedUserId, '지연사용자', 7_705);
|
||||
const cleanupAuth = buildAuth(cleanupUserId, '정리사용자', 7_706);
|
||||
const raceAuth = buildAuth(raceUserId, '경합사용자', 7_707);
|
||||
const preopenAuth = buildAuth(preopenUserId, '가오픈사용자', 7_708);
|
||||
|
||||
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => {
|
||||
const redisClient = {
|
||||
@@ -171,6 +167,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
await db.inputEvent.deleteMany();
|
||||
await db.logEntry.deleteMany();
|
||||
await db.npcSelectionToken.deleteMany();
|
||||
await db.worldState.updateMany({
|
||||
data: {
|
||||
clockTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: new Date(),
|
||||
},
|
||||
});
|
||||
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
await db.general.createMany({
|
||||
data: Array.from({ length: 24 }, (_, index) => ({
|
||||
@@ -201,36 +204,10 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
await closeDb?.();
|
||||
}, 30_000);
|
||||
|
||||
it('reserves at most five exact type-2 NPCs and preserves Ref refresh/keep timing', async () => {
|
||||
it('reserves at most five type-2 NPCs and preserves Ref refresh/keep timing', async () => {
|
||||
const config = await appRouter.createCaller(buildContext('npc-possession-config')).join.getConfig();
|
||||
expect(config.npcPossession).toEqual({ enabled: true });
|
||||
|
||||
const worldState = await db.worldState.findFirstOrThrow();
|
||||
const acceptedGameTick = Number(worldState.clockTick);
|
||||
const hiddenSeed = asRecord(worldState.meta).hiddenSeed;
|
||||
expect(Number.isSafeInteger(acceptedGameTick)).toBe(true);
|
||||
if (typeof hiddenSeed !== 'string' && typeof hiddenSeed !== 'number') {
|
||||
throw new Error('NPC possession integration hidden seed is missing');
|
||||
}
|
||||
const selectable = await db.general.findMany({
|
||||
where: { userId: null, npcState: 2 },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { id: true, leadership: true, strength: true, intel: true },
|
||||
});
|
||||
const weights = Object.fromEntries(
|
||||
selectable.map((candidate) => [
|
||||
String(candidate.id),
|
||||
Math.pow(candidate.leadership + candidate.strength + candidate.intel, 1.5),
|
||||
])
|
||||
);
|
||||
const rng = new RandUtil(
|
||||
new LiteHashDRBG(buildNpcSelectionTokenSeed(hiddenSeed, 7_701, acceptedGameTick))
|
||||
);
|
||||
const expectedCandidateIds = new Set<number>();
|
||||
while (expectedCandidateIds.size < Math.min(5, selectable.length)) {
|
||||
expectedCandidateIds.add(Number(rng.choiceUsingWeight(weights)));
|
||||
}
|
||||
|
||||
const [first, concurrentSameOwner] = await Promise.all([
|
||||
appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}),
|
||||
appRouter.createCaller(buildContext('npc-possession-token-concurrent')).join.listPossessCandidates({}),
|
||||
@@ -240,9 +217,6 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
expect(first.candidates.length).toBeGreaterThan(0);
|
||||
expect(first.candidates.length).toBeLessThanOrEqual(5);
|
||||
expect(new Set(first.candidates.map(({ id }) => id)).size).toBe(first.candidates.length);
|
||||
expect(first.candidates.map(({ id }) => id).sort((left, right) => left - right)).toEqual(
|
||||
[...expectedCandidateIds].sort((left, right) => left - right)
|
||||
);
|
||||
expect(first.pickMoreSeconds).toBe(0);
|
||||
expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true);
|
||||
const rows = await db.general.findMany({
|
||||
@@ -281,6 +255,49 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
||||
expect(other.candidates.some(({ id }) => firstIds.has(id))).toBe(false);
|
||||
}, 30_000);
|
||||
|
||||
it('refreshes possession candidates while the realtime game tick is negative before opening', async () => {
|
||||
await stopRuntime('preopen candidate clock boundary');
|
||||
const worldState = await db.worldState.findFirstOrThrow();
|
||||
const originalClock = {
|
||||
clockBaseTime: worldState.clockBaseTime,
|
||||
clockTick: worldState.clockTick,
|
||||
clockMode: worldState.clockMode,
|
||||
clockWallAnchor: worldState.clockWallAnchor,
|
||||
};
|
||||
const openAt = new Date('2099-08-01T00:00:00.000Z');
|
||||
try {
|
||||
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: preopenUserId } });
|
||||
await db.worldState.update({
|
||||
where: { id: worldState.id },
|
||||
data: {
|
||||
clockBaseTime: openAt,
|
||||
clockTick: 0n,
|
||||
clockMode: 'realtime',
|
||||
clockWallAnchor: openAt,
|
||||
},
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2099-07-31T23:59:30.000Z'));
|
||||
|
||||
const first = await appRouter
|
||||
.createCaller(buildContext('npc-possession-preopen-first', preopenAuth))
|
||||
.join.listPossessCandidates({});
|
||||
expect(first.pickMoreSeconds).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(30_000);
|
||||
const refreshed = await appRouter
|
||||
.createCaller(buildContext('npc-possession-preopen-refresh', preopenAuth))
|
||||
.join.listPossessCandidates({ refresh: true, keepIds: [] });
|
||||
expect(refreshed.tokenNonce).not.toBe(first.tokenNonce);
|
||||
expect(refreshed.pickMoreSeconds).toBeGreaterThan(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: preopenUserId } });
|
||||
await db.worldState.update({ where: { id: worldState.id }, data: originalClock });
|
||||
await startRuntime('npc-possession-integration-daemon-resumed');
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('commits exactly one of two concurrent picks and keeps retry, logs, token and reload atomic', async () => {
|
||||
const reservation = await appRouter
|
||||
.createCaller(buildContext('npc-possession-token-current'))
|
||||
|
||||
@@ -2007,7 +2007,11 @@ test('장수 생성에서 등록 전콘을 골라 생성 요청에 전달한다'
|
||||
accessPages: [],
|
||||
createGeneralInputs: [],
|
||||
joinConfig: {
|
||||
rules: { stat: { total: 150, min: 30, max: 70 }, allowCustomName: true },
|
||||
rules: {
|
||||
stat: { total: 150, min: 30, max: 70 },
|
||||
allowDirectCreation: true,
|
||||
allowCustomName: true,
|
||||
},
|
||||
user: {
|
||||
id: 'user-1',
|
||||
displayName: '생성장수',
|
||||
@@ -2056,7 +2060,11 @@ test('활성 전용 아이콘이 없으면 대표 preset을 장수 생성 요청
|
||||
accessPages: [],
|
||||
createGeneralInputs: [],
|
||||
joinConfig: {
|
||||
rules: { stat: { total: 150, min: 30, max: 70 }, allowCustomName: true },
|
||||
rules: {
|
||||
stat: { total: 150, min: 30, max: 70 },
|
||||
allowDirectCreation: true,
|
||||
allowCustomName: true,
|
||||
},
|
||||
user: {
|
||||
id: 'user-1',
|
||||
displayName: '생성장수',
|
||||
|
||||
@@ -41,6 +41,7 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
|
||||
return response({
|
||||
rules: {
|
||||
stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 },
|
||||
allowDirectCreation: true,
|
||||
allowCustomName: true,
|
||||
},
|
||||
user: {
|
||||
|
||||
@@ -141,9 +141,16 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
|
||||
return response({
|
||||
rules: {
|
||||
stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 },
|
||||
allowDirectCreation: false,
|
||||
allowCustomName: true,
|
||||
},
|
||||
user: { id: 'npc-user', displayName: '빙의사용자', canCreateGeneral: true },
|
||||
user: {
|
||||
id: 'npc-user',
|
||||
displayName: '빙의사용자',
|
||||
canCreateGeneral: true,
|
||||
icons: [],
|
||||
preferredPicture: null,
|
||||
},
|
||||
personalities: [{ key: 'Random', name: '???', info: '' }],
|
||||
warSpecials: [],
|
||||
nations: [],
|
||||
@@ -225,6 +232,83 @@ const installFixture = async (page: Page, state: FixtureState): Promise<void> =>
|
||||
});
|
||||
};
|
||||
|
||||
test('shows only possession and keeps every candidate reachable from the left on mobile', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const state: FixtureState = {
|
||||
reservationCalls: 0,
|
||||
reservationInputs: [],
|
||||
rawBodies: [],
|
||||
possessInputs: [],
|
||||
hasGeneral: false,
|
||||
injectTimeout: false,
|
||||
};
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('join');
|
||||
|
||||
await expect(page.getByRole('heading', { name: '장수 빙의', exact: true, level: 1 })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '장수 생성', exact: true })).toHaveCount(0);
|
||||
await expect(page.locator('.npc-card')).toHaveCount(5);
|
||||
|
||||
const candidateGeometry = await page.locator('.npc-possession-section').evaluate((section) => {
|
||||
const sectionRect = section.getBoundingClientRect();
|
||||
const holder = section.querySelector<HTMLElement>('.npc-card-holder')!;
|
||||
const holderRect = holder.getBoundingClientRect();
|
||||
const cards = [...holder.querySelectorAll<HTMLElement>('.npc-card')];
|
||||
return {
|
||||
viewportWidth: window.innerWidth,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
sectionLeft: sectionRect.left,
|
||||
sectionRight: sectionRect.right,
|
||||
holderLeft: holderRect.left,
|
||||
firstCardLeft: cards[0]!.getBoundingClientRect().left,
|
||||
lastCardRight: cards.at(-1)!.getBoundingClientRect().right,
|
||||
holderClientWidth: holder.clientWidth,
|
||||
holderScrollWidth: holder.scrollWidth,
|
||||
holderScrollLeft: holder.scrollLeft,
|
||||
};
|
||||
});
|
||||
expect(candidateGeometry.viewportWidth).toBe(390);
|
||||
expect(candidateGeometry.documentWidth).toBe(390);
|
||||
expect(candidateGeometry.sectionLeft).toBeGreaterThanOrEqual(0);
|
||||
expect(candidateGeometry.sectionRight).toBeLessThanOrEqual(390);
|
||||
expect(candidateGeometry.firstCardLeft).toBeGreaterThanOrEqual(candidateGeometry.holderLeft);
|
||||
expect(candidateGeometry.lastCardRight).toBeGreaterThan(candidateGeometry.sectionRight);
|
||||
expect(candidateGeometry.holderScrollWidth).toBeGreaterThan(candidateGeometry.holderClientWidth);
|
||||
expect(candidateGeometry.holderScrollLeft).toBe(0);
|
||||
|
||||
const refreshButton = page.locator('#btn-pick-more');
|
||||
await expect(refreshButton).toBeEnabled();
|
||||
const refreshStyle = await refreshButton.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
backgroundColor: style.backgroundColor,
|
||||
borderBottomWidth: style.borderBottomWidth,
|
||||
opacity: style.opacity,
|
||||
cursor: style.cursor,
|
||||
};
|
||||
});
|
||||
expect(refreshStyle.backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
|
||||
expect(refreshStyle).toMatchObject({ borderBottomWidth: '4px', opacity: '1', cursor: 'pointer' });
|
||||
await page.screenshot({ path: testInfo.outputPath('npc-possession-mobile-active.png'), fullPage: true });
|
||||
|
||||
const dialogs: string[] = [];
|
||||
page.on('dialog', async (dialog) => {
|
||||
dialogs.push(dialog.message());
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await page.locator('.npc-action').last().click();
|
||||
expect(dialogs).toContain('빙의할까요? : 빙의후보5');
|
||||
await expect
|
||||
.poll(() => page.locator('.npc-card-holder').evaluate((holder) => (holder as HTMLElement).scrollLeft))
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await refreshButton.click();
|
||||
await expect.poll(() => state.reservationCalls).toBe(2);
|
||||
expect(state.reservationInputs.at(-1)).toMatchObject({ refresh: true, keepIds: [] });
|
||||
});
|
||||
|
||||
test('renders Ref-shaped token cards, preserves keep cooldown and retries possession with one ID', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
@@ -239,6 +323,8 @@ test('renders Ref-shaped token cards, preserves keep cooldown and retries posses
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: 1024, height: 900 });
|
||||
await page.goto('join?tab=possess');
|
||||
await expect(page.getByRole('heading', { name: '장수 빙의', exact: true, level: 1 })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '장수 생성', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'NPC 빙의' })).toHaveClass(/active/);
|
||||
|
||||
await expect(page.locator('.npc-card')).toHaveCount(5);
|
||||
|
||||
@@ -53,6 +53,8 @@ const submitting = ref(false);
|
||||
|
||||
const joinConfig = ref<JoinConfig | null>(null);
|
||||
const accountIcons = computed(() => joinConfig.value?.user.icons ?? []);
|
||||
const directCreationEnabled = computed(() => joinConfig.value?.rules.allowDirectCreation === true);
|
||||
const possessionOnly = computed(() => !directCreationEnabled.value && joinConfig.value?.npcPossession.enabled === true);
|
||||
const activeTab = ref<'create' | 'possess'>('create');
|
||||
const contextTab = ref<'invitation' | 'map' | 'generals'>('invitation');
|
||||
const inheritOpen = ref(false);
|
||||
@@ -429,7 +431,11 @@ const loadConfig = async () => {
|
||||
joinConfig.value = config;
|
||||
const storedPossession = readPendingPossess();
|
||||
pendingPossessAction.value = storedPossession?.ownerUserId === config.user.id ? storedPossession : null;
|
||||
if (route.query.tab === 'possess' && config.npcPossession.enabled && config.user.canCreateGeneral) {
|
||||
if (
|
||||
config.npcPossession.enabled &&
|
||||
config.user.canCreateGeneral &&
|
||||
(!config.rules.allowDirectCreation || route.query.tab === 'possess')
|
||||
) {
|
||||
activeTab.value = 'possess';
|
||||
}
|
||||
const pending = readPendingJoin();
|
||||
@@ -658,13 +664,14 @@ onUnmounted(() => {
|
||||
<main class="join-page">
|
||||
<header class="join-header">
|
||||
<div>
|
||||
<h1 class="join-title">장수 생성/빙의</h1>
|
||||
<h1 class="join-title">{{ possessionOnly ? '장수 빙의' : '장수 생성/빙의' }}</h1>
|
||||
<p class="join-subtitle">로그인 완료, 아직 장수가 없는 상태입니다.</p>
|
||||
</div>
|
||||
<div class="join-tabs">
|
||||
<RouterLink class="simulator-link" to="/past-plays">내 지난 플레이</RouterLink>
|
||||
<RouterLink class="simulator-link" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
||||
<button
|
||||
v-if="directCreationEnabled"
|
||||
:class="{ active: activeTab === 'create' }"
|
||||
:disabled="joinConfig?.user.canCreateGeneral === false"
|
||||
@click="activeTab = 'create'"
|
||||
@@ -1099,7 +1106,7 @@ onUnmounted(() => {
|
||||
<div class="npc-footer">
|
||||
<button
|
||||
id="btn-pick-more"
|
||||
class="ghost"
|
||||
class="legacy-button legacy-button--secondary"
|
||||
type="button"
|
||||
:disabled="npcLoading || npcPickMoreSeconds > 0 || submitting || hasPendingPossession"
|
||||
@click="loadNpcCandidates(true)"
|
||||
@@ -1683,6 +1690,7 @@ onUnmounted(() => {
|
||||
|
||||
.npc-possession-section {
|
||||
width: 1000px;
|
||||
min-width: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
@@ -1807,7 +1815,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.npc-general-list-wrap {
|
||||
width: 970px;
|
||||
width: min(100%, 970px);
|
||||
margin: 0 auto 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -1929,5 +1937,23 @@ onUnmounted(() => {
|
||||
.context-general-table {
|
||||
min-width: 520px;
|
||||
}
|
||||
|
||||
.npc-possession-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.npc-card-holder {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.npc-card {
|
||||
flex: 0 0 125px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -44,6 +44,7 @@ type LobbyFixtureOptions = {
|
||||
imageServer: number;
|
||||
} | null;
|
||||
selectionPoolEnabled?: boolean;
|
||||
directGeneralCreationEnabled?: boolean;
|
||||
npcPossessionEnabled?: boolean;
|
||||
userCnt?: number;
|
||||
maxUserCnt?: number;
|
||||
@@ -99,6 +100,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
||||
imageServer: 1,
|
||||
},
|
||||
selectionPoolEnabled = true,
|
||||
directGeneralCreationEnabled = true,
|
||||
npcPossessionEnabled = false,
|
||||
userCnt = 1,
|
||||
maxUserCnt = 500,
|
||||
@@ -275,6 +277,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
||||
autorunUser,
|
||||
isUnited,
|
||||
selectionPoolEnabled,
|
||||
directGeneralCreationEnabled,
|
||||
npcPossessionEnabled,
|
||||
myGeneral,
|
||||
});
|
||||
@@ -906,6 +909,7 @@ test('opens the mode-1 possession route with a fresh gateway game token outside
|
||||
await installFixture(page, {
|
||||
myGeneral: null,
|
||||
selectionPoolEnabled: false,
|
||||
directGeneralCreationEnabled: false,
|
||||
npcPossessionEnabled: true,
|
||||
});
|
||||
await page.route('**/hwe/join?**', async (route) => {
|
||||
@@ -918,6 +922,7 @@ test('opens the mode-1 possession route with a fresh gateway game token outside
|
||||
|
||||
await page.goto('lobby');
|
||||
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
||||
await expect(row.getByRole('button', { name: '장수생성' })).toHaveCount(0);
|
||||
await expect(row.getByRole('button', { name: '장수빙의' })).toBeEnabled();
|
||||
await row.getByRole('button', { name: '장수빙의' }).click();
|
||||
|
||||
|
||||
@@ -674,6 +674,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
</button>
|
||||
<template v-else>
|
||||
<button
|
||||
v-if="
|
||||
profileDetails[profile.profileName]
|
||||
?.directGeneralCreationEnabled
|
||||
"
|
||||
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
||||
:disabled="
|
||||
entryLoading[profile.profileName] ||
|
||||
|
||||
@@ -79,9 +79,11 @@ export class GameClock {
|
||||
if (this.mode === 'manual') {
|
||||
return this.tick;
|
||||
}
|
||||
// A wall-clock correction must never rewind already-observed gameplay.
|
||||
const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow);
|
||||
return elapsedTicks <= 0 ? this.tick : this.addTicks(this.tick, elapsedTicks);
|
||||
// A future realtime anchor represents the formal opening at anchor tick.
|
||||
// Before that instant Ref exposes the elapsed offset as a negative tick,
|
||||
// which lets PREOPEN-only actions keep their logical cooldowns moving.
|
||||
return this.addTicks(this.tick, elapsedTicks);
|
||||
}
|
||||
|
||||
now(wallNow: Date): Date {
|
||||
|
||||
@@ -35,16 +35,17 @@ describe('GameClock', () => {
|
||||
expect(clock.tickToDate(tick).getTime() - baseTime.getTime()).toBe(jumped.getTime() - wallAnchor.getTime());
|
||||
});
|
||||
|
||||
it('does not rewind game time after a backward wall-clock correction', () => {
|
||||
it('projects a negative tick before a future realtime anchor', () => {
|
||||
const clock = new GameClock({
|
||||
baseTime,
|
||||
tick: GAME_TICKS_PER_TURN * 10,
|
||||
tick: 0,
|
||||
mode: 'realtime',
|
||||
wallAnchor: new Date('2026-01-02T00:00:00.000Z'),
|
||||
wallAnchor: new Date('2026-01-01T01:00:00.000Z'),
|
||||
turnSeconds: 3_600,
|
||||
});
|
||||
|
||||
expect(clock.nowTick(new Date('2025-01-01T00:00:00.000Z'))).toBe(GAME_TICKS_PER_TURN * 10);
|
||||
expect(clock.nowTick(new Date('2026-01-01T00:30:00.000Z'))).toBe(-GAME_TICKS_PER_TURN / 2);
|
||||
expect(clock.nowTick(new Date('2026-01-01T01:00:00.000Z'))).toBe(0);
|
||||
});
|
||||
|
||||
it('projects near the safe tick boundary without unsafe intermediate multiplication', () => {
|
||||
|
||||
Reference in New Issue
Block a user