merge: 최신 main 변경을 일람 정렬 컨트롤 개선에 통합
This commit is contained in:
@@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { asRecord, type RankDataType } from '@sammo-ts/common';
|
||||
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import {
|
||||
@@ -63,6 +63,14 @@ const zImmediateActionInput = z
|
||||
})
|
||||
.optional();
|
||||
const MAIN_RECORD_LIMIT = 15;
|
||||
const PERSONAL_RECORD_TYPES = [
|
||||
'firenum',
|
||||
'warnum',
|
||||
'killnum',
|
||||
'deathnum',
|
||||
'killcrew',
|
||||
'deathcrew',
|
||||
] as const satisfies readonly RankDataType[];
|
||||
const NEUTRAL_NATION_CONTEXT = {
|
||||
id: 0,
|
||||
name: '재야',
|
||||
@@ -276,7 +284,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
|
||||
const metaRecord = asRecord(general.meta);
|
||||
const officerCityId = readNumber(metaRecord.officerCity ?? metaRecord.officer_city ?? metaRecord.officerCityId, 0);
|
||||
const [city, queriedNation, worldState, officerCity, troop, troopLeader, troopLeaderFirstTurn, accessLog] =
|
||||
const [city, queriedNation, worldState, officerCity, troop, troopLeader, troopLeaderFirstTurn, accessLog, rankRows] =
|
||||
await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
@@ -346,6 +354,10 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
where: { generalId: general.id },
|
||||
select: { refreshScore: true, refreshScoreTotal: true },
|
||||
}),
|
||||
ctx.db.rankData.findMany({
|
||||
where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } },
|
||||
select: { type: true, value: true },
|
||||
}),
|
||||
]);
|
||||
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
|
||||
|
||||
@@ -466,6 +478,8 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
};
|
||||
const refreshScore = accessLog?.refreshScore ?? 0;
|
||||
const refreshScoreTotal = accessLog?.refreshScoreTotal ?? 0;
|
||||
const rankValues = new Map(rankRows.map((row) => [row.type, row.value]));
|
||||
const rankValue = (type: (typeof PERSONAL_RECORD_TYPES)[number]): number => rankValues.get(type) ?? 0;
|
||||
const troopStatus: 'inactive' | 'present' | 'away' =
|
||||
troopLeaderFirstTurn?.actionCode !== undefined && troopLeaderFirstTurn.actionCode !== 'che_집합'
|
||||
? 'inactive'
|
||||
@@ -527,6 +541,15 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
statUpgradeLimit: readNumber(constValues.upgradeLimit, 30),
|
||||
dex: [1, 2, 3, 4, 5].map((index) => readNumber(metaRecord[`dex${index}`], 0)),
|
||||
},
|
||||
records: {
|
||||
battles: rankValue('warnum'),
|
||||
strategies: rankValue('firenum'),
|
||||
serviceYears: readNumber(metaRecord.belong, 0),
|
||||
wins: rankValue('killnum'),
|
||||
losses: rankValue('deathnum'),
|
||||
killedCrew: rankValue('killcrew'),
|
||||
lostCrew: rankValue('deathcrew'),
|
||||
},
|
||||
items: {
|
||||
horse: normalizeItemCode(general.horseCode),
|
||||
weapon: normalizeItemCode(general.weaponCode),
|
||||
|
||||
@@ -72,6 +72,11 @@ const parseBuffRecord = (raw: unknown): Record<string, number> => {
|
||||
|
||||
const serializeBuffRecord = (buff: Record<string, number>): string => JSON.stringify(buff);
|
||||
|
||||
const readStringList = (raw: unknown): string[] => {
|
||||
const parsed = typeof raw === 'string' ? parseJson<unknown>(raw) : raw;
|
||||
return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : [];
|
||||
};
|
||||
|
||||
const readBuffLevel = (buff: Record<string, number>, key: InheritBuffType): number => {
|
||||
const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null;
|
||||
return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0)));
|
||||
@@ -133,7 +138,7 @@ const patchGeneral = async (
|
||||
strength?: number;
|
||||
intelligence?: number;
|
||||
};
|
||||
specialWar?: string;
|
||||
specialWar?: string | null;
|
||||
}
|
||||
): Promise<void> => {
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
@@ -530,16 +535,15 @@ export const inheritRouter = router({
|
||||
}
|
||||
|
||||
const meta = asRecord(general.meta);
|
||||
const prevList =
|
||||
parseJson<string[]>(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? [];
|
||||
const prevList = readStringList(meta.prev_types_special2);
|
||||
prevList.push(general.special2Code);
|
||||
|
||||
await patchGeneral(ctx, general.id, {
|
||||
specialWar: 'None',
|
||||
specialWar: null,
|
||||
meta: {
|
||||
...meta,
|
||||
inheritResetSpecialWar: nextLevel,
|
||||
prev_types_special2: JSON.stringify(prevList),
|
||||
prev_types_special2: prevList,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ const buildContext = (authenticated: boolean, generalAccessTracking = false) =>
|
||||
general: {
|
||||
findFirst: findGeneral,
|
||||
},
|
||||
rankData: { findMany: async () => [] },
|
||||
city: { findUnique: findCity },
|
||||
nation: { findUnique: findNation },
|
||||
generalAccessLog: { findUnique: async () => null },
|
||||
|
||||
@@ -85,6 +85,7 @@ const createContext = (options: {
|
||||
troopLeaderAction?: string | null;
|
||||
refreshScore?: number;
|
||||
refreshScoreTotal?: number;
|
||||
rankRows?: Array<{ type: string; value: number }>;
|
||||
requestId?: string;
|
||||
transaction?: ReturnType<typeof vi.fn>;
|
||||
}) => {
|
||||
@@ -128,6 +129,9 @@ const createContext = (options: {
|
||||
refreshScoreTotal: options.refreshScoreTotal ?? 0,
|
||||
})),
|
||||
},
|
||||
rankData: {
|
||||
findMany: vi.fn(async () => options.rankRows ?? []),
|
||||
},
|
||||
city: {
|
||||
findUnique: vi.fn(async () => options.city ?? null),
|
||||
aggregate: vi.fn(async () => ({
|
||||
@@ -206,6 +210,41 @@ const createContext = (options: {
|
||||
};
|
||||
|
||||
describe('in-game my information ownership', () => {
|
||||
it('returns the owned general battle records from the same rank_data source used by rankings', async () => {
|
||||
const fixture = createContext({
|
||||
me: buildGeneral({ meta: { belong: 4, rank_killnum: 999 } }),
|
||||
rankRows: [
|
||||
{ type: 'firenum', value: 12 },
|
||||
{ type: 'warnum', value: 8 },
|
||||
{ type: 'killnum', value: 5 },
|
||||
{ type: 'deathnum', value: 3 },
|
||||
{ type: 'killcrew', value: 12_345 },
|
||||
{ type: 'deathcrew', value: 6_789 },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
|
||||
general: {
|
||||
records: {
|
||||
battles: 8,
|
||||
strategies: 12,
|
||||
serviceYears: 4,
|
||||
wins: 5,
|
||||
losses: 3,
|
||||
killedCrew: 12_345,
|
||||
lostCrew: 6_789,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(fixture.db.rankData.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
generalId: 7,
|
||||
type: { in: ['firenum', 'warnum', 'killnum', 'deathnum', 'killcrew', 'deathcrew'] },
|
||||
},
|
||||
select: { type: true, value: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns every ref progress-bar input from the owned general and current city read model', async () => {
|
||||
const fixture = createContext({
|
||||
me: buildGeneral({
|
||||
|
||||
@@ -331,6 +331,90 @@ describe('inherit router actor and permission boundaries', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reserves the selected Ref war trait and charges the authenticated owner once', async () => {
|
||||
const fixture = buildContext({
|
||||
inheritancePoint: 5_000,
|
||||
configConst: { availableSpecialWar: ['che_의술'] },
|
||||
});
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' })
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'patchGeneral',
|
||||
generalId: 7,
|
||||
patch: { meta: { inheritSpecificSpecialWar: 'che_의술' } },
|
||||
});
|
||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
|
||||
expect(fixture.logCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: 'user-1',
|
||||
year: 200,
|
||||
month: 4,
|
||||
text: '4000 포인트로 다음 전투 특기로 의술 지정',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not dispatch or charge when a different war trait is already reserved', async () => {
|
||||
const fixture = buildContext({
|
||||
inheritancePoint: 5_000,
|
||||
general: buildGeneral({ meta: { inheritSpecificSpecialWar: 'che_신산' } }),
|
||||
configConst: { availableSpecialWar: ['che_의술'] },
|
||||
});
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' })
|
||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '이미 예약한 특기가 있습니다.' });
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets the current war trait to the in-memory null sentinel and preserves Ref history as an array', async () => {
|
||||
const fixture = buildContext({
|
||||
inheritancePoint: 2_000,
|
||||
general: buildGeneral({ meta: { prev_types_special2: ['che_돌격'], marker: 3 } }),
|
||||
});
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(fixture.requestCommand).toHaveBeenCalledWith({
|
||||
type: 'patchGeneral',
|
||||
generalId: 7,
|
||||
patch: {
|
||||
specialWar: null,
|
||||
meta: {
|
||||
prev_types_special2: ['che_돌격', 'che_선봉'],
|
||||
marker: 3,
|
||||
inheritResetSpecialWar: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } }));
|
||||
expect(fixture.logCreate).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: 'user-1',
|
||||
year: 200,
|
||||
month: 4,
|
||||
text: '1000 포인트로 전투 특기 초기화',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not dispatch or charge when the current war trait is already blank', async () => {
|
||||
const fixture = buildContext({ inheritancePoint: 2_000, general: buildGeneral({ special2Code: 'None' }) });
|
||||
|
||||
await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '이미 전투 특기가 공란입니다.',
|
||||
});
|
||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
||||
expect(fixture.pointUpsert).not.toHaveBeenCalled();
|
||||
expect(fixture.logCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queues Ref-compatible nextTurnTimeBase without moving the current scheduled turn', async () => {
|
||||
const fixture = buildContext({
|
||||
inheritancePoint: 2_000,
|
||||
|
||||
@@ -69,7 +69,31 @@ export const do징병 = (ai: GeneralAI) => {
|
||||
}
|
||||
|
||||
const generalMeta = asRecord(ai.general.meta);
|
||||
const fullLeadership = readMetaNumber(generalMeta, 'fullLeadership', ai.general.stats.leadership);
|
||||
const recruitContext = {
|
||||
general: ai.general,
|
||||
nation,
|
||||
...(ai.worldRef
|
||||
? {
|
||||
worldView: {
|
||||
listGenerals: () => ai.worldRef!.listGenerals(),
|
||||
listGeneralsByCity: (cityId: number) =>
|
||||
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
|
||||
listNations: () => ai.worldRef!.listNations(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
time: {
|
||||
year: ai.world.currentYear,
|
||||
month: ai.world.currentMonth,
|
||||
startYear: ai.startYear,
|
||||
},
|
||||
};
|
||||
const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv);
|
||||
// The cached AI stat follows the scenario/global classification cap, while
|
||||
// che_징병 resolves the actual command capacity from the general's current
|
||||
// stat and modules. NPC recruitment must request that same uncapped amount;
|
||||
// otherwise a 300-leadership general can be stuck at a 100/140/255 cap.
|
||||
const fullLeadership = recruitment.resolveFullLeadership(recruitContext);
|
||||
trace('population-policy', {
|
||||
population: city.population,
|
||||
populationMax: city.populationMax,
|
||||
@@ -175,26 +199,6 @@ export const do징병 = (ai: GeneralAI) => {
|
||||
// whether to halve the requested crew. In particular, that command caps
|
||||
// the charge at the actually refillable amount when the selected type is
|
||||
// already equipped, then applies traits/items and legacy rounding.
|
||||
const recruitContext = {
|
||||
general: ai.general,
|
||||
nation,
|
||||
...(ai.worldRef
|
||||
? {
|
||||
worldView: {
|
||||
listGenerals: () => ai.worldRef!.listGenerals(),
|
||||
listGeneralsByCity: (cityId: number) =>
|
||||
ai.worldRef!.listGenerals().filter((candidate) => candidate.cityId === cityId),
|
||||
listNations: () => ai.worldRef!.listNations(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
time: {
|
||||
year: ai.world.currentYear,
|
||||
month: ai.world.currentMonth,
|
||||
startYear: ai.startYear,
|
||||
},
|
||||
};
|
||||
const recruitment = new RecruitmentCommandResolver(ai.commandEnv.generalActionModules ?? [], ai.commandEnv);
|
||||
const goldCost = recruitment.getCost(recruitContext, crewTypeId, crewAmount, picked).gold;
|
||||
const killCrew = readMetaNumber(generalMeta, 'rank_killcrew', readMetaNumber(generalMeta, 'killcrew', 0));
|
||||
const deathCrew = readMetaNumber(generalMeta, 'rank_deathcrew', readMetaNumber(generalMeta, 'deathcrew', 0));
|
||||
|
||||
@@ -257,7 +257,7 @@ const zPatchGeneral = z.object({
|
||||
intelligence: zFiniteNumber.optional(),
|
||||
})
|
||||
.optional(),
|
||||
specialWar: z.string().optional(),
|
||||
specialWar: z.string().nullable().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -734,10 +734,10 @@ async function handlePatchGeneral(
|
||||
...command.patch.stats,
|
||||
};
|
||||
}
|
||||
if (typeof command.patch.specialWar === 'string') {
|
||||
if (command.patch.specialWar !== undefined) {
|
||||
patch.role = {
|
||||
...general.role,
|
||||
specialWar: command.patch.specialWar,
|
||||
specialWar: command.patch.specialWar === 'None' ? null : command.patch.specialWar,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -955,6 +955,57 @@ describe('legacy NPC AI final-decision parity', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[101, 100, 10_100],
|
||||
[140, 100, 14_000],
|
||||
[256, 255, 25_600],
|
||||
[300, 140, 30_000],
|
||||
[300, 255, 30_000],
|
||||
])(
|
||||
'requests the command-resolved full crew above the cached AI cap (leadership=%i, cached=%i)',
|
||||
(leadership, cachedLeadership, amount) => {
|
||||
const ai = makeAi({
|
||||
dipState: 2,
|
||||
city: { population: 100_000, populationMax: 100_000 },
|
||||
general: {
|
||||
stats: { leadership, strength: 70, intelligence: 70 },
|
||||
gold: 100_000,
|
||||
rice: 100_000,
|
||||
meta: { killturn: 100, fullLeadership: cachedLeadership, rank_killcrew: 0, rank_deathcrew: 1 },
|
||||
},
|
||||
rng: makeRng([], [0, 0]),
|
||||
});
|
||||
|
||||
expect(do징병(ai)).toMatchObject({
|
||||
action: 'che_징병',
|
||||
args: { crewType: 1, amount },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('uses recruitment stat modules when resolving uncapped NPC crew capacity', () => {
|
||||
const ai = makeAi({
|
||||
dipState: 2,
|
||||
city: { population: 100_000, populationMax: 100_000 },
|
||||
general: {
|
||||
stats: { leadership: 240, strength: 70, intelligence: 70 },
|
||||
gold: 100_000,
|
||||
rice: 100_000,
|
||||
meta: { killturn: 100, fullLeadership: 100, rank_killcrew: 0, rank_deathcrew: 1 },
|
||||
},
|
||||
generalActionModules: singleActionModuleStack({
|
||||
eventHandlers: {},
|
||||
onCalcStat: (_context, statName, value) => (statName === 'leadership' ? value * 1.25 : value),
|
||||
}),
|
||||
rng: makeRng([], [0, 0]),
|
||||
});
|
||||
|
||||
expect(do징병(ai)).toMatchObject({
|
||||
action: 'che_징병',
|
||||
args: { crewType: 1, amount: 30_000 },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the refillable same-type crew amount for the legacy gold-cost halving threshold', () => {
|
||||
const ai = makeAi({
|
||||
dipState: 2,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { LogCategory, LogFormat } from '@sammo-ts/logic';
|
||||
import type { TurnDaemonCommand } from '@sammo-ts/common';
|
||||
|
||||
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
|
||||
import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js';
|
||||
import {
|
||||
createAddGlobalBetrayHandler,
|
||||
createAssignGeneralSpecialityHandler,
|
||||
} from '../src/turn/monthlySpecialityBetrayAction.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const event: TurnEvent = {
|
||||
@@ -177,6 +180,84 @@ describe('monthly speciality and betrayal actions', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['고정 후 초기화', ['reserve', 'reset']],
|
||||
['초기화 후 고정', ['reset', 'reserve']],
|
||||
] as const)('%s 순서에서도 다음 월에 지정한 전투 특기를 지급한다', async (_label, steps) => {
|
||||
const world = buildWorld();
|
||||
const initial = world.getGeneralById(3)!;
|
||||
const initialMeta = { ...initial.meta };
|
||||
delete initialMeta.inheritSpecificSpecialWar;
|
||||
world.updateGeneral(3, {
|
||||
role: { ...initial.role, specialWar: 'che_신산' },
|
||||
meta: initialMeta,
|
||||
});
|
||||
world.acknowledgeDirtyState(world.peekDirtyState());
|
||||
|
||||
const commandHandler = createTurnDaemonCommandHandler({ world });
|
||||
let requestIndex = 0;
|
||||
const dispatchPatch = async (patch: Extract<TurnDaemonCommand, { type: 'patchGeneral' }>['patch']) => {
|
||||
requestIndex += 1;
|
||||
const command = normalizeTurnDaemonCommand({
|
||||
requestId: `inherit-war-trait-${requestIndex}`,
|
||||
sentAt: '2026-08-21T00:00:00.000Z',
|
||||
command: { type: 'patchGeneral', generalId: 3, patch },
|
||||
});
|
||||
expect(command).not.toBeNull();
|
||||
await expect(commandHandler.handle(command!)).resolves.toMatchObject({ type: 'patchGeneral', ok: true });
|
||||
};
|
||||
|
||||
for (const step of steps) {
|
||||
const current = world.getGeneralById(3)!;
|
||||
if (step === 'reserve') {
|
||||
await dispatchPatch({
|
||||
meta: { ...current.meta, inheritSpecificSpecialWar: 'che_의술' },
|
||||
});
|
||||
} else {
|
||||
await dispatchPatch({
|
||||
specialWar: null,
|
||||
meta: {
|
||||
...current.meta,
|
||||
inheritResetSpecialWar: 0,
|
||||
prev_types_special2: ['che_신산'],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(world.getGeneralById(3)?.role.specialWar).toBeNull();
|
||||
expect(world.getGeneralById(3)?.meta).toMatchObject({
|
||||
inheritSpecificSpecialWar: 'che_의술',
|
||||
prev_types_special2: ['che_신산'],
|
||||
});
|
||||
|
||||
await createAssignGeneralSpecialityHandler({ getWorld: () => world })([], environment, event);
|
||||
|
||||
expect(world.getGeneralById(3)?.role.specialWar).toBe('che_의술');
|
||||
expect(world.getGeneralById(3)?.meta).not.toHaveProperty('inheritSpecificSpecialWar');
|
||||
expect(world.getGeneralById(3)?.meta.prev_types_special2).toEqual(['che_신산']);
|
||||
expect(
|
||||
world
|
||||
.peekDirtyState()
|
||||
.logs.filter((log) => log.generalId === 3)
|
||||
.map((log) => log.text)
|
||||
).toEqual(['특기 【<b><C>의술</></b>】을 습득', '특기 【<b><L>의술</></b>】을 익혔습니다!']);
|
||||
});
|
||||
|
||||
it('normalizes the legacy None sentinel before monthly eligibility checks', async () => {
|
||||
const world = buildWorld();
|
||||
const target = world.getGeneralById(3)!;
|
||||
world.updateGeneral(3, { role: { ...target.role, specialWar: 'che_신산' } });
|
||||
world.acknowledgeDirtyState(world.peekDirtyState());
|
||||
const commandHandler = createTurnDaemonCommandHandler({ world });
|
||||
|
||||
await expect(
|
||||
commandHandler.handle({ type: 'patchGeneral', generalId: 3, patch: { specialWar: 'None' } })
|
||||
).resolves.toMatchObject({ type: 'patchGeneral', ok: true });
|
||||
|
||||
expect(world.getGeneralById(3)?.role.specialWar).toBeNull();
|
||||
});
|
||||
|
||||
it('does nothing before the three-year opening period ends', async () => {
|
||||
const world = buildWorld();
|
||||
await createAssignGeneralSpecialityHandler({ getWorld: () => world })([], { ...environment, year: 192 }, event);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createAddGlobalBetrayHandler,
|
||||
createAssignGeneralSpecialityHandler,
|
||||
} from '../src/turn/monthlySpecialityBetrayAction.js';
|
||||
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
|
||||
import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
@@ -105,7 +106,7 @@ integration('monthly speciality and betrayal persistence', () => {
|
||||
}),
|
||||
buildGeneral(generalIds[1], {
|
||||
domestic: 'che_경작',
|
||||
war: null,
|
||||
war: 'che_신산',
|
||||
meta: {
|
||||
specage: 99,
|
||||
specage2: 30,
|
||||
@@ -198,6 +199,22 @@ integration('monthly speciality and betrayal persistence', () => {
|
||||
const hooks = await createDatabaseTurnHooks(databaseUrl!, world);
|
||||
|
||||
try {
|
||||
const reservedGeneral = world.getGeneralById(generalIds[1])!;
|
||||
const commandHandler = createTurnDaemonCommandHandler({ world });
|
||||
await expect(
|
||||
commandHandler.handle({
|
||||
type: 'patchGeneral',
|
||||
generalId: reservedGeneral.id,
|
||||
patch: {
|
||||
specialWar: null,
|
||||
meta: {
|
||||
...reservedGeneral.meta,
|
||||
inheritResetSpecialWar: 0,
|
||||
prev_types_special2: ['che_신산'],
|
||||
},
|
||||
},
|
||||
})
|
||||
).resolves.toMatchObject({ type: 'patchGeneral', ok: true });
|
||||
await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z'));
|
||||
await hooks.hooks.flushChanges?.({
|
||||
lastTurnTime: state.lastTurnTime.toISOString(),
|
||||
@@ -214,7 +231,12 @@ integration('monthly speciality and betrayal persistence', () => {
|
||||
expect(rows[0]?.specialCode).not.toBe('None');
|
||||
expect(rows[0]?.meta).toMatchObject({ betray: 2 });
|
||||
expect(rows[1]).toMatchObject({ special2Code: 'che_의술' });
|
||||
expect(rows[1]?.meta).toMatchObject({ betray: 3, marker: 2 });
|
||||
expect(rows[1]?.meta).toMatchObject({
|
||||
betray: 3,
|
||||
marker: 2,
|
||||
inheritResetSpecialWar: 0,
|
||||
prev_types_special2: ['che_신산'],
|
||||
});
|
||||
expect(rows[1]?.meta).not.toHaveProperty('inheritSpecificSpecialWar');
|
||||
expect(await db.logEntry.count({ where: { generalId: { in: [...generalIds] } } })).toBe(4);
|
||||
} finally {
|
||||
|
||||
@@ -112,6 +112,15 @@ const myGeneral = (state: FixtureState) => ({
|
||||
statUpgradeLimit: 20,
|
||||
dex: [350, 1_375, 3_500, 7_125, 1_275_975],
|
||||
},
|
||||
records: {
|
||||
battles: 8,
|
||||
strategies: 12,
|
||||
serviceYears: 4,
|
||||
wins: 5,
|
||||
losses: 3,
|
||||
killedCrew: 12_345,
|
||||
lostCrew: 6_789,
|
||||
},
|
||||
items: { horse: 'che_명마', weapon: null, book: null, item: null },
|
||||
itemNames: { horse: '명마', weapon: null, book: null, item: null },
|
||||
},
|
||||
@@ -1184,6 +1193,9 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
|
||||
expect(myPageImages[1]?.backgroundImage).toContain('/game/crewtype1.png');
|
||||
await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관');
|
||||
await expect(page.locator('.legacy-general-details')).toContainText('병종 보병');
|
||||
await expect(page.locator('.legacy-general-details')).toContainText('전투 8 · 계략 12 · 사관 4년');
|
||||
await expect(page.locator('.legacy-general-details')).toContainText('승률 62.50% · 승리 5 · 패배 3');
|
||||
await expect(page.locator('.legacy-general-details')).toContainText('살상률 181.84% · 사살 12,345 · 피살 6,789');
|
||||
await expect(page.locator('.item-group')).toContainText('명마');
|
||||
await expect(page.locator('#container')).not.toContainText('che_');
|
||||
await expect(page.locator('.title-row')).toContainText('내 정 보');
|
||||
|
||||
@@ -796,12 +796,12 @@ onMounted(() => {
|
||||
width: min(100%, 1000px);
|
||||
margin: 0 auto;
|
||||
border: 1px solid #888;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
padding: 0 7px;
|
||||
color: #fff;
|
||||
height: 1597px;
|
||||
min-height: 1597px;
|
||||
font: 14px/21px var(--sammo-font-sans);
|
||||
}
|
||||
|
||||
@@ -1017,7 +1017,7 @@ a:not(.legacy-button):focus-visible {
|
||||
}
|
||||
|
||||
.inherit-page {
|
||||
height: 3047.5px;
|
||||
min-height: 3047.5px;
|
||||
}
|
||||
|
||||
.shop-item .buy-button {
|
||||
|
||||
@@ -136,6 +136,9 @@ const statusLine = computed(() =>
|
||||
|
||||
const canSave = computed(() => (data.value?.settings.myset ?? 1) > 0);
|
||||
const penalties = computed(() => Object.entries(data.value?.penalties ?? {}));
|
||||
const numberText = (value: number): string => value.toLocaleString('ko-KR');
|
||||
const percentText = (numerator: number, denominator: number): string =>
|
||||
`${((numerator / Math.max(denominator, 1)) * 100).toFixed(2)}%`;
|
||||
const noDefencePenaltyWaived = computed(() => {
|
||||
const environment = asRecord(world.value?.config.environment);
|
||||
return isDefenceTrainPenaltyWaivedByScenarioEffect(
|
||||
@@ -437,9 +440,22 @@ onMounted(() => {
|
||||
}})</strong
|
||||
>
|
||||
</div>
|
||||
<div>전투 0 · 계략 0 · 사관 7년</div>
|
||||
<div>승률 0% · 승리 0 · 패배 0</div>
|
||||
<div>살상률 0% · 사살 0 · 피살 0</div>
|
||||
<div>
|
||||
전투 {{ numberText(data.general.records.battles) }} · 계략
|
||||
{{ numberText(data.general.records.strategies) }} · 사관
|
||||
{{ numberText(data.general.records.serviceYears) }}년
|
||||
</div>
|
||||
<div>
|
||||
승률 {{ percentText(data.general.records.wins, data.general.records.battles) }} · 승리
|
||||
{{ numberText(data.general.records.wins) }} · 패배
|
||||
{{ numberText(data.general.records.losses) }}
|
||||
</div>
|
||||
<div>
|
||||
살상률
|
||||
{{ percentText(data.general.records.killedCrew, data.general.records.lostCrew) }} · 사살
|
||||
{{ numberText(data.general.records.killedCrew) }} · 피살
|
||||
{{ numberText(data.general.records.lostCrew) }}
|
||||
</div>
|
||||
<div>
|
||||
소속 {{ data.nation?.name ?? '재야' }} · 도시 {{ data.city?.name ?? '-' }} · 병종
|
||||
{{ data.general.crewTypeName ?? '-' }} · 내정특기
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
# Caddy prefix 계약
|
||||
# Core2026 환경별 Caddy prefix 계약
|
||||
|
||||
## 환경과 ingress
|
||||
|
||||
| 환경 | 공개 주소 | 연결 계약 |
|
||||
| ---- | --------- | --------- |
|
||||
| 공개 | `dev-sam2026.hided.net` | 실제 외부 Core2026 서비스입니다. 로컬 Docker `14999`의 주소가 아닙니다. |
|
||||
| E2E | `dev-sam-e2e.hided.net` | 외부 Caddy TLS → `172.30.1.54:14999` HTTP → Docker Caddy입니다. |
|
||||
| 환경 | 공개 주소·prefix | 접속·연결 계약 |
|
||||
| ------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| Ref | `dev-sam-ref.hided.net` | 개발 호스트 `172.30.1.54:3400`의 PHP 기준 구현입니다. |
|
||||
| 로컬 E2E | `dev-sam-e2e.hided.net` | 외부 Caddy TLS → 개발 호스트 `172.30.1.54:14999` HTTP → Docker Caddy입니다. |
|
||||
| 공개 개발 | `dev-sam2026.hided.net` | `ssh serv`의 `core2026-dev-sam2026`/`hidche_ng_my`입니다. 로컬 E2E `14999`와 다른 호스트입니다. |
|
||||
| sam Core 운영 | `sam.hided.net/gateway/`와 일곱 profile prefix | `ssh serv`의 `core2026-sam-production`/`hidche_core2026_my`입니다. |
|
||||
| sam PHP 운영 | `sam.hided.net/sam/`과 기존 PHP 경로 | `ssh serv`의 별도 `sam_hided_net` project입니다. |
|
||||
|
||||
`dev-sam2026.hided.net`과 `sam.hided.net` Core prefix는 같은 Git 구현을 사용할 수
|
||||
있지만 PostgreSQL, Redis, named volume, release queue와 active commit이 분리된
|
||||
배포 환경입니다. 한 환경의 release/API/Chromium 결과를 다른 환경의 반영 근거로
|
||||
사용하지 않습니다. 상위 `sam_rebuild` 작업공간에서는
|
||||
`docs/docker-environment-routing.md`의 전체 결정 절차도 함께 따릅니다.
|
||||
|
||||
외부 Caddy는 E2E 호스트의 모든 경로를 `172.30.1.54:14999`로 전달하고 원래
|
||||
`Host` header와 path prefix를 보존합니다. `handle_path`처럼 prefix를 제거하는
|
||||
@@ -26,7 +35,7 @@ HTTP_PORT=14999
|
||||
`https://dev-sam-e2e.hided.net/gateway/oauth/callback`을 파생합니다. 도메인을
|
||||
바꾼 뒤에는 Caddy뿐 아니라 runtime도 재생성하여 process 환경을 갱신합니다.
|
||||
|
||||
## 활성 경로
|
||||
## 로컬 E2E 활성 경로
|
||||
|
||||
| 서비스 | 공개 prefix | frontend | API |
|
||||
| ------- | ----------- | -------: | ------: |
|
||||
@@ -36,8 +45,9 @@ HTTP_PORT=14999
|
||||
|
||||
표의 port는 Docker 내부 Caddy가 연결하는 frontend/API listener입니다. 외부
|
||||
Caddy가 이 port들에 직접 연결하지 않습니다. `kwe`, `pwe`, `twe`, `nya`,
|
||||
`pya`는 resource·profile 이름으로 사용할 수 있지만 활성 Caddy route가
|
||||
아닙니다.
|
||||
`pya`는 로컬 E2E에서 resource·profile 이름으로 사용할 수 있지만 활성 Caddy
|
||||
route가 아닙니다. `sam.hided.net`의 별도 운영 Core stack에는 이 다섯 profile도
|
||||
활성 prefix이므로 환경별 계약을 섞지 않습니다.
|
||||
|
||||
Caddy는 prefix를 보존해 upstream에 전달합니다. 앱은 root 배포를 가정하지
|
||||
않고 frontend base, tRPC, SSE, upload와 direct navigation에 같은 prefix를
|
||||
|
||||
@@ -86,7 +86,7 @@ storage, route guards, and image loading.
|
||||
| best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error |
|
||||
| hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error |
|
||||
| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows |
|
||||
| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error |
|
||||
| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error, two 30-row history pages that expand the document and keep the last row/load-more button reachable by scrolling |
|
||||
| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error |
|
||||
| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error |
|
||||
| survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error |
|
||||
|
||||
@@ -230,7 +230,7 @@ export type TurnDaemonCommand =
|
||||
strength?: number;
|
||||
intelligence?: number;
|
||||
};
|
||||
specialWar?: string;
|
||||
specialWar?: string | null;
|
||||
};
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -55,6 +55,18 @@ describe('Ref event domestic traits', () => {
|
||||
expect(eventMusang!.getWarPowerMultiplier?.(context, unit, unit)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it('applies the persisted victory count to the ordinary 무쌍 battle multiplier', async () => {
|
||||
const musang = await new WarTraitLoader().load('che_무쌍');
|
||||
const unit = {
|
||||
getGeneral: () => ({ meta: { rank_killnum: 40 } }),
|
||||
} as unknown as WarUnit;
|
||||
const context = { unit } as unknown as WarActionContext;
|
||||
|
||||
const multiplier = musang.getWarPowerMultiplier?.(context, unit, unit);
|
||||
expect(multiplier?.[0]).toBeCloseTo(1.2, 12);
|
||||
expect(multiplier?.[1]).toBeCloseTo(0.92, 12);
|
||||
});
|
||||
|
||||
it('keeps event and ordinary 견고 injury-prevention triggers distinct by raise type', async () => {
|
||||
const [eventGyeongo] = await loadEventDomesticTraitModules(['che_event_견고']);
|
||||
const canonical = await new WarTraitLoader().load('che_견고');
|
||||
|
||||
@@ -224,6 +224,63 @@ describe('war triggers', () => {
|
||||
expect(general.atmos).toBeCloseTo(115.5, 12);
|
||||
});
|
||||
|
||||
it('accumulates battle, victory, loss, and casualty records on the persisted rank meta keys', () => {
|
||||
const attacker = buildGeneral(80);
|
||||
attacker.meta = { ...attacker.meta, rank_warnum: 2, rank_killnum: 3, rank_killcrew: 400 };
|
||||
const defender = {
|
||||
...buildGeneral(70),
|
||||
id: 2,
|
||||
name: 'Defender',
|
||||
nationId: 2,
|
||||
meta: { ...buildGeneral(70).meta, rank_warnum: 4, rank_deathnum: 1, rank_deathcrew: 500 },
|
||||
};
|
||||
const crewType = new WarCrewType(buildUnitSet().crewTypes![0]!);
|
||||
const attackerUnit = new WarUnitGeneral(
|
||||
new RandUtil(new ConstantRNG(0)),
|
||||
buildConfig(),
|
||||
attacker,
|
||||
buildCity(),
|
||||
buildNation(),
|
||||
true,
|
||||
crewType,
|
||||
new ActionLogger({ generalId: attacker.id, nationId: attacker.nationId }),
|
||||
new WarActionPipeline([])
|
||||
);
|
||||
const defenderUnit = new WarUnitGeneral(
|
||||
new RandUtil(new ConstantRNG(0)),
|
||||
buildConfig(),
|
||||
defender,
|
||||
{ ...buildCity(), nationId: 2 },
|
||||
{ ...buildNation(), id: 2 },
|
||||
false,
|
||||
crewType,
|
||||
new ActionLogger({ generalId: defender.id, nationId: defender.nationId }),
|
||||
new WarActionPipeline([])
|
||||
);
|
||||
|
||||
attackerUnit.setOppose(defenderUnit);
|
||||
defenderUnit.setOppose(attackerUnit);
|
||||
attackerUnit.increaseKilled(120);
|
||||
defenderUnit.decreaseHP(120);
|
||||
attackerUnit.addWin();
|
||||
defenderUnit.addLose();
|
||||
attackerUnit.finishBattle();
|
||||
defenderUnit.finishBattle();
|
||||
|
||||
expect(attacker.meta).toMatchObject({
|
||||
rank_warnum: 3,
|
||||
rank_killnum: 4,
|
||||
rank_killcrew: 520,
|
||||
rank_killcrew_person: 120,
|
||||
});
|
||||
expect(defender.meta).toMatchObject({
|
||||
rank_warnum: 5,
|
||||
rank_deathnum: 2,
|
||||
rank_deathcrew: 620,
|
||||
rank_deathcrew_person: 120,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates the legacy experience level and applies item experience modifiers immediately', () => {
|
||||
const general = buildGeneral(80);
|
||||
general.experience = 90;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { dirname, extname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const imageRoot = resolve(repositoryRoot, '../../image');
|
||||
const imageRoot = process.env.FRONTEND_PARITY_IMAGE_ROOT ?? resolve(repositoryRoot, '../../image');
|
||||
const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR;
|
||||
const gameUrl = `http://127.0.0.1:${process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'}/che/inherit`;
|
||||
|
||||
@@ -117,9 +117,21 @@ const statusFixture = {
|
||||
currentStat: { leadership: 70, strength: 45, intel: 85 },
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => {
|
||||
interface InheritanceLogFixture {
|
||||
id: number;
|
||||
year: number;
|
||||
month: number;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const installFixture = async (
|
||||
page: Page,
|
||||
options: { failBuff?: boolean; logPages?: InheritanceLogFixture[][] } = {}
|
||||
) => {
|
||||
let buffMutationCount = 0;
|
||||
let resetTurnMutationCount = 0;
|
||||
let logRequestCount = 0;
|
||||
const uniqueAuctionRequests: unknown[] = [];
|
||||
await installImages(page);
|
||||
await page.addInitScript(() => {
|
||||
@@ -148,7 +160,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {})
|
||||
});
|
||||
}
|
||||
if (name === 'inherit.getLogs') {
|
||||
return response([
|
||||
const defaultPage = [
|
||||
{
|
||||
id: 2,
|
||||
year: 200,
|
||||
@@ -156,7 +168,11 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {})
|
||||
text: '1000 포인트로 장수 소유자 확인',
|
||||
createdAt: '2026-07-26T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
];
|
||||
const pages = options.logPages ?? [defaultPage];
|
||||
const pageIndex = Math.min(logRequestCount, pages.length - 1);
|
||||
logRequestCount += 1;
|
||||
return response(pages[pageIndex] ?? []);
|
||||
}
|
||||
if (name === 'join.getConfig') {
|
||||
return response({ rules: { stat: { total: 200, min: 10, max: 100 } } });
|
||||
@@ -184,6 +200,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {})
|
||||
return {
|
||||
buffMutationCount: () => buffMutationCount,
|
||||
resetTurnMutationCount: () => resetTurnMutationCount,
|
||||
logRequestCount: () => logRequestCount,
|
||||
uniqueAuctionRequests,
|
||||
};
|
||||
};
|
||||
@@ -333,6 +350,52 @@ test.describe('inheritance management legacy parity', () => {
|
||||
await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000');
|
||||
});
|
||||
|
||||
test('keeps every paged inheritance log reachable by document scrolling', async ({ page }) => {
|
||||
const buildPage = (firstId: number, count: number): InheritanceLogFixture[] =>
|
||||
Array.from({ length: count }, (_, index) => {
|
||||
const id = firstId - index;
|
||||
return {
|
||||
id,
|
||||
year: 200,
|
||||
month: 4,
|
||||
text: `유산 포인트 변경 내역 ${id}`,
|
||||
createdAt: `2026-07-${String((id % 27) + 1).padStart(2, '0')}T00:00:00.000Z`,
|
||||
};
|
||||
});
|
||||
const fixture = await installFixture(page, {
|
||||
logPages: [buildPage(60, 30), buildPage(30, 30), []],
|
||||
});
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await page.goto(gameUrl);
|
||||
await expect(page.locator('.log-row')).toHaveCount(30);
|
||||
|
||||
const firstHeight = await page.evaluate(() => document.scrollingElement?.scrollHeight ?? 0);
|
||||
const moreButton = page.getByRole('button', { name: '더 가져오기' });
|
||||
await moreButton.click();
|
||||
await expect(page.locator('.log-row')).toHaveCount(60);
|
||||
await expect(page.locator('.log-row').last()).toContainText('유산 포인트 변경 내역 1');
|
||||
const expandedHeight = await page.evaluate(() => document.scrollingElement?.scrollHeight ?? 0);
|
||||
expect(expandedHeight).toBeGreaterThan(firstHeight);
|
||||
|
||||
await page.evaluate(() => window.scrollTo(0, document.scrollingElement?.scrollHeight ?? 0));
|
||||
await expect(page.locator('.log-row').last()).toBeInViewport();
|
||||
await expect(moreButton).toBeInViewport();
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
Math.abs(
|
||||
window.scrollY + window.innerHeight - (document.scrollingElement?.scrollHeight ?? window.innerHeight)
|
||||
)
|
||||
)
|
||||
).toBeLessThanOrEqual(1);
|
||||
if (artifactRoot) {
|
||||
await page.screenshot({ path: resolve(artifactRoot, 'inherit-core-mobile-60-logs.png'), fullPage: true });
|
||||
}
|
||||
|
||||
await moreButton.click();
|
||||
await expect.poll(fixture.logRequestCount).toBe(3);
|
||||
await expect(moreButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('selects a Ref default unique and starts its auction from the inheritance page', async ({ page }) => {
|
||||
const fixture = await installFixture(page);
|
||||
await page.goto(gameUrl);
|
||||
|
||||
Reference in New Issue
Block a user