Merge remote-tracking branch 'origin/main' into fix/existing-test-failures-20260812

This commit is contained in:
2026-08-13 00:52:28 +00:00
82 changed files with 2013 additions and 849 deletions
+4 -2
View File
@@ -316,8 +316,10 @@ docs에서 확인해 주세요. 존재하지 않는 명령을 오래된 report
## 코드 스타일 ## 코드 스타일
- TypeScript는 workspace 전체에서 정확히 `6.0.2`를 사용해 주세요. package-local - Vite, Vue/Volar와 compiler API 소비자는 workspace 전체에서 정확히
다른 버전을 추가하지 말아 주세요. TypeScript `6.0.3`을 사용해 주세요. CLI project build/typecheck는 root의
`@typescript/native` alias로 고정한 TypeScript 7 `tsc`를 사용하며 package-local
다른 compiler version이나 직접 `tsc` 호출을 추가하지 말아 주세요.
- TypeScript/JSON/Vue SFC는 기존 4-space 스타일을 유지해 주세요. - TypeScript/JSON/Vue SFC는 기존 4-space 스타일을 유지해 주세요.
- public API는 명시적 타입을 사용하고 `any`, 불필요하게 넓은 `unknown`, - public API는 명시적 타입을 사용하고 `any`, 불필요하게 넓은 `unknown`,
`as unknown as` 우회를 피해 주세요. `as unknown as` 우회를 피해 주세요.
+2 -2
View File
@@ -73,8 +73,8 @@ input-event 결과를 transaction으로 반영합니다. Redis pub/sub과 SSE는
## 도구 체인 ## 도구 체인
- pnpm `11.17.0`, Turbo - pnpm `11.21.0`, Turbo
- TypeScript `6.0.2` - TypeScript `6.0.3` compiler API와 TypeScript `7.0.2` native `tsc`
- Fastify, tRPC, zod - Fastify, tRPC, zod
- Vue 3, Pinia, Vue Router, Vite - Vue 3, Pinia, Vue Router, Vite
- PostgreSQL, Prisma, Redis - PostgreSQL, Prisma, Redis
+1 -1
View File
@@ -23,7 +23,7 @@
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"test": "vitest run --config vitest.config.ts", "test": "vitest run --config vitest.config.ts",
"typecheck": "tsc -b" "typecheck": "pnpm -w tsc7 -b app/game-api/tsconfig.json"
}, },
"devDependencies": { "devDependencies": {
"@types/sanitize-html": "2.16.1", "@types/sanitize-html": "2.16.1",
+85
View File
@@ -0,0 +1,85 @@
import {
createFullRealtimeReadModelInvalidation,
hasRealtimeReadModelInvalidation,
mergeRealtimeReadModelInvalidations,
resolveRealtimeReadModelInvalidation,
type PublicRealtimeEvent,
type RealtimeEvent,
type RealtimeReadModelChanges,
type RealtimeViewerIdentity,
} from '@sammo-ts/common';
import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC } from '@sammo-ts/logic';
const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => {
const seen = new Set<string>();
return identities.filter((identity) => {
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const isMailboxRelevant = (mailbox: number, identity: RealtimeViewerIdentity): boolean =>
mailbox === MESSAGE_MAILBOX_PUBLIC ||
(identity.generalId !== null && mailbox === identity.generalId) ||
(identity.nationId !== null && mailbox === MESSAGE_MAILBOX_NATIONAL_BASE + identity.nationId);
const eventChanges = (event: RealtimeEvent): RealtimeReadModelChanges | null => {
if (event.type === 'readModelChanged') return event.changes;
if (event.type === 'turnCompleted') return event.changes ?? null;
return null;
};
export const shouldReloadRealtimeViewerIdentity = (
event: RealtimeEvent,
identity: RealtimeViewerIdentity
): boolean => {
if (identity.generalId === null) return false;
const changes = eventChanges(event);
if (!changes) return false;
const generalId = identity.generalId;
return [
changes.generalIds,
changes.mapGeneralIds ?? changes.generalIds,
changes.frontStatusGeneralIds ?? [],
changes.frontStatusActorIds ?? [],
changes.lobbyGeneralIds ?? changes.generalIds,
changes.reservedGeneralIds,
changes.recordGeneralIds,
].some((ids) => ids.includes(generalId));
};
/**
* Converts an internal Redis event to the minimal browser contract. Empty
* clock-only turn events are suppressed; the remaining payload never includes
* entity IDs, wall-clock timestamps, logical turn times, or revisions.
*/
export const toPublicRealtimeEvent = (
event: RealtimeEvent,
identities: readonly RealtimeViewerIdentity[]
): PublicRealtimeEvent | null => {
const viewers = uniqueIdentities(
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
);
if (event.type === 'messageCreated') {
return viewers.some((identity) => isMailboxRelevant(event.mailbox, identity))
? { type: 'messagesInvalidated' }
: null;
}
if (event.type === 'turnCompleted' && !event.changes) {
return {
type: 'readModelInvalidated',
invalidation: createFullRealtimeReadModelInvalidation(),
};
}
const changes = eventChanges(event);
if (!changes) return null;
const invalidation = viewers
.map((identity) => resolveRealtimeReadModelInvalidation(changes, identity))
.reduce(mergeRealtimeReadModelInvalidations);
if (!hasRealtimeReadModelInvalidation(invalidation)) return null;
return { type: 'readModelInvalidated', invalidation };
};
+40 -6
View File
@@ -3,7 +3,9 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import { resolveOfficerLevelName, sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js';
import { readOnlyAuthedProcedure, router } from '../../trpc.js'; import { readOnlyAuthedProcedure, router } from '../../trpc.js';
import { loadTraitNames } from '../nation/shared.js';
const numberOrNull = (value: unknown): number | null => const numberOrNull = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null; typeof value === 'number' && Number.isFinite(value) ? value : null;
@@ -87,6 +89,31 @@ export const archiveRouter = router({
nationByServerAndId.set(key, nation); nationByServerAndId.set(key, nation);
} }
} }
const archivedRoles = generals.map((general) => {
const data = asRecord(general.data);
const role = asRecord(data.role);
return {
personal: displayTextOrNull(data.personalCode ?? data.personal ?? role.personality),
special: displayTextOrNull(data.specialCode ?? data.special ?? role.specialDomestic),
special2: displayTextOrNull(data.special2Code ?? data.special2 ?? role.specialWar),
};
});
const [personalityNames, domesticNames, warNames] = await Promise.all([
loadTraitNames(
archivedRoles.map((role) => role.personal),
'personality'
),
loadTraitNames(
archivedRoles.map((role) => role.special),
'domestic'
),
loadTraitNames(
archivedRoles.map((role) => role.special2),
'war'
),
]);
const displayRole = (value: string | null, names: Awaited<ReturnType<typeof loadTraitNames>>): string | null =>
value ? (names.get(value)?.name ?? sanitizeInternalDisplayCode(value)) : null;
const seasons = new Map< const seasons = new Map<
string, string,
@@ -110,6 +137,7 @@ export const archiveRouter = router({
experience: number | null; experience: number | null;
dedication: number | null; dedication: number | null;
officerLevel: number | null; officerLevel: number | null;
officerLevelText: string | null;
personal: string | null; personal: string | null;
special: string | null; special: string | null;
special2: string | null; special2: string | null;
@@ -118,7 +146,7 @@ export const archiveRouter = router({
} }
>(); >();
for (const general of generals) { for (const [generalIndex, general] of generals.entries()) {
const game = gameByServer.get(general.serverId); const game = gameByServer.get(general.serverId);
let season = seasons.get(general.serverId); let season = seasons.get(general.serverId);
if (!season) { if (!season) {
@@ -136,10 +164,12 @@ export const archiveRouter = router({
const data = asRecord(general.data); const data = asRecord(general.data);
const stats = asRecord(data.stats); const stats = asRecord(data.stats);
const role = asRecord(data.role);
const nationId = firstNumber(data, 'nationId', 'nation') ?? 0; const nationId = firstNumber(data, 'nationId', 'nation') ?? 0;
const nation = nationByServerAndId.get(`${general.serverId}:${nationId}`); const nation = nationByServerAndId.get(`${general.serverId}:${nationId}`);
const nationData = asRecord(nation?.data); const nationData = asRecord(nation?.data);
const officerLevel = firstNumber(data, 'officerLevel', 'officer_level');
const nationLevel = firstNumber(nationData, 'level', 'nationLevel');
const archivedRole = archivedRoles[generalIndex]!;
season.generals.push({ season.generals.push({
generalNo: general.generalNo, generalNo: general.generalNo,
name: general.name, name: general.name,
@@ -152,10 +182,14 @@ export const archiveRouter = router({
intel: firstNumber(data, 'intel', 'intelligence') ?? numberOrNull(stats.intelligence), intel: firstNumber(data, 'intel', 'intelligence') ?? numberOrNull(stats.intelligence),
experience: numberOrNull(data.experience), experience: numberOrNull(data.experience),
dedication: numberOrNull(data.dedication), dedication: numberOrNull(data.dedication),
officerLevel: firstNumber(data, 'officerLevel', 'officer_level'), officerLevel,
personal: displayTextOrNull(data.personalCode ?? data.personal ?? role.personality), officerLevelText:
special: displayTextOrNull(data.specialCode ?? data.special ?? role.specialDomestic), officerLevel === null
special2: displayTextOrNull(data.special2Code ?? data.special2 ?? role.specialWar), ? null
: resolveOfficerLevelName(officerLevel, nationLevel === null ? undefined : nationLevel),
personal: displayRole(archivedRole.personal, personalityNames),
special: displayRole(archivedRole.special, domesticNames),
special2: displayRole(archivedRole.special2, warNames),
historyCount: parseHistory(data.history).length, historyCount: parseHistory(data.history).length,
}); });
} }
+58 -6
View File
@@ -17,6 +17,16 @@ import {
import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js'; import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js';
import { resolveAccessWindows } from '../../services/generalAccess.js'; import { resolveAccessWindows } from '../../services/generalAccess.js';
import { adjustAccountIconForUser } from '../../services/accountIconSync.js'; import { adjustAccountIconForUser } from '../../services/accountIconSync.js';
import {
loadCrewTypeDisplayNames,
loadItemDisplayNames,
resolveCityLevelName,
resolveDedicationLevelName,
resolveNationLevelName,
resolveOfficerLevelName,
resolveRegionName,
sanitizeInternalDisplayCode,
} from '../../services/gameDisplayNames.js';
import { getMyGeneral } from '../shared/general.js'; import { getMyGeneral } from '../shared/general.js';
import { loadTraitNames, resolveNationNotice, type TraitNameMap } from '../nation/shared.js'; import { loadTraitNames, resolveNationNotice, type TraitNameMap } from '../nation/shared.js';
@@ -155,7 +165,7 @@ const resolveTraitDisplayName = (code: string, names: TraitNameMap): string => {
return loadedName; return loadedName;
} }
// Ref는 class getName()을 표시하므로 로더가 모르는 선택적 특기도 raw namespace는 노출하지 않는다. // Ref는 class getName()을 표시하므로 로더가 모르는 선택적 특기도 raw namespace는 노출하지 않는다.
return code.replace(/^che_(?:event_)?/u, ''); return sanitizeInternalDisplayCode(code);
}; };
const resolveUserSettings = (meta: Record<string, unknown>) => { const resolveUserSettings = (meta: Record<string, unknown>) => {
@@ -244,7 +254,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
return null; return null;
} }
const [city, nation, worldState] = await Promise.all([ const [city, queriedNation, worldState] = await Promise.all([
general.cityId > 0 general.cityId > 0
? ctx.db.city.findUnique({ ? ctx.db.city.findUnique({
where: { id: general.cityId }, where: { id: general.cityId },
@@ -291,18 +301,36 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
: Promise.resolve(NEUTRAL_NATION_CONTEXT), : Promise.resolve(NEUTRAL_NATION_CONTEXT),
ctx.db.worldState.findFirst({ select: { config: true } }), ctx.db.worldState.findFirst({ select: { config: true } }),
]); ]);
const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT;
const [personalityNames, domesticNames, warNames] = await Promise.all([ const [capitalCity, cityNation] = await Promise.all([
nation.capitalCityId
? ctx.db.city.findUnique({ where: { id: nation.capitalCityId }, select: { name: true } })
: Promise.resolve(null),
city && city.nationId > 0
? ctx.db.nation.findUnique({ where: { id: city.nationId }, select: { name: true } })
: Promise.resolve(null),
]);
const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeNames, itemNames] = await Promise.all([
loadTraitNames([general.personalCode], 'personality'), loadTraitNames([general.personalCode], 'personality'),
loadTraitNames([general.specialCode], 'domestic'), loadTraitNames([general.specialCode], 'domestic'),
loadTraitNames([general.special2Code], 'war'), loadTraitNames([general.special2Code], 'war'),
loadTraitNames([nation.typeCode], 'nation'),
loadCrewTypeDisplayNames(worldState, ctx.profile.id),
loadItemDisplayNames([general.horseCode, general.weaponCode, general.bookCode, general.itemCode]),
]); ]);
const metaRecord = asRecord(general.meta); const metaRecord = asRecord(general.meta);
const worldConfig = asRecord(worldState?.config); const worldConfig = asRecord(worldState?.config);
const constValues = asRecord(worldConfig.const ?? worldConfig.consts); const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
const maxDedicationLevel = readNumber(constValues.maxDedLevel, 30);
const settings = resolveUserSettings(metaRecord); const settings = resolveUserSettings(metaRecord);
const penalties = resolvePenalty(general.penalty); const penalties = resolvePenalty(general.penalty);
const dedicationLevel = readNumber(metaRecord.dedlevel, 0);
const itemName = (code: string | null): string | null => {
const normalized = normalizeItemCode(code);
return normalized ? (itemNames.get(normalized) ?? sanitizeInternalDisplayCode(normalized)) : null;
};
return { return {
general: { general: {
@@ -315,6 +343,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
picture: general.picture, picture: general.picture,
imageServer: general.imageServer, imageServer: general.imageServer,
officerLevel: general.officerLevel, officerLevel: general.officerLevel,
officerLevelText: resolveOfficerLevelName(general.officerLevel, nation.level),
stats: { stats: {
leadership: general.leadership, leadership: general.leadership,
strength: general.strength, strength: general.strength,
@@ -331,6 +360,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
age: general.age, age: general.age,
turnTime: general.turnTime.toISOString(), turnTime: general.turnTime.toISOString(),
crewTypeId: general.crewTypeId, crewTypeId: general.crewTypeId,
crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-',
traits: { traits: {
personal: resolveTraitDisplayName(general.personalCode, personalityNames), personal: resolveTraitDisplayName(general.personalCode, personalityNames),
specialDomestic: resolveTraitDisplayName(general.specialCode, domesticNames), specialDomestic: resolveTraitDisplayName(general.specialCode, domesticNames),
@@ -338,7 +368,8 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
}, },
progression: { progression: {
experienceLevel: readNumber(metaRecord.explevel, 0), experienceLevel: readNumber(metaRecord.explevel, 0),
dedicationLevel: readNumber(metaRecord.dedlevel, 0), dedicationLevel,
dedicationText: resolveDedicationLevelName(dedicationLevel, maxDedicationLevel),
statExperience: { statExperience: {
leadership: readNumber(metaRecord.leadership_exp, 0), leadership: readNumber(metaRecord.leadership_exp, 0),
strength: readNumber(metaRecord.strength_exp, 0), strength: readNumber(metaRecord.strength_exp, 0),
@@ -353,6 +384,12 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
book: normalizeItemCode(general.bookCode), book: normalizeItemCode(general.bookCode),
item: normalizeItemCode(general.itemCode), item: normalizeItemCode(general.itemCode),
}, },
itemNames: {
horse: itemName(general.horseCode),
weapon: itemName(general.weaponCode),
book: itemName(general.bookCode),
item: itemName(general.itemCode),
},
}, },
iconChoices: ctx.auth?.user.canUseGeneralPicture === false ? [] : (ctx.auth?.user.icons ?? []), iconChoices: ctx.auth?.user.canUseGeneralPicture === false ? [] : (ctx.auth?.user.icons ?? []),
canChangeIcon: general.npcState === 0 && ctx.auth?.user.canUseGeneralPicture !== false, canChangeIcon: general.npcState === 0 && ctx.auth?.user.canUseGeneralPicture !== false,
@@ -360,8 +397,23 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
typeof metaRecord.generalIconChangedAt === 'string' typeof metaRecord.generalIconChangedAt === 'string'
? new Date(new Date(metaRecord.generalIconChangedAt).getTime() + 24 * 60 * 60 * 1000).toISOString() ? new Date(new Date(metaRecord.generalIconChangedAt).getTime() + 24 * 60 * 60 * 1000).toISOString()
: null, : null,
city, city: city
nation, ? {
...city,
levelName: resolveCityLevelName(city.level),
regionName: resolveRegionName(city.region),
nationName: city.nationId > 0 ? (cityNation?.name ?? '-') : '공백지',
}
: null,
nation: {
...nation,
levelName: resolveNationLevelName(nation.level),
typeName:
nation.id === 0
? '해당 없음'
: (nationTypeNames.get(nation.typeCode)?.name ?? sanitizeInternalDisplayCode(nation.typeCode)),
capitalCityName: nation.id === 0 ? null : (capitalCity?.name ?? null),
},
settings, settings,
penalties, penalties,
}; };
@@ -4,8 +4,15 @@ import { asRecord } from '@sammo-ts/common';
import { LogCategory } from '@sammo-ts/logic'; import { LogCategory } from '@sammo-ts/logic';
import { accessAuthedProcedure } from '../../../trpc.js'; import { accessAuthedProcedure } from '../../../trpc.js';
import {
loadCrewTypeDisplayNames,
loadItemDisplayNames,
resolveDedicationLevelName,
resolveOfficerLevelName,
sanitizeInternalDisplayCode,
} from '../../../services/gameDisplayNames.js';
import { getMyGeneral } from '../../shared/general.js'; import { getMyGeneral } from '../../shared/general.js';
import { assertNationAccess, formatDateTime, resolveNationPermission } from '../shared.js'; import { assertNationAccess, formatDateTime, loadTraitNames, resolveNationPermission } from '../shared.js';
export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => { export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
const me = await getMyGeneral(ctx); const me = await getMyGeneral(ctx);
@@ -98,6 +105,36 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
typeof constValues.upgradeLimit === 'number' && Number.isFinite(constValues.upgradeLimit) typeof constValues.upgradeLimit === 'number' && Number.isFinite(constValues.upgradeLimit)
? constValues.upgradeLimit ? constValues.upgradeLimit
: 30; : 30;
const maxDedicationLevel =
typeof constValues.maxDedLevel === 'number' && Number.isFinite(constValues.maxDedLevel)
? Math.max(0, Math.trunc(constValues.maxDedLevel))
: 30;
const [personalityNames, domesticNames, warNames, crewTypeNames, itemNames] = await Promise.all([
loadTraitNames(
generalRows.map((general) => general.personalCode),
'personality'
),
loadTraitNames(
generalRows.map((general) => general.specialCode),
'domestic'
),
loadTraitNames(
generalRows.map((general) => general.special2Code),
'war'
),
loadCrewTypeDisplayNames(worldState, ctx.profile.id),
loadItemDisplayNames(
generalRows.flatMap((general) => [
general.weaponCode,
general.bookCode,
general.horseCode,
general.itemCode,
])
),
]);
const traitName = (code: string, names: Awaited<ReturnType<typeof loadTraitNames>>): string =>
names.get(code)?.name ?? sanitizeInternalDisplayCode(code);
const itemName = (code: string): string => itemNames.get(code) ?? sanitizeInternalDisplayCode(code);
const generals = generalRows.map((general) => { const generals = generalRows.map((general) => {
const meta = const meta =
@@ -108,6 +145,11 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
const value = meta[key]; const value = meta[key];
return typeof value === 'number' && Number.isFinite(value) ? value : 0; return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}; };
const storedDedicationLevel = metaNumber('dedlevel');
const dedicationLevel =
storedDedicationLevel > 0
? storedDedicationLevel
: Math.max(0, Math.min(Math.ceil(Math.sqrt(general.dedication) / 10), maxDedicationLevel));
return { return {
id: general.id, id: general.id,
name: general.name, name: general.name,
@@ -115,6 +157,7 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
imageServer: general.imageServer, imageServer: general.imageServer,
npcState: general.npcState, npcState: general.npcState,
officerLevel: general.officerLevel, officerLevel: general.officerLevel,
officerLevelText: resolveOfficerLevelName(general.officerLevel, nation.level),
cityId: general.cityId, cityId: general.cityId,
turnTime: formatDateTime(general.turnTime), turnTime: formatDateTime(general.turnTime),
recentWar: formatDateTime(general.recentWarTime), recentWar: formatDateTime(general.recentWarTime),
@@ -134,19 +177,28 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
atmos: general.atmos, atmos: general.atmos,
age: general.age, age: general.age,
crewTypeId: general.crewTypeId, crewTypeId: general.crewTypeId,
crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-',
equipment: { equipment: {
weapon: general.weaponCode, weapon: general.weaponCode,
book: general.bookCode, book: general.bookCode,
horse: general.horseCode, horse: general.horseCode,
item: general.itemCode, item: general.itemCode,
}, },
equipmentNames: {
weapon: itemName(general.weaponCode),
book: itemName(general.bookCode),
horse: itemName(general.horseCode),
item: itemName(general.itemCode),
},
traits: { traits: {
personal: general.personalCode, personal: traitName(general.personalCode, personalityNames),
specialDomestic: general.specialCode, specialDomestic: traitName(general.specialCode, domesticNames),
specialWar: general.special2Code, specialWar: traitName(general.special2Code, warNames),
}, },
progression: { progression: {
experienceLevel: metaNumber('explevel'), experienceLevel: metaNumber('explevel'),
dedicationLevel,
dedicationText: resolveDedicationLevelName(dedicationLevel, maxDedicationLevel),
statExperience: { statExperience: {
leadership: metaNumber('leadership_exp'), leadership: metaNumber('leadership_exp'),
strength: metaNumber('strength_exp'), strength: metaNumber('strength_exp'),
@@ -1,6 +1,8 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { asNumber, asRecord } from '@sammo-ts/common';
import { accessAuthedProcedure } from '../../../trpc.js'; import { accessAuthedProcedure } from '../../../trpc.js';
import { resolveDedicationLevelName, sanitizeInternalDisplayCode } from '../../../services/gameDisplayNames.js';
import { getMyGeneral } from '../../shared/general.js'; import { getMyGeneral } from '../../shared/general.js';
import { import {
assertNationAccess, assertNationAccess,
@@ -15,8 +17,8 @@ const experienceLevel = (experience: number): number =>
0, 0,
Math.min(100, experience < 1000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10))) Math.min(100, experience < 1000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10)))
); );
const dedicationLevel = (dedication: number): number => const dedicationLevel = (dedication: number, maxLevel: number): number =>
Math.max(0, Math.min(10, Math.ceil(Math.sqrt(dedication) / 10))); Math.max(0, Math.min(maxLevel, Math.ceil(Math.sqrt(dedication) / 10)));
export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => { export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => {
const general = await getMyGeneral(ctx); const general = await getMyGeneral(ctx);
@@ -85,14 +87,22 @@ export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => {
const accessByGeneral = new Map(accessRows.map((entry) => [entry.generalId, entry.refreshScoreTotal])); const accessByGeneral = new Map(accessRows.map((entry) => [entry.generalId, entry.refreshScoreTotal]));
const nationTrait = (await loadTraitNames([nation.typeCode], 'nation')).get(nation.typeCode); const nationTrait = (await loadTraitNames([nation.typeCode], 'nation')).get(nation.typeCode);
const permission = resolveNationPermission(general, nation.meta, true); const permission = resolveNationPermission(general, nation.meta, true);
const config = asRecord(worldState?.config);
const maxDedicationLevel = Math.max(0, Math.trunc(asNumber(asRecord(config.const).maxDedLevel, 30)));
const visibleList = list.map((entry) => { const visibleList = list.map((entry) => {
const entryDedicationLevel = dedicationLevel(entry.dedication, maxDedicationLevel);
const dedicationDisplay = {
dedicationLevel: entryDedicationLevel,
dedicationText: resolveDedicationLevelName(entryDedicationLevel, maxDedicationLevel),
bill: entryDedicationLevel * 200 + 400,
};
const { permission: _targetPermission, ...safeEntry } = entry; const { permission: _targetPermission, ...safeEntry } = entry;
if (permission >= 1) { if (permission >= 1) {
return { return {
...safeEntry, ...safeEntry,
refreshScoreTotal: accessByGeneral.get(entry.id) ?? 0, refreshScoreTotal: accessByGeneral.get(entry.id) ?? 0,
experienceLevel: experienceLevel(entry.experience), experienceLevel: experienceLevel(entry.experience),
dedicationLevel: dedicationLevel(entry.dedication), ...dedicationDisplay,
}; };
} }
const { crew: _crew, experience: _experience, dedication: _dedication, ...visible } = safeEntry; const { crew: _crew, experience: _experience, dedication: _dedication, ...visible } = safeEntry;
@@ -105,7 +115,7 @@ export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => {
officerCity: 0, officerCity: 0,
officerCityName: null, officerCityName: null,
experienceLevel: experienceLevel(entry.experience), experienceLevel: experienceLevel(entry.experience),
dedicationLevel: dedicationLevel(entry.dedication), ...dedicationDisplay,
}; };
}); });
@@ -118,7 +128,7 @@ export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => {
typeCode: nation.typeCode, typeCode: nation.typeCode,
type: { type: {
key: nation.typeCode, key: nation.typeCode,
name: nationTrait?.name ?? nation.typeCode, name: nationTrait?.name ?? sanitizeInternalDisplayCode(nation.typeCode),
info: nationTrait?.info ?? '', info: nationTrait?.info ?? '',
}, },
capitalCityId: nation.capitalCityId ?? 0, capitalCityId: nation.capitalCityId ?? 0,
+5 -7
View File
@@ -28,6 +28,7 @@ import {
import type { GameApiContext, InputJsonValue, WorldStateRow } from '../../context.js'; import type { GameApiContext, InputJsonValue, WorldStateRow } from '../../context.js';
import { purifyNationHtml } from '../../security/nationHtml.js'; import { purifyNationHtml } from '../../security/nationHtml.js';
import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js';
import { resolveSecretPermission } from '../shared/secretPermission.js'; import { resolveSecretPermission } from '../shared/secretPermission.js';
export type PermissionKind = 'normal' | 'ambassador' | 'auditor'; export type PermissionKind = 'normal' | 'ambassador' | 'auditor';
@@ -313,10 +314,7 @@ export const loadTraitNames = async (keys: Array<string | null>, kind: keyof Tra
} }
} }
if (eventFiltered.length) { if (eventFiltered.length) {
const modules = await loadEventDomesticTraitModules( const modules = await loadEventDomesticTraitModules(eventFiltered, new EventDomesticTraitLoader());
eventFiltered,
new EventDomesticTraitLoader()
);
for (const module of modules) { for (const module of modules) {
cache.set(module.key, { name: module.name, info: module.info ?? '' }); cache.set(module.key, { name: module.name, info: module.info ?? '' });
} }
@@ -513,21 +511,21 @@ export const mapGeneralList = async (
personality: personalityKey personality: personalityKey
? { ? {
key: personalityKey, key: personalityKey,
name: personalityMap.get(personalityKey)?.name ?? personalityKey, name: personalityMap.get(personalityKey)?.name ?? sanitizeInternalDisplayCode(personalityKey),
info: personalityMap.get(personalityKey)?.info ?? '', info: personalityMap.get(personalityKey)?.info ?? '',
} }
: null, : null,
specialDomestic: domesticKey specialDomestic: domesticKey
? { ? {
key: domesticKey, key: domesticKey,
name: domesticMap.get(domesticKey)?.name ?? domesticKey, name: domesticMap.get(domesticKey)?.name ?? sanitizeInternalDisplayCode(domesticKey),
info: domesticMap.get(domesticKey)?.info ?? '', info: domesticMap.get(domesticKey)?.info ?? '',
} }
: null, : null,
specialWar: warKey specialWar: warKey
? { ? {
key: warKey, key: warKey,
name: warMap.get(warKey)?.name ?? warKey, name: warMap.get(warKey)?.name ?? sanitizeInternalDisplayCode(warKey),
info: warMap.get(warKey)?.info ?? '', info: warMap.get(warKey)?.info ?? '',
} }
: null, : null,
+162 -165
View File
@@ -8,6 +8,7 @@ import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { loadMapLayout } from '../../maps/mapLayout.js'; import { loadMapLayout } from '../../maps/mapLayout.js';
import { loadPublicMap } from '../../maps/worldMap.js'; import { loadPublicMap } from '../../maps/worldMap.js';
import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js'; import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js';
import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js';
import { accessInputProcedure, procedure, router, sessionActivityProcedure } from '../../trpc.js'; import { accessInputProcedure, procedure, router, sessionActivityProcedure } from '../../trpc.js';
import { loadTraitNames } from '../nation/shared.js'; import { loadTraitNames } from '../nation/shared.js';
@@ -478,174 +479,170 @@ export const publicRouter = router({
})); }));
}), }),
getNpcList: accessInputProcedure( getNpcList: accessInputProcedure(
z z
.object({ .object({
sort: z.number().int().min(1).max(8).catch(1).optional(), sort: z.number().int().min(1).max(8).catch(1).optional(),
includeAllWithToken: z.boolean().optional(), includeAllWithToken: z.boolean().optional(),
}) })
.optional() .optional()
) ).query(async ({ ctx, input }) => {
.query(async ({ ctx, input }) => { const sort = (input?.sort ?? 1) as NpcListSort;
const sort = (input?.sort ?? 1) as NpcListSort; const includeAllWithToken = input?.includeAllWithToken === true;
const includeAllWithToken = input?.includeAllWithToken === true; if (includeAllWithToken && !ctx.auth) {
if (includeAllWithToken && !ctx.auth) { throw new TRPCError({ code: 'UNAUTHORIZED' });
throw new TRPCError({ code: 'UNAUTHORIZED' }); }
} const now = new Date(Math.floor(Date.now() / 1000) * 1000);
const now = new Date(Math.floor(Date.now() / 1000) * 1000); const poolGeneralIds = includeAllWithToken
const poolGeneralIds = includeAllWithToken ? []
? [] : (
: ( await ctx.db.selectPoolEntry.findMany({
await ctx.db.selectPoolEntry.findMany({ where: { generalId: { not: null } },
where: { generalId: { not: null } }, select: { generalId: true },
select: { generalId: true }, })
}) ).flatMap(({ generalId }) => (generalId === null ? [] : [generalId]));
).flatMap(({ generalId }) => (generalId === null ? [] : [generalId])); const [generals, nations, activeTokens, worldState] = await Promise.all([
const [generals, nations, activeTokens, worldState] = await Promise.all([ ctx.db.general.findMany({
ctx.db.general.findMany({ ...(includeAllWithToken
...(includeAllWithToken ? {}
? {} : {
: { where: {
where: { OR: [{ npcState: 1 }, { npcState: 0, id: { in: poolGeneralIds } }],
OR: [ },
{ npcState: 1 }, }),
{ npcState: 0, id: { in: poolGeneralIds } }, select: {
], id: true,
}, name: true,
}), picture: true,
select: { imageServer: true,
id: true, npcState: true,
name: true, age: true,
picture: true, officerLevel: true,
imageServer: true, nationId: true,
npcState: true, leadership: true,
age: true, strength: true,
officerLevel: true, intel: true,
nationId: true, experience: true,
leadership: true, dedication: true,
strength: true, personalCode: true,
intel: true, specialCode: true,
experience: true, special2Code: true,
dedication: true, meta: true,
personalCode: true, },
specialCode: true, orderBy: { id: 'asc' },
special2Code: true, }),
meta: true, ctx.db.nation.findMany({
}, select: { id: true, name: true, level: true },
orderBy: { id: 'asc' }, }),
}), includeAllWithToken
ctx.db.nation.findMany({ ? ctx.db.npcSelectionToken.findMany({
select: { id: true, name: true, level: true }, where: { validUntil: { gte: now } },
}), select: { pickResult: true },
includeAllWithToken })
? ctx.db.npcSelectionToken.findMany({ : [],
where: { validUntil: { gte: now } }, includeAllWithToken
select: { pickResult: true }, ? ctx.db.worldState.findFirst({
}) select: { config: true },
: [], })
includeAllWithToken : null,
? ctx.db.worldState.findFirst({ ]);
select: { config: true },
})
: null,
]);
const personalityKeys = generals.map((general) => normalizeTraitKey(general.personalCode)); const personalityKeys = generals.map((general) => normalizeTraitKey(general.personalCode));
const domesticKeys = generals.map((general) => normalizeTraitKey(general.specialCode)); const domesticKeys = generals.map((general) => normalizeTraitKey(general.specialCode));
const warKeys = generals.map((general) => normalizeTraitKey(general.special2Code)); const warKeys = generals.map((general) => normalizeTraitKey(general.special2Code));
const [personalityMap, domesticMap, warMap] = await Promise.all([ const [personalityMap, domesticMap, warMap] = await Promise.all([
loadTraitNames(personalityKeys, 'personality'), loadTraitNames(personalityKeys, 'personality'),
loadTraitNames(domesticKeys, 'domestic'), loadTraitNames(domesticKeys, 'domestic'),
loadTraitNames(warKeys, 'war'), loadTraitNames(warKeys, 'war'),
]); ]);
const nationMap = new Map(nations.map((nation) => [nation.id, nation])); const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
const worldConfig = asRecord(worldState?.config); const worldConfig = asRecord(worldState?.config);
const worldConstants = asRecord(worldConfig.const); const worldConstants = asRecord(worldConfig.const);
const maxLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxLevel, 255))); const maxLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxLevel, 255)));
const maxDedLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxDedLevel, 30))); const maxDedLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxDedLevel, 30)));
// Legacy a_npcList.php shows select_pool humans first and possessed npc=1 rows. // Legacy a_npcList.php shows select_pool humans first and possessed npc=1 rows.
// Unpossessed npc=2 candidates belong only to the token-aware selection screen. // Unpossessed npc=2 candidates belong only to the token-aware selection screen.
// selection list instead consumes the raw id-ordered full list before its own comparator. // selection list instead consumes the raw id-ordered full list before its own comparator.
const sourceRows = includeAllWithToken const sourceRows = includeAllWithToken
? generals ? generals
: [ : [
...generals.filter((general) => general.npcState === 0), ...generals.filter((general) => general.npcState === 0),
...generals.filter((general) => general.npcState === 1), ...generals.filter((general) => general.npcState === 1),
]; ];
const rows = sourceRows.map((general) => { const rows = sourceRows.map((general) => {
const meta = asRecord(general.meta); const meta = asRecord(general.meta);
const personalityKey = normalizeTraitKey(general.personalCode); const personalityKey = normalizeTraitKey(general.personalCode);
const domesticKey = normalizeTraitKey(general.specialCode); const domesticKey = normalizeTraitKey(general.specialCode);
const warKey = normalizeTraitKey(general.special2Code); const warKey = normalizeTraitKey(general.special2Code);
const ownerName = const ownerName =
general.npcState === 1 general.npcState === 1
? typeof meta.owner_name === 'string' ? typeof meta.owner_name === 'string'
? meta.owner_name ? meta.owner_name
: typeof meta.ownerName === 'string' : typeof meta.ownerName === 'string'
? meta.ownerName ? meta.ownerName
: '' : ''
: ''; : '';
return {
id: general.id,
name: general.name,
picture: general.picture,
imageServer: general.imageServer,
npcState: general.npcState,
ownerName,
age: general.age,
level: includeAllWithToken
? resolveExperienceLevel(general.experience, maxLevel)
: readFiniteMetaNumber(meta, 'explevel'),
officerLevel: general.officerLevel,
killturn: readFiniteMetaNumber(meta, 'killturn'),
nationId: general.nationId,
nationName: nationMap.get(general.nationId)?.name ?? '-',
nationLevel: nationMap.get(general.nationId)?.level ?? 0,
personality: personalityKey
? {
key: personalityKey,
name: personalityMap.get(personalityKey)?.name ?? personalityKey,
info: personalityMap.get(personalityKey)?.info ?? '',
}
: null,
specialDomestic: domesticKey
? {
key: domesticKey,
name: domesticMap.get(domesticKey)?.name ?? domesticKey,
info: domesticMap.get(domesticKey)?.info ?? '',
}
: null,
specialWar: warKey
? {
key: warKey,
name: warMap.get(warKey)?.name ?? warKey,
info: warMap.get(warKey)?.info ?? '',
}
: null,
statTotal: general.leadership + general.strength + general.intel,
leadership: general.leadership,
strength: general.strength,
intelligence: general.intel,
experience: general.experience,
experienceText: resolveHonorText(general.experience),
dedication: general.dedication,
dedicationText: resolveDedicationText(general.dedication, maxDedLevel),
};
});
const tokenKeepCounts = Object.fromEntries(
activeTokens.flatMap((token) =>
Object.entries(asRecord(token.pickResult)).flatMap(([generalId, value]) => {
const keepCount = asNumber(asRecord(value).keepCount, Number.NaN);
return Number.isFinite(keepCount) ? [[generalId, Math.max(0, Math.floor(keepCount))]] : [];
})
)
);
return { return {
sort, id: general.id,
generals: includeAllWithToken ? rows : sortNpcList(rows, sort), name: general.name,
tokenKeepCounts, picture: general.picture,
imageServer: general.imageServer,
npcState: general.npcState,
ownerName,
age: general.age,
level: includeAllWithToken
? resolveExperienceLevel(general.experience, maxLevel)
: readFiniteMetaNumber(meta, 'explevel'),
officerLevel: general.officerLevel,
killturn: readFiniteMetaNumber(meta, 'killturn'),
nationId: general.nationId,
nationName: nationMap.get(general.nationId)?.name ?? '-',
nationLevel: nationMap.get(general.nationId)?.level ?? 0,
personality: personalityKey
? {
key: personalityKey,
name: personalityMap.get(personalityKey)?.name ?? sanitizeInternalDisplayCode(personalityKey),
info: personalityMap.get(personalityKey)?.info ?? '',
}
: null,
specialDomestic: domesticKey
? {
key: domesticKey,
name: domesticMap.get(domesticKey)?.name ?? sanitizeInternalDisplayCode(domesticKey),
info: domesticMap.get(domesticKey)?.info ?? '',
}
: null,
specialWar: warKey
? {
key: warKey,
name: warMap.get(warKey)?.name ?? sanitizeInternalDisplayCode(warKey),
info: warMap.get(warKey)?.info ?? '',
}
: null,
statTotal: general.leadership + general.strength + general.intel,
leadership: general.leadership,
strength: general.strength,
intelligence: general.intel,
experience: general.experience,
experienceText: resolveHonorText(general.experience),
dedication: general.dedication,
dedicationText: resolveDedicationText(general.dedication, maxDedLevel),
}; };
}), });
const tokenKeepCounts = Object.fromEntries(
activeTokens.flatMap((token) =>
Object.entries(asRecord(token.pickResult)).flatMap(([generalId, value]) => {
const keepCount = asNumber(asRecord(value).keepCount, Number.NaN);
return Number.isFinite(keepCount) ? [[generalId, Math.max(0, Math.floor(keepCount))]] : [];
})
)
);
return {
sort,
generals: includeAllWithToken ? rows : sortNpcList(rows, sort),
tokenKeepCounts,
};
}),
}); });
+2 -1
View File
@@ -2,6 +2,7 @@ import { asRecord } from '@sammo-ts/common';
import { z } from 'zod'; import { z } from 'zod';
import { accessAuthedInputProcedure, authedProcedure } from '../../trpc.js'; import { accessAuthedInputProcedure, authedProcedure } from '../../trpc.js';
import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js';
import { loadTraitNames } from '../nation/shared.js'; import { loadTraitNames } from '../nation/shared.js';
import { getMyGeneral } from '../shared/general.js'; import { getMyGeneral } from '../shared/general.js';
import { resolveSecretPermission } from '../shared/secretPermission.js'; import { resolveSecretPermission } from '../shared/secretPermission.js';
@@ -172,7 +173,7 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
level: nation.level, level: nation.level,
type: { type: {
key: nation.typeCode, key: nation.typeCode,
name: nationTypeNames.get(nation.typeCode)?.name ?? nation.typeCode, name: nationTypeNames.get(nation.typeCode)?.name ?? sanitizeInternalDisplayCode(nation.typeCode),
}, },
power: readMetaNumber(nation.meta, 'power'), power: readMetaNumber(nation.meta, 'power'),
capitalCityId: nation.capitalCityId ?? 0, capitalCityId: nation.capitalCityId ?? 0,
+38 -9
View File
@@ -4,7 +4,7 @@ import fastifyStatic from '@fastify/static';
import path from 'path'; import path from 'path';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { buildGameEventChannel } from '@sammo-ts/common'; import { buildGameEventChannel, type RealtimeViewerIdentity } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { import {
createGamePostgresConnector, createGamePostgresConnector,
@@ -23,6 +23,7 @@ import { buildBattleSimQueueKeys } from './battleSim/keys.js';
import { RedisBattleSimTransport } from './battleSim/redisTransport.js'; import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
import { RedisRealtimeEventHub } from './realtime/eventHub.js'; import { RedisRealtimeEventHub } from './realtime/eventHub.js';
import { formatSseFrame } from './realtime/sse.js'; import { formatSseFrame } from './realtime/sse.js';
import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from './realtime/publicEvent.js';
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js'; import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js'; import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js';
import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js'; import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js';
@@ -229,6 +230,17 @@ export const createGameApiServer = async () => {
return; return;
} }
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
const general = await postgres.prisma.general.findFirst({
where: { userId: auth.user.id, npcState: 0 },
select: { id: true, cityId: true, nationId: true },
});
return general
? { generalId: general.id, cityId: general.cityId, nationId: general.nationId }
: { generalId: null, cityId: null, nationId: null };
};
let viewerIdentity = await loadViewerIdentity();
reply.hijack(); reply.hijack();
const requestOrigin = request.headers.origin; const requestOrigin = request.headers.origin;
if (typeof requestOrigin === 'string' && requestOrigin.length > 0) { if (typeof requestOrigin === 'string' && requestOrigin.length > 0) {
@@ -256,30 +268,47 @@ export const createGameApiServer = async () => {
sendFrame( sendFrame(
formatSseFrame({ formatSseFrame({
event: 'ready', event: 'ready',
data: JSON.stringify({ at: new Date().toISOString() }), data: '{}',
}) })
); );
let closed = false;
let eventQueue = Promise.resolve();
const unsubscribe = realtimeHub.subscribe((event) => { const unsubscribe = realtimeHub.subscribe((event) => {
sendFrame( eventQueue = eventQueue
formatSseFrame({ .then(async () => {
event: event.type, if (closed) return;
data: JSON.stringify(event), const identities = [viewerIdentity];
id: event.at, if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) {
const nextIdentity = await loadViewerIdentity();
identities.push(nextIdentity);
viewerIdentity = nextIdentity;
}
const publicEvent = toPublicRealtimeEvent(event, identities);
if (!publicEvent || closed) return;
sendFrame(
formatSseFrame({
event: publicEvent.type,
data: JSON.stringify(publicEvent),
})
);
}) })
); .catch(() => {
// A best-effort notification must not affect committed game state.
});
}); });
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
sendFrame( sendFrame(
formatSseFrame({ formatSseFrame({
event: 'ping', event: 'ping',
data: JSON.stringify({ at: new Date().toISOString() }), data: '{}',
}) })
); );
}, 15000); }, 15000);
const close = () => { const close = () => {
closed = true;
clearInterval(heartbeat); clearInterval(heartbeat);
unsubscribe(); unsubscribe();
}; };
@@ -0,0 +1,138 @@
import { asRecord } from '@sammo-ts/common';
import { isItemKey, ItemLoader } from '@sammo-ts/logic';
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
import type { WorldStateRow } from '../context.js';
const NATION_LEVEL_NAMES: Record<number, string> = {
0: '방랑군',
1: '호족',
2: '군벌',
3: '주자사',
4: '주목',
5: '공',
6: '왕',
7: '황제',
};
const CITY_LEVEL_NAMES: Record<number, string> = {
1: '수',
2: '진',
3: '관',
4: '이',
5: '소',
6: '중',
7: '대',
8: '특',
};
const REGION_NAMES: Record<number, string> = {
1: '하북',
2: '중원',
3: '서북',
4: '서촉',
5: '남중',
6: '초',
7: '오월',
8: '동이',
};
const OFFICER_LEVEL_NAMES: Record<number, string> = {
12: '군주',
11: '참모',
10: '제1장군',
9: '제1모사',
8: '제2장군',
7: '제2모사',
6: '제3장군',
5: '제3모사',
4: '태수',
3: '군사',
2: '종사',
1: '일반',
0: '재야',
};
const OFFICER_LEVEL_NAMES_BY_NATION_LEVEL: Record<number, Record<number, string>> = {
7: { 12: '황제', 11: '승상', 10: '표기장군', 9: '사공', 8: '거기장군', 7: '태위', 6: '위장군', 5: '사도' },
6: { 12: '왕', 11: '광록훈', 10: '좌장군', 9: '상서령', 8: '우장군', 7: '중서령', 6: '전장군', 5: '비서령' },
5: { 12: '공', 11: '광록대부', 10: '안국장군', 9: '집금오', 8: '파로장군', 7: '소부' },
4: { 12: '주목', 11: '태사령', 10: '아문장군', 9: '낭중', 8: '호군', 7: '종사중랑' },
3: { 12: '주자사', 11: '주부', 10: '편장군', 9: '간의대부' },
2: { 12: '군벌', 11: '참모', 10: '비장군', 9: '부참모' },
1: { 12: '영주', 11: '참모' },
0: { 12: '두목', 11: '부두목' },
};
export const sanitizeInternalDisplayCode = (value: string | null | undefined): string => {
if (!value || value === 'None') {
return '-';
}
if (/^\d+$/u.test(value)) {
return '-';
}
return value.replace(/^che_(?:event_)?/u, '');
};
export const resolveNationLevelName = (level: number): string => NATION_LEVEL_NAMES[level] ?? '-';
export const resolveCityLevelName = (level: number): string => CITY_LEVEL_NAMES[level] ?? '-';
export const resolveRegionName = (region: number): string => REGION_NAMES[region] ?? '-';
export const resolveOfficerLevelName = (officerLevel: number, nationLevel?: number): string => {
if (officerLevel < 5) {
return OFFICER_LEVEL_NAMES[officerLevel] ?? '-';
}
if (nationLevel === undefined) {
return OFFICER_LEVEL_NAMES[officerLevel] ?? '-';
}
return OFFICER_LEVEL_NAMES_BY_NATION_LEVEL[nationLevel]?.[officerLevel] ?? '-';
};
export const resolveDedicationLevelName = (dedicationLevel: number, maxDedicationLevel: number): string => {
if (dedicationLevel <= 0) {
return '무품관';
}
return `${Math.max(1, maxDedicationLevel - dedicationLevel + 1)}품관`;
};
const resolveUnitSetName = (world: Pick<WorldStateRow, 'config'> | null, fallback: string): string => {
const config = asRecord(world?.config);
const environment = asRecord(config.environment ?? config.map);
return typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : fallback;
};
const crewTypeNameCache = new Map<string, Promise<Map<number, string>>>();
export const loadCrewTypeDisplayNames = (
world: Pick<WorldStateRow, 'config'> | null,
fallback: string
): Promise<Map<number, string>> => {
const unitSetName = resolveUnitSetName(world, fallback);
const cached = crewTypeNameCache.get(unitSetName);
if (cached) {
return cached;
}
const pending = loadUnitSetDefinitionByName(unitSetName)
.then((definition) => new Map((definition.crewTypes ?? []).map((crewType) => [crewType.id, crewType.name])))
.catch(() => new Map<number, string>());
crewTypeNameCache.set(unitSetName, pending);
return pending;
};
const itemLoader = new ItemLoader();
export const loadItemDisplayNames = async (values: Array<string | null | undefined>): Promise<Map<string, string>> => {
const keys = Array.from(new Set(values.filter((value): value is string => Boolean(value) && value !== 'None')));
const entries = await Promise.all(
keys.map(async (key) => {
if (!isItemKey(key)) {
return [key, sanitizeInternalDisplayCode(key)] as const;
}
const item = await itemLoader.load(key).catch(() => null);
return [key, item?.name ?? sanitizeInternalDisplayCode(key)] as const;
})
);
return new Map(entries);
};
+6 -4
View File
@@ -156,7 +156,8 @@ describe('archive.myPastPlays', () => {
leadership: 80, leadership: 80,
strength: 70, strength: 70,
officerLevel: 8, officerLevel: 8,
personal: '3', officerLevelText: '제2장군',
personal: '-',
historyCount: 2, historyCount: 2,
}), }),
expect.objectContaining({ expect.objectContaining({
@@ -166,9 +167,10 @@ describe('archive.myPastPlays', () => {
strength: 71, strength: 71,
intel: 61, intel: 61,
officerLevel: 7, officerLevel: 7,
personal: 'che_의리', officerLevelText: '제2모사',
special: 'che_상재', personal: '의리',
special2: 'che_신산', special: '상재',
special2: '신산',
historyCount: 1, historyCount: 1,
}), }),
], ],
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import {
resolveCityLevelName,
resolveDedicationLevelName,
resolveNationLevelName,
resolveOfficerLevelName,
resolveRegionName,
sanitizeInternalDisplayCode,
} from '../src/services/gameDisplayNames.js';
describe('Ref GUI display names', () => {
it('maps nation, city, region, office, and dedication levels to Ref labels', () => {
expect(resolveNationLevelName(3)).toBe('주자사');
expect(resolveCityLevelName(8)).toBe('특');
expect(resolveRegionName(2)).toBe('중원');
expect(resolveOfficerLevelName(9, 3)).toBe('간의대부');
expect(resolveOfficerLevelName(5, 3)).toBe('-');
expect(resolveOfficerLevelName(5)).toBe('제3모사');
expect(resolveDedicationLevelName(2, 30)).toBe('29품관');
expect(resolveDedicationLevelName(0, 30)).toBe('무품관');
});
it('never exposes internal prefixes or numeric fallback codes', () => {
expect(sanitizeInternalDisplayCode('che_event_의병')).toBe('의병');
expect(sanitizeInternalDisplayCode('che_법가')).toBe('법가');
expect(sanitizeInternalDisplayCode('3')).toBe('-');
expect(resolveNationLevelName(99)).toBe('-');
expect(resolveCityLevelName(99)).toBe('-');
expect(resolveRegionName(99)).toBe('-');
});
});
@@ -248,6 +248,55 @@ describe('in-game my information ownership', () => {
); );
}); });
it('returns Ref display names instead of numeric levels and internal codes for the main GUI', async () => {
const fixture = createContext({
me: buildGeneral({
officerLevel: 9,
crewTypeId: 1100,
horseCode: 'che_명마_03_노새',
meta: { explevel: 4, dedlevel: 2 },
}),
city: {
id: 1,
name: '업',
level: 8,
nationId: 1,
population: 1_000,
populationMax: 2_000,
agriculture: 100,
agricultureMax: 200,
commerce: 100,
commerceMax: 200,
security: 100,
securityMax: 200,
trust: 70,
trade: 100,
defence: 100,
defenceMax: 200,
wall: 100,
wallMax: 200,
region: 2,
supplyState: 1,
frontState: 0,
},
});
await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({
general: {
officerLevelText: '간의대부',
crewTypeName: '보병',
progression: { experienceLevel: 4, dedicationLevel: 2, dedicationText: '29품관' },
itemNames: { horse: '노새(+3)' },
},
city: { levelName: '특', regionName: '중원', nationName: '위' },
nation: {
levelName: '주자사',
typeName: '법가',
capitalCityName: '업',
},
});
});
it('returns the Ref-style neutral nation frame and trait display names on the main read model', async () => { it('returns the Ref-style neutral nation frame and trait display names on the main read model', async () => {
const fixture = createContext({ const fixture = createContext({
me: buildGeneral({ me: buildGeneral({
@@ -542,8 +591,14 @@ describe('battle-center general and user permissions', () => {
id: 7, id: 7,
picture: 'default.jpg', picture: 'default.jpg',
imageServer: 0, imageServer: 0,
officerLevelText: '일반',
crewTypeName: '-',
equipmentNames: { weapon: '-', book: '-', horse: '-', item: '-' },
traits: { personal: '-', specialDomestic: '-', specialWar: '-' },
progression: { progression: {
experienceLevel: 0, experienceLevel: 0,
dedicationLevel: 1,
dedicationText: '30품관',
statExperience: { leadership: 0, strength: 0, intelligence: 0 }, statExperience: { leadership: 0, strength: 0, intelligence: 0 },
statUpgradeLimit: 20, statUpgradeLimit: 20,
dex: [0, 0, 0, 0, 0], dex: [0, 0, 0, 0, 0],
@@ -117,6 +117,9 @@ describe('nation general and secret office permissions', () => {
cityName: null, cityName: null,
troopName: null, troopName: null,
refreshScoreTotal: 10, refreshScoreTotal: 10,
dedicationLevel: 1,
dedicationText: '30품관',
bill: 600,
}); });
expect(result.generals[0]).not.toHaveProperty('crew'); expect(result.generals[0]).not.toHaveProperty('crew');
await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' }); await expect(caller.nation.getSecretGeneralList()).rejects.toMatchObject({ code: 'FORBIDDEN' });
@@ -0,0 +1,169 @@
import { describe, expect, it } from 'vitest';
import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common';
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from '../src/realtime/publicEvent.js';
const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const;
const turnEvent = (changes = createEmptyRealtimeReadModelChanges()): RealtimeEvent => ({
type: 'turnCompleted',
at: '2026-08-12T12:34:56.789Z',
lastTurnTime: '0185-02-01T00:00:00.000Z',
changes,
revision: 42,
});
describe('public realtime event privacy boundary', () => {
it('suppresses clock-only and unrelated private general turns', () => {
expect(toPublicRealtimeEvent(turnEvent(), [viewer])).toBeNull();
expect(
toPublicRealtimeEvent(
turnEvent({
...createEmptyRealtimeReadModelChanges(),
generalIds: [99],
}),
[viewer]
)
).toBeNull();
});
it('publishes only viewer-specific boolean invalidations', () => {
const publicEvent = toPublicRealtimeEvent(
turnEvent({
...createEmptyRealtimeReadModelChanges(),
generalIds: [7, 99],
reservedGeneralIds: [7],
recordGeneralIds: [7],
}),
[viewer]
);
expect(publicEvent).toEqual({
type: 'readModelInvalidated',
invalidation: {
context: true,
lobby: false,
map: false,
commands: true,
contacts: false,
boardAccess: true,
reservedTurns: true,
records: true,
frontStatus: false,
},
});
const serialized = JSON.stringify(publicEvent);
expect(publicEvent).not.toHaveProperty('at');
expect(publicEvent).not.toHaveProperty('lastTurnTime');
expect(publicEvent).not.toHaveProperty('revision');
for (const forbidden of ['generalIds', 'cityIds', 'nationIds', '99']) {
expect(serialized).not.toContain(forbidden);
}
});
it('keeps global refresh meaning without exposing its source identity or time', () => {
const publicEvent = toPublicRealtimeEvent(
{
type: 'readModelChanged',
at: '2026-08-12T12:34:56.789Z',
revision: 43,
changes: {
...createEmptyRealtimeReadModelChanges(),
worldChanged: true,
globalRecordsChanged: true,
worldHistoryChanged: true,
},
},
[viewer]
);
expect(publicEvent).toMatchObject({
type: 'readModelInvalidated',
invalidation: { lobby: true, map: true, commands: true, records: true },
});
expect(JSON.stringify(publicEvent)).not.toMatch(/2026|0185|revision|Ids/u);
});
it('uses a conservative identifier-free fallback for an older daemon', () => {
expect(
toPublicRealtimeEvent(
{
type: 'turnCompleted',
at: '2026-08-12T12:34:56.789Z',
lastTurnTime: '0185-02-01T00:00:00.000Z',
},
[viewer]
)
).toEqual({
type: 'readModelInvalidated',
invalidation: {
context: true,
lobby: true,
map: true,
commands: true,
contacts: true,
boardAccess: true,
reservedTurns: true,
records: true,
frontStatus: true,
},
});
});
it('filters message events per viewer and removes mailbox, sender, message, and time fields', () => {
const event: RealtimeEvent = {
type: 'messageCreated',
at: '2026-08-12T12:34:56.789Z',
mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + viewer.nationId,
msgType: 'national',
messageId: 123,
senderId: 99,
};
expect(toPublicRealtimeEvent(event, [viewer])).toEqual({ type: 'messagesInvalidated' });
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
});
it('requests an identity refresh only when the viewer general may have changed', () => {
expect(
shouldReloadRealtimeViewerIdentity(
turnEvent({ ...createEmptyRealtimeReadModelChanges(), generalIds: [7] }),
viewer
)
).toBe(true);
expect(
shouldReloadRealtimeViewerIdentity(
turnEvent({ ...createEmptyRealtimeReadModelChanges(), generalIds: [99] }),
viewer
)
).toBe(false);
});
it('merges previous and committed identities across an ownership transition', () => {
const event: RealtimeEvent = {
type: 'readModelChanged',
at: '2026-08-12T12:34:56.789Z',
revision: 44,
changes: {
...createEmptyRealtimeReadModelChanges(),
generalIds: [7],
nationIds: [3],
frontStatusNationIds: [3],
},
};
expect(
toPublicRealtimeEvent(event, [viewer, { generalId: 7, cityId: 4, nationId: 3 }])
).toMatchObject({
type: 'readModelInvalidated',
invalidation: {
context: true,
commands: true,
boardAccess: true,
frontStatus: true,
},
});
});
});
-1
View File
@@ -3,7 +3,6 @@
"compilerOptions": { "compilerOptions": {
"outDir": "dist", "outDir": "dist",
"composite": true, "composite": true,
"baseUrl": ".",
"paths": { "paths": {
"@sammo-ts/common": [ "@sammo-ts/common": [
"../../packages/common/src/index.ts" "../../packages/common/src/index.ts"
+1 -1
View File
@@ -99,7 +99,7 @@
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs", "profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs",
"test": "vitest run --config vitest.config.ts", "test": "vitest run --config vitest.config.ts",
"typecheck": "tsc -b" "typecheck": "pnpm -w tsc7 -b app/game-engine/tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^7.9.1", "@prisma/client": "^7.9.1",
@@ -79,6 +79,7 @@ const DEFAULT_GENERAL_RICE = 1000;
const DEFAULT_CREW_TYPE_ID = 1100; const DEFAULT_CREW_TYPE_ID = 1100;
const MAX_GENERAL_TURNS = 30; const MAX_GENERAL_TURNS = 30;
const DEFAULT_TURN_ACTION = '휴식'; const DEFAULT_TURN_ACTION = '휴식';
export const JOIN_WELCOME_MESSAGE = '삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^';
const LEGACY_TIMEZONE_OFFSET_MS = 9 * 60 * 60 * 1000; const LEGACY_TIMEZONE_OFFSET_MS = 9 * 60 * 60 * 1000;
const LEGACY_JOIN_REMOVED_CHARACTERS = /[\\/`#|-]/gu; const LEGACY_JOIN_REMOVED_CHARACTERS = /[\\/`#|-]/gu;
@@ -460,7 +461,7 @@ const pushCreationLogs = (
logger.pushGeneralHistoryLog(`<Y>${options.name}</>, <G>${options.cityName}</>에서 큰 뜻을 품다.`); logger.pushGeneralHistoryLog(`<Y>${options.name}</>, <G>${options.cityName}</>에서 큰 뜻을 품다.`);
logger.pushGeneralActionLog( logger.pushGeneralActionLog(
[ [
'삼국지 모의전투 PHP의 세계에 오신 것을 환영합니다 ^o^', JOIN_WELCOME_MESSAGE,
'처음 하시는 경우에는 <D>도움말</>을 참고하시고,', '처음 하시는 경우에는 <D>도움말</>을 참고하시고,',
'문의사항이 있으시면 게시판에 글을 남겨주시면 되겠네요~', '문의사항이 있으시면 게시판에 글을 남겨주시면 되겠네요~',
'부디 즐거운 삼모전 되시길 바랍니다 ^^', '부디 즐거운 삼모전 되시길 바랍니다 ^^',
@@ -6,10 +6,12 @@ import {
EventDomesticTraitLoader, EventDomesticTraitLoader,
isEventDomesticTraitKey, isEventDomesticTraitKey,
isPersonalityTraitKey, isPersonalityTraitKey,
isWarTraitKey,
LogCategory, LogCategory,
LogScope, LogScope,
PERSONALITY_TRAIT_KEYS, PERSONALITY_TRAIT_KEYS,
simpleSerialize, simpleSerialize,
WarTraitLoader,
} from '@sammo-ts/logic'; } from '@sammo-ts/logic';
import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra'; import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra';
@@ -78,6 +80,8 @@ export interface SelectPoolCandidateDto {
specialDomesticName: string; specialDomesticName: string;
specialDomesticInfo: string; specialDomesticInfo: string;
specialWar: string | null; specialWar: string | null;
specialWarName: string | null;
specialWarInfo: string;
ego: string | null; ego: string | null;
dex: [number, number, number, number, number]; dex: [number, number, number, number, number];
imageServer: 0 | 1; imageServer: 0 | 1;
@@ -158,11 +162,16 @@ const candidateWeight = (candidate: SelectPoolCandidateInfo): number =>
candidate.dex.reduce((sum, value) => sum + value, 0); candidate.dex.reduce((sum, value) => sum + value, 0);
const eventDomesticTraitLoader = new EventDomesticTraitLoader(); const eventDomesticTraitLoader = new EventDomesticTraitLoader();
const warTraitLoader = new WarTraitLoader();
const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise<SelectPoolCandidateDto> => { const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise<SelectPoolCandidateDto> => {
const trait = isEventDomesticTraitKey(candidate.specialDomestic) const trait = isEventDomesticTraitKey(candidate.specialDomestic)
? await eventDomesticTraitLoader.load(candidate.specialDomestic) ? await eventDomesticTraitLoader.load(candidate.specialDomestic)
: null; : null;
const warTrait =
candidate.specialWar && isWarTraitKey(candidate.specialWar)
? await warTraitLoader.load(candidate.specialWar)
: null;
return { return {
uniqueName: candidate.uniqueName, uniqueName: candidate.uniqueName,
generalName: candidate.generalName, generalName: candidate.generalName,
@@ -173,6 +182,8 @@ const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise<Selec
specialDomesticName: trait?.name ?? candidate.specialDomestic.replace(/^che_event_/, ''), specialDomesticName: trait?.name ?? candidate.specialDomestic.replace(/^che_event_/, ''),
specialDomesticInfo: trait?.info ?? '', specialDomesticInfo: trait?.info ?? '',
specialWar: candidate.specialWar ?? null, specialWar: candidate.specialWar ?? null,
specialWarName: warTrait?.name ?? candidate.specialWar?.replace(/^che_(?:event_)?/u, '') ?? null,
specialWarInfo: warTrait?.info ?? '',
ego: candidate.ego ?? null, ego: candidate.ego ?? null,
dex: candidate.dex, dex: candidate.dex,
imageServer: candidate.imgsvr, imageServer: candidate.imgsvr,
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { buildJoinCreateGeneralSeed, cutJoinTurnTime } from '../src/turn/joinCreateGeneralService.js'; import {
buildJoinCreateGeneralSeed,
cutJoinTurnTime,
JOIN_WELCOME_MESSAGE,
} from '../src/turn/joinCreateGeneralService.js';
describe('generic join legacy time contracts', () => { describe('generic join legacy time contracts', () => {
it('builds the Ref MakeGeneral seed from the logical game tick', () => { it('builds the Ref MakeGeneral seed from the logical game tick', () => {
@@ -14,4 +18,9 @@ describe('generic join legacy time contracts', () => {
'2026-07-30T02:00:00.000Z' '2026-07-30T02:00:00.000Z'
); );
}); });
it('uses the HiDCHe product name without the legacy PHP runtime label', () => {
expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^');
expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP');
});
}); });
+13
View File
@@ -383,9 +383,22 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p
.first() .first()
.evaluate((el) => getComputedStyle(el).borderCollapse) .evaluate((el) => getComputedStyle(el).borderCollapse)
).toBe(borderCollapse); ).toBe(borderCollapse);
if (path === 'nation/info') {
await expect(page.locator(selector)).toContainText('작 위호족');
await expect(page.locator(selector)).not.toContainText('작 위1');
}
} }
}); });
test('국가 정보의 작위는 Ref 국가 등급 이름으로 표시된다', async ({ page }) => {
await install(page);
await page.goto('nation/info');
const root = page.locator('.legacy-info-page');
await expect(root).toContainText('작 위호족');
await expect(root).not.toContainText('작 위1');
});
test('global-info renders the ref nation summary columns beside the map', async ({ page }) => { test('global-info renders the ref nation summary columns beside the map', async ({ page }) => {
await install(page); await install(page);
await page.setViewportSize({ width: 1200, height: 900 }); await page.setViewportSize({ width: 1200, height: 900 });
+100 -7
View File
@@ -63,7 +63,9 @@ const myGeneral = (state: FixtureState) => ({
troopId: 0, troopId: 0,
picture: null, picture: null,
imageServer: 0, imageServer: 0,
officerLevel: state.permission === 'head' ? 5 : 1, officerLevel: state.permission === 'head' ? 9 : 1,
officerLevelText:
state.permission === 'head' ? '간의대부' : state.buildNationCandidateEnabled ? '재야' : '일반',
stats: { leadership: 70, strength: 60, intelligence: 50 }, stats: { leadership: 70, strength: 60, intelligence: 50 },
gold: 1_000, gold: 1_000,
rice: 2_000, rice: 2_000,
@@ -76,30 +78,74 @@ const myGeneral = (state: FixtureState) => ({
age: 30, age: 30,
turnTime: '2026-01-01 00:10:00', turnTime: '2026-01-01 00:10:00',
crewTypeId: 1, crewTypeId: 1,
crewTypeName: '보병',
traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' }, traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' },
progression: { progression: {
experienceLevel: 1, experienceLevel: 1,
dedicationLevel: 2, dedicationLevel: 2,
dedicationText: '29품관',
statExperience: { leadership: 7, strength: 8, intelligence: 9 }, statExperience: { leadership: 7, strength: 8, intelligence: 9 },
statUpgradeLimit: 20, statUpgradeLimit: 20,
dex: [350, 1_375, 3_500, 7_125, 1_275_975], dex: [350, 1_375, 3_500, 7_125, 1_275_975],
}, },
items: { horse: 'che_명마', weapon: null, book: null, item: null }, items: { horse: 'che_명마', weapon: null, book: null, item: null },
itemNames: { horse: '명마', weapon: null, book: null, item: null },
},
city: {
id: 1,
name: '업',
level: 8,
levelName: '특',
region: 2,
regionName: '중원',
nationId: 1,
nationName: '위',
population: 1000,
populationMax: 2000,
agriculture: 100,
agricultureMax: 200,
commerce: 100,
commerceMax: 200,
security: 100,
securityMax: 200,
trust: 70,
trade: 100,
defence: 100,
defenceMax: 200,
wall: 100,
wallMax: 200,
supplyState: 1,
frontState: 0,
}, },
city: { id: 1, name: '업', level: 8, nationId: 1 },
nation: state.buildNationCandidateEnabled nation: state.buildNationCandidateEnabled
? { ? {
id: 0, id: 0,
name: '재야', name: '재야',
color: '#000000', color: '#000000',
level: 0, level: 0,
levelName: '방랑군',
gold: 0, gold: 0,
rice: 0, rice: 0,
tech: 0, tech: 0,
typeCode: 'None', typeCode: 'None',
typeName: '해당 없음',
capitalCityId: null, capitalCityId: null,
capitalCityName: null,
} }
: { id: 1, name: '위', color: '#777777', level: 3 }, : {
id: 1,
name: '위',
color: '#777777',
level: 3,
levelName: '주자사',
gold: 10_000,
rice: 20_000,
tech: 100,
typeCode: 'che_법가',
typeName: '법가',
capitalCityId: 1,
capitalCityName: '업',
},
settings: { settings: {
tnmt: 0, tnmt: 0,
defence_train: 80, defence_train: 80,
@@ -116,7 +162,7 @@ const myGeneral = (state: FixtureState) => ({
const battleCenter = (state: FixtureState) => ({ const battleCenter = (state: FixtureState) => ({
me: { me: {
id: 7, id: 7,
officerLevel: state.permission === 'head' ? 5 : 1, officerLevel: state.permission === 'head' ? 9 : 1,
permissionLevel: state.permission === 'head' ? 2 : 0, permissionLevel: state.permission === 'head' ? 2 : 0,
}, },
nation: { id: 1, name: '위', color: '#777777', level: 3 }, nation: { id: 1, name: '위', color: '#777777', level: 3 },
@@ -128,7 +174,8 @@ const battleCenter = (state: FixtureState) => ({
id: 7, id: 7,
name: '검증장수', name: '검증장수',
npcState: 0, npcState: 0,
officerLevel: state.permission === 'head' ? 5 : 1, officerLevel: state.permission === 'head' ? 9 : 1,
officerLevelText: state.permission === 'head' ? '간의대부' : '일반',
cityId: 1, cityId: 1,
turnTime: '2026-01-01 00:10:00', turnTime: '2026-01-01 00:10:00',
recentWar: '2026-01-01 00:00:00', recentWar: '2026-01-01 00:00:00',
@@ -144,10 +191,14 @@ const battleCenter = (state: FixtureState) => ({
atmos: 90, atmos: 90,
age: 30, age: 30,
crewTypeId: 1, crewTypeId: 1,
crewTypeName: '보병',
equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' }, equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' },
traits: { personal: 'None', specialDomestic: 'None', specialWar: 'None' }, equipmentNames: { weapon: '-', book: '-', horse: '-', item: '-' },
traits: { personal: '-', specialDomestic: '-', specialWar: '-' },
progression: { progression: {
experienceLevel: 1, experienceLevel: 1,
dedicationLevel: 2,
dedicationText: '29품관',
statExperience: { leadership: 7, strength: 8, intelligence: 9 }, statExperience: { leadership: 7, strength: 8, intelligence: 9 },
statUpgradeLimit: 20, statUpgradeLimit: 20,
dex: [350, 1_375, 3_500, 7_125, 1_275_975], dex: [350, 1_375, 3_500, 7_125, 1_275_975],
@@ -159,6 +210,7 @@ const battleCenter = (state: FixtureState) => ({
name: '다른장수', name: '다른장수',
npcState: 2, npcState: 2,
officerLevel: 1, officerLevel: 1,
officerLevelText: '일반',
cityId: 1, cityId: 1,
turnTime: '2026-01-01 00:20:00', turnTime: '2026-01-01 00:20:00',
recentWar: null, recentWar: null,
@@ -174,10 +226,14 @@ const battleCenter = (state: FixtureState) => ({
atmos: 60, atmos: 60,
age: 20, age: 20,
crewTypeId: 1, crewTypeId: 1,
crewTypeName: '보병',
equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' }, equipment: { weapon: 'None', book: 'None', horse: 'None', item: 'None' },
traits: { personal: 'None', specialDomestic: 'None', specialWar: 'None' }, equipmentNames: { weapon: '-', book: '-', horse: '-', item: '-' },
traits: { personal: '-', specialDomestic: '-', specialWar: '-' },
progression: { progression: {
experienceLevel: 0, experienceLevel: 0,
dedicationLevel: 0,
dedicationText: '무품관',
statExperience: { leadership: 0, strength: 0, intelligence: 0 }, statExperience: { leadership: 0, strength: 0, intelligence: 0 },
statUpgradeLimit: 20, statUpgradeLimit: 20,
dex: [0, 0, 0, 0, 0], dex: [0, 0, 0, 0, 0],
@@ -512,6 +568,35 @@ test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표
await persistParityArtifact(page, 'main-neutral-trait-display', geometry); await persistParityArtifact(page, 'main-neutral-trait-display', geometry);
}); });
test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명으로 표시된다', async ({ page }) => {
const state: FixtureState = {
permission: 'head',
myset: 3,
mainTraits: { personal: '안전', specialDomestic: '상재', specialWar: '신산' },
settingMutations: [],
accessPages: [],
};
await install(page, state);
await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('');
const nationCard = page.locator('.nation-card');
await expect(nationCard.locator('.title')).toHaveText('위 (주자사)');
await expect(nationCard).toContainText('체제법가');
await expect(nationCard).toContainText('수도업');
await expect(nationCard).toContainText('국가 등급주자사');
const generalCard = page.locator('.general-card');
await expect(generalCard.locator('.general-title')).toContainText('검증장수 · 간의대부');
await expect(generalCard).toContainText('병종보병');
await expect(generalCard).toContainText('계급29품관');
const cityCard = page.locator('.city-card');
await expect(cityCard.locator('.title')).toContainText('【중원 | 특】 업');
await expect(cityCard.locator('.title')).toContainText('지배 국가 【 위 】');
await expect(page.locator('.main-page')).not.toContainText('che_');
});
test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => { test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => {
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] }; const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] };
await install(page, state); await install(page, state);
@@ -566,6 +651,10 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
await install(page, state); await install(page, state);
await page.setViewportSize({ width: 1000, height: 900 }); await page.setViewportSize({ width: 1000, height: 900 });
await page.goto('my-page'); await page.goto('my-page');
await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관');
await expect(page.locator('.legacy-general-details')).toContainText('병종 보병');
await expect(page.locator('.item-group')).toContainText('명마');
await expect(page.locator('#container')).not.toContainText('che_');
await expect(page.locator('.title-row')).toContainText('내 정 보'); await expect(page.locator('.title-row')).toContainText('내 정 보');
await expect(page.locator('#set_my_setting')).toBeVisible(); await expect(page.locator('#set_my_setting')).toBeVisible();
await expect(page.locator('.general-column [role="progressbar"]')).toHaveCount(14); await expect(page.locator('.general-column [role="progressbar"]')).toHaveCount(14);
@@ -1020,6 +1109,10 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8'); await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8');
await page.getByRole('button', { name: '다음 ▶' }).click(); await page.getByRole('button', { name: '다음 ▶' }).click();
await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7'); await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7');
await expect(page.locator('.battle-general-name')).toContainText('검증장수 (간의대부)');
await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
await expect(page.locator('.battle-general-extra')).toContainText('병종보병');
await expect(page.locator('.battle-general-card')).not.toContainText('che_');
await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14); await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14);
await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5); await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
expect( expect(
+156 -186
View File
@@ -20,6 +20,7 @@ type NavigationFixture = {
generalMeCalls: number; generalMeCalls: number;
operations: string[]; operations: string[];
generalName?: string; generalName?: string;
generalTurnTime?: string;
cityDefence?: number; cityDefence?: number;
cityState?: number; cityState?: number;
nationRate?: number; nationRate?: number;
@@ -68,60 +69,40 @@ const operationInput = (route: Route, index: number): DashboardBundleInput => {
return entry.json ?? (entry as DashboardBundleInput); return entry.json ?? (entry as DashboardBundleInput);
}; };
const readModelChanges = ( const readModelInvalidation = (
overrides: Partial<{ overrides: Partial<{
generalIds: number[]; context: boolean;
cityIds: number[]; lobby: boolean;
nationIds: number[]; map: boolean;
mapGeneralIds: number[]; commands: boolean;
mapCityIds: number[]; contacts: boolean;
mapNationIds: number[]; boardAccess: boolean;
frontStatusGeneralIds: number[]; reservedTurns: boolean;
frontStatusNationIds: number[]; records: boolean;
frontStatusActorIds: number[]; frontStatus: boolean;
frontStatusChanged: boolean;
lobbyGeneralIds: number[];
lobbyChanged: boolean;
reservedGeneralIds: number[];
recordGeneralIds: number[];
worldChanged: boolean;
globalRecordsChanged: boolean;
worldHistoryChanged: boolean;
contactsChanged: boolean;
}> }>
) => ({ ) => ({
generalIds: [], context: false,
cityIds: [], lobby: false,
nationIds: [], map: false,
mapGeneralIds: [], commands: false,
mapCityIds: [], contacts: false,
mapNationIds: [], boardAccess: false,
frontStatusGeneralIds: [], reservedTurns: false,
frontStatusNationIds: [], records: false,
frontStatusActorIds: [], frontStatus: false,
frontStatusChanged: false,
lobbyGeneralIds: [],
lobbyChanged: false,
reservedGeneralIds: [],
recordGeneralIds: [],
worldChanged: false,
globalRecordsChanged: false,
worldHistoryChanged: false,
contactsChanged: false,
...overrides, ...overrides,
}); });
const emitReadModelChanges = (page: Page, changes: ReturnType<typeof readModelChanges>) => const emitReadModelInvalidation = (page: Page, invalidation: ReturnType<typeof readModelInvalidation>) =>
page.evaluate((payload) => { page.evaluate((payload) => {
(window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime( (window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime(
'readModelChanged', 'readModelInvalidated',
{ {
at: new Date().toISOString(), invalidation: payload,
revision: Date.now(),
changes: payload,
} }
); );
}, changes); }, invalidation);
const commandTableFixture = (large: boolean, blockedCount = 0) => ({ const commandTableFixture = (large: boolean, blockedCount = 0) => ({
general: large general: large
@@ -239,7 +220,7 @@ const generalContext = (state: NavigationFixture) => ({
dex: [350, 100_000, 500_000, 1_000_000, 1_275_975], dex: [350, 100_000, 500_000, 1_000_000, 1_275_975],
}, },
items: { horse: null, weapon: null, book: null, item: null }, items: { horse: null, weapon: null, book: null, item: null },
turnTime: '0185-01-01T00:00:00.000Z', turnTime: state.generalTurnTime ?? '0185-01-01T00:00:00.000Z',
}, },
city: { city: {
id: 1, id: 1,
@@ -666,6 +647,66 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
}); });
test('main general card renders the next turn in the Seoul server timezone', async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 0,
permission: 0,
nationLevel: 0,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
generalName: 'Administrator',
generalTurnTime: '2026-08-13T00:07:06.713Z',
currentYear: 179,
currentMonth: 8,
};
await installFixture(page, state);
await page.setViewportSize({ width: 1200, height: 900 });
await waitForMain(page);
const title = page.locator('[data-main-target="general"] .general-title').first();
await expect(title).toContainText('Administrator');
await expect(title).toContainText('다음 턴 09:07');
await expect(title).not.toContainText('00:07');
const desktopGeometry = await title.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
width: rect.width,
height: rect.height,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
overflow: style.overflow,
};
});
expect(desktopGeometry.width).toBeGreaterThan(0);
expect(desktopGeometry.height).toBeGreaterThan(0);
if (artifactRoot) {
const target = resolve(artifactRoot);
await mkdir(target, { recursive: true });
await Promise.all([
page.screenshot({ path: resolve(target, 'main-turn-time-seoul-desktop-1200.png'), fullPage: true }),
writeFile(
resolve(target, 'main-turn-time-seoul-desktop-1200.json'),
`${JSON.stringify(desktopGeometry, null, 2)}\n`
),
]);
}
await page.setViewportSize({ width: 500, height: 900 });
const mobileTitle = page.locator('[data-main-target="general"] .general-title').first();
await expect(mobileTitle).toContainText('다음 턴 09:07');
expect(await mobileTitle.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0);
if (artifactRoot) {
await page.screenshot({
path: resolve(artifactRoot, 'main-turn-time-seoul-mobile-500.png'),
fullPage: true,
});
}
});
test('pure NPC message senders are not rendered as reply targets', async ({ page }) => { test('pure NPC message senders are not rendered as reply targets', async ({ page }) => {
const target = (generalId: number, generalName: string) => ({ const target = (generalId: number, generalName: string) => ({
generalId, generalId,
@@ -1279,26 +1320,6 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
const callsBeforeRefresh = state.generalMeCalls; const callsBeforeRefresh = state.generalMeCalls;
const operationsBeforeClockOnly = state.operations.length; const operationsBeforeClockOnly = state.operations.length;
await page.evaluate(() => {
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
'turnCompleted',
{
at: new Date().toISOString(),
lastTurnTime: '0185-02-01T00:00:00.000Z',
changes: {
generalIds: [],
cityIds: [],
nationIds: [],
reservedGeneralIds: [],
recordGeneralIds: [],
worldChanged: false,
globalRecordsChanged: false,
worldHistoryChanged: false,
contactsChanged: false,
},
}
);
});
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
expect(state.operations.slice(operationsBeforeClockOnly)).toEqual([]); expect(state.operations.slice(operationsBeforeClockOnly)).toEqual([]);
@@ -1308,28 +1329,17 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }) const emit = (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void })
.__emitMainRealtime; .__emitMainRealtime;
for (let index = 0; index < 100; index += 1) { for (let index = 0; index < 100; index += 1) {
emit('turnCompleted', { emit('readModelInvalidated', {
at: new Date().toISOString(), invalidation: {
lastTurnTime: '0185-02-01T00:00:00.000Z', context: true,
changes: { lobby: false,
generalIds: [7], map: false,
cityIds: [], commands: true,
nationIds: [], contacts: false,
mapGeneralIds: [], boardAccess: true,
mapCityIds: [], reservedTurns: false,
mapNationIds: [], records: false,
frontStatusGeneralIds: [], frontStatus: false,
frontStatusNationIds: [],
frontStatusActorIds: [],
frontStatusChanged: false,
lobbyGeneralIds: [],
lobbyChanged: false,
reservedGeneralIds: [],
recordGeneralIds: [],
worldChanged: false,
globalRecordsChanged: false,
worldHistoryChanged: false,
contactsChanged: false,
}, },
}); });
} }
@@ -1374,29 +1384,18 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
const operationsBeforeSurvey = state.operations.length; const operationsBeforeSurvey = state.operations.length;
await page.evaluate(() => { await page.evaluate(() => {
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
'readModelChanged', 'readModelInvalidated',
{ {
at: new Date().toISOString(), invalidation: {
revision: 42, context: false,
changes: { lobby: false,
generalIds: [], map: false,
cityIds: [], commands: false,
nationIds: [], contacts: false,
mapGeneralIds: [], boardAccess: false,
mapCityIds: [], reservedTurns: false,
mapNationIds: [], records: false,
frontStatusGeneralIds: [], frontStatus: true,
frontStatusNationIds: [],
frontStatusActorIds: [],
frontStatusChanged: true,
lobbyGeneralIds: [],
lobbyChanged: false,
reservedGeneralIds: [],
recordGeneralIds: [],
worldChanged: false,
globalRecordsChanged: false,
worldHistoryChanged: false,
contactsChanged: false,
}, },
} }
); );
@@ -1471,7 +1470,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
{ op: 'replace', path: '/general/0/values/0/possible', value: false }, { op: 'replace', path: '/general/0/values/0/possible', value: false },
{ op: 'replace', path: '/general/0/values/0/status', value: 'blocked' }, { op: 'replace', path: '/general/0/values/0/status', value: 'blocked' },
]; ];
await emitReadModelChanges(page, readModelChanges({ cityIds: [1], mapCityIds: [] })); await emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true }));
await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeDefence + 1); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeDefence + 1);
await expect(page.locator('[data-city-progress="수비"] .city-progress__text')).toHaveText('900 / 2,000'); await expect(page.locator('[data-city-progress="수비"] .city-progress__text')).toHaveText('900 / 2,000');
@@ -1485,7 +1484,10 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
{ op: 'replace', path: '/general/0/values/1/possible', value: false }, { op: 'replace', path: '/general/0/values/1/possible', value: false },
{ op: 'replace', path: '/general/0/values/1/status', value: 'blocked' }, { op: 'replace', path: '/general/0/values/1/status', value: 'blocked' },
]; ];
await emitReadModelChanges(page, readModelChanges({ nationIds: [1], mapNationIds: [], frontStatusNationIds: [] })); await emitReadModelInvalidation(
page,
readModelInvalidation({ context: true, commands: true, boardAccess: true })
);
await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeTax + 1); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeTax + 1);
const callsBeforeCityState = state.generalMeCalls; const callsBeforeCityState = state.generalMeCalls;
@@ -1499,7 +1501,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
{ op: 'replace', path: '/general/0/values/2/possible', value: false }, { op: 'replace', path: '/general/0/values/2/possible', value: false },
{ op: 'replace', path: '/general/0/values/2/status', value: 'blocked' }, { op: 'replace', path: '/general/0/values/2/status', value: 'blocked' },
]; ];
await emitReadModelChanges(page, readModelChanges({ cityIds: [1], mapCityIds: [1] })); await emitReadModelInvalidation(page, readModelInvalidation({ context: true, map: true, commands: true }));
await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeCityState + 1); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeCityState + 1);
await expect(page.locator('.city-base .city-state img')).toHaveAttribute('src', /event5\.gif$/u); await expect(page.locator('.city-base .city-state img')).toHaveAttribute('src', /event5\.gif$/u);
expect(state.operations.slice(operationsBeforeCityState).sort()).toEqual( expect(state.operations.slice(operationsBeforeCityState).sort()).toEqual(
@@ -1512,7 +1514,10 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
state.contextRevision = 'O'.repeat(22); state.contextRevision = 'O'.repeat(22);
state.contextOperations = [{ op: 'replace', path: '/missing/value', value: 'invalid-delta' }]; state.contextOperations = [{ op: 'replace', path: '/missing/value', value: 'invalid-delta' }];
state.commandTableOperations = []; state.commandTableOperations = [];
await emitReadModelChanges(page, readModelChanges({ generalIds: [7] })); await emitReadModelInvalidation(
page,
readModelInvalidation({ context: true, commands: true, boardAccess: true })
);
await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeFallback + 2); await expect.poll(() => state.generalMeCalls, { timeout: 4_000 }).toBe(callsBeforeFallback + 2);
expect(state.forceSnapshotCalls).toBe(forcedBeforeFallback + 1); expect(state.forceSnapshotCalls).toBe(forcedBeforeFallback + 1);
await expect(page.locator('.general-title')).toContainText('snapshot복구장수'); await expect(page.locator('.general-title')).toContainText('snapshot복구장수');
@@ -1541,29 +1546,18 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
const callsAfterLeavingMain = state.generalMeCalls; const callsAfterLeavingMain = state.generalMeCalls;
await page.evaluate(() => { await page.evaluate(() => {
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
'turnCompleted', 'readModelInvalidated',
{ {
at: new Date().toISOString(), invalidation: {
lastTurnTime: '0185-02-01T00:00:00.000Z', context: true,
changes: { lobby: false,
generalIds: [7], map: false,
cityIds: [], commands: true,
nationIds: [], contacts: false,
mapGeneralIds: [], boardAccess: true,
mapCityIds: [], reservedTurns: false,
mapNationIds: [], records: false,
frontStatusGeneralIds: [], frontStatus: false,
frontStatusNationIds: [],
frontStatusActorIds: [],
frontStatusChanged: false,
lobbyGeneralIds: [],
lobbyChanged: false,
reservedGeneralIds: [],
recordGeneralIds: [],
worldChanged: false,
globalRecordsChanged: false,
worldHistoryChanged: false,
contactsChanged: false,
}, },
} }
); );
@@ -1597,7 +1591,7 @@ test('global activity, world history, and a month boundary refresh their visible
{ id: 3, text: '장수 동향 기록' }, { id: 3, text: '장수 동향 기록' },
]; ];
const operationsBeforeGlobal = state.operations.length; const operationsBeforeGlobal = state.operations.length;
await emitReadModelChanges(page, readModelChanges({ globalRecordsChanged: true })); await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
await expect(page.locator('[data-main-target="global-records"]')).toContainText('자동 갱신된 장수 동향'); await expect(page.locator('[data-main-target="global-records"]')).toContainText('자동 갱신된 장수 동향');
expect(state.operations.slice(operationsBeforeGlobal)).toEqual(['general.getRecentRecords']); expect(state.operations.slice(operationsBeforeGlobal)).toEqual(['general.getRecentRecords']);
@@ -1606,24 +1600,22 @@ test('global activity, world history, and a month boundary refresh their visible
{ id: 1, text: '중원 정세 기록' }, { id: 1, text: '중원 정세 기록' },
]; ];
const operationsBeforeHistory = state.operations.length; const operationsBeforeHistory = state.operations.length;
await emitReadModelChanges(page, readModelChanges({ worldHistoryChanged: true })); await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
await expect(page.locator('[data-main-target="world-history"]')).toContainText('자동 갱신된 중원 정세'); await expect(page.locator('[data-main-target="world-history"]')).toContainText('자동 갱신된 중원 정세');
expect(state.operations.slice(operationsBeforeHistory)).toEqual(['general.getRecentRecords']); expect(state.operations.slice(operationsBeforeHistory)).toEqual(['general.getRecentRecords']);
state.currentMonth = 2; state.currentMonth = 2;
const operationsBeforeMonth = state.operations.length; const operationsBeforeMonth = state.operations.length;
await page.evaluate( await page.evaluate(
(changes) => { (invalidation) => {
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
'turnCompleted', 'readModelInvalidated',
{ {
at: new Date().toISOString(), invalidation,
lastTurnTime: '0185-02-01T00:00:00.000Z',
changes,
} }
); );
}, },
readModelChanges({ worldChanged: true }) readModelInvalidation({ lobby: true, map: true, commands: true })
); );
await expect(page.getByText('현재: 185년 2월')).toBeVisible(); await expect(page.getByText('현재: 185년 2월')).toBeVisible();
await expect(page.locator('.map-viewer')).toContainText('185年 2月'); await expect(page.locator('.map-viewer')).toContainText('185年 2月');
@@ -1689,29 +1681,18 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
state.generalName = '탭공유갱신장수'; state.generalName = '탭공유갱신장수';
await leaderPage.evaluate(() => { await leaderPage.evaluate(() => {
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
'readModelChanged', 'readModelInvalidated',
{ {
at: new Date().toISOString(), invalidation: {
revision: 100, context: true,
changes: { lobby: false,
generalIds: [7], map: false,
cityIds: [], commands: true,
nationIds: [], contacts: false,
mapGeneralIds: [], boardAccess: true,
mapCityIds: [], reservedTurns: false,
mapNationIds: [], records: false,
frontStatusGeneralIds: [], frontStatus: false,
frontStatusNationIds: [],
frontStatusActorIds: [],
frontStatusChanged: false,
lobbyGeneralIds: [],
lobbyChanged: false,
reservedGeneralIds: [],
recordGeneralIds: [],
worldChanged: false,
globalRecordsChanged: false,
worldHistoryChanged: false,
contactsChanged: false,
}, },
} }
); );
@@ -1727,29 +1708,18 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
state.generalName = '리더만갱신장수'; state.generalName = '리더만갱신장수';
await leaderPage.evaluate(() => { await leaderPage.evaluate(() => {
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime( (window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
'readModelChanged', 'readModelInvalidated',
{ {
at: new Date().toISOString(), invalidation: {
revision: 101, context: true,
changes: { lobby: false,
generalIds: [7], map: false,
cityIds: [], commands: true,
nationIds: [], contacts: false,
mapGeneralIds: [], boardAccess: true,
mapCityIds: [], reservedTurns: false,
mapNationIds: [], records: false,
frontStatusGeneralIds: [], frontStatus: false,
frontStatusNationIds: [],
frontStatusActorIds: [],
frontStatusChanged: false,
lobbyGeneralIds: [],
lobbyChanged: false,
reservedGeneralIds: [],
recordGeneralIds: [],
worldChanged: false,
globalRecordsChanged: false,
worldHistoryChanged: false,
contactsChanged: false,
}, },
} }
); );
+11
View File
@@ -41,6 +41,7 @@ const installArchive = async (page: Page) => {
experience: 23000, experience: 23000,
dedication: 1200, dedication: 1200,
officerLevel: 12, officerLevel: 12,
officerLevelText: '황제',
personal: '대담', personal: '대담',
special: '상재', special: '상재',
special2: '신산', special2: '신산',
@@ -66,6 +67,15 @@ const installArchive = async (page: Page) => {
}); });
}; };
test('지난 플레이 관직은 숫자 대신 저장된 Ref 표시명으로 나타난다', async ({ page }) => {
await installArchive(page);
await page.goto('past-plays');
const generalRow = page.locator('tbody tr').filter({ hasText: '관우' });
await expect(generalRow).toContainText('황제');
await expect(generalRow).not.toContainText('che_');
});
test('past plays is available without a current general and preserves desktop interaction geometry', async ({ test('past plays is available without a current general and preserves desktop interaction geometry', async ({
page, page,
}) => { }) => {
@@ -78,6 +88,7 @@ test('past plays is available without a current general and preserves desktop in
await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible(); await expect(page.getByRole('heading', { name: '내 지난 플레이 보기' })).toBeVisible();
await expect(page.getByText('천하쟁패 · 51기')).toBeVisible(); await expect(page.getByText('천하쟁패 · 51기')).toBeVisible();
await expect(page.locator('.general-name')).toHaveText('관우'); await expect(page.locator('.general-name')).toHaveText('관우');
await expect(page.locator('tbody tr').filter({ hasText: '관우' })).toContainText('황제');
await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute('href', gamePath('/dynasty/7')); await expect(page.getByRole('link', { name: '이 기수 국가 정보' })).toHaveAttribute('href', gamePath('/dynasty/7'));
const historyToggle = page.locator('.history-toggle'); const historyToggle = page.locator('.history-toggle');
await expect(historyToggle).toHaveText('보기 (2)'); await expect(historyToggle).toHaveText('보기 (2)');
@@ -230,7 +230,7 @@ test.describe('scenario 903 live selection pool', () => {
expect(geometry.footerBanner.height).toBeCloseTo(20.1875, 3); expect(geometry.footerBanner.height).toBeCloseTo(20.1875, 3);
await expect(page.locator('.invitation-table tbody tr')).toHaveCount(0); await expect(page.locator('.invitation-table tbody tr')).toHaveCount(0);
await expect(page.locator('.footer-banner')).toContainText( await expect(page.locator('.footer-banner')).toContainText(
'삼국지 모의전투 HiDCHe core2026' '삼국지 모의전투 HiDCHe'
); );
await expect(page.locator('.footer-banner a')).toHaveText('Credit'); await expect(page.locator('.footer-banner a')).toHaveText('Credit');
+1 -1
View File
@@ -56,7 +56,7 @@
"autoprefixer": "^10.5.4", "autoprefixer": "^10.5.4",
"postcss": "8.5.26", "postcss": "8.5.26",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "6.0.2", "typescript": "6.0.3",
"vite": "^8.2.1", "vite": "^8.2.1",
"vue-tsc": "^3.3.9" "vue-tsc": "^3.3.9"
} }
@@ -8,7 +8,10 @@ interface CityInfo {
id: number; id: number;
name: string; name: string;
level: number; level: number;
levelName: string;
regionName: string;
nationId: number; nationId: number;
nationName: string;
population: number; population: number;
populationMax: number; populationMax: number;
agriculture: number; agriculture: number;
@@ -66,8 +69,8 @@ const metrics = computed(() => {
<div v-else-if="!props.city" class="empty">도시 정보를 불러오지 못했습니다.</div> <div v-else-if="!props.city" class="empty">도시 정보를 불러오지 못했습니다.</div>
<div v-else class="city-body"> <div v-else class="city-body">
<div class="title"> <div class="title">
{{ props.city.name }} (Lv {{ props.city.level }}) · 국가 {{ props.city.nationId || '무주' }} · 보급 {{ props.city.regionName }} | {{ props.city.levelName }} {{ props.city.name }} ·
{{ props.city.supplyState }} · 전방 {{ props.city.frontState }} {{ props.city.nationId > 0 ? `지배 국가 【 ${props.city.nationName}` : '공 백 지' }}
</div> </div>
<div class="progress-grid"> <div class="progress-grid">
<div <div
@@ -2,6 +2,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import SkeletonLines from '../ui/SkeletonLines.vue'; import SkeletonLines from '../ui/SkeletonLines.vue';
import LegacyProgressBar from '../ui/LegacyProgressBar.vue'; import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
import { formatSeoulHourMinute } from '../../utils/legacyDateTime';
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress'; import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
interface GeneralStats { interface GeneralStats {
@@ -13,6 +14,7 @@ interface GeneralStats {
interface GeneralProgression { interface GeneralProgression {
experienceLevel: number; experienceLevel: number;
dedicationLevel: number; dedicationLevel: number;
dedicationText?: string;
statExperience?: { leadership: number; strength: number; intelligence: number }; statExperience?: { leadership: number; strength: number; intelligence: number };
statUpgradeLimit?: number; statUpgradeLimit?: number;
} }
@@ -22,6 +24,7 @@ interface GeneralInfo {
name: string; name: string;
npcState: number; npcState: number;
officerLevel: number; officerLevel: number;
officerLevelText: string;
stats: GeneralStats; stats: GeneralStats;
gold: number; gold: number;
rice: number; rice: number;
@@ -34,6 +37,7 @@ interface GeneralInfo {
age?: number; age?: number;
turnTime?: string; turnTime?: string;
crewTypeId?: number; crewTypeId?: number;
crewTypeName?: string;
traits?: { personal: string; specialWar: string; specialDomestic: string }; traits?: { personal: string; specialWar: string; specialDomestic: string };
progression?: GeneralProgression; progression?: GeneralProgression;
} }
@@ -78,9 +82,9 @@ const experiencePercent = computed(() =>
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div> <div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
<div v-else class="general-body"> <div v-else class="general-body">
<div class="general-title"> <div class="general-title">
{{ props.general.name }} · 관직 {{ props.general.officerLevel }} · {{ props.general.age ?? '-' }} · {{ props.general.name }} · {{ props.general.officerLevelText }} · {{ props.general.age ?? '-' }} ·
다음 다음
{{ props.general.turnTime?.slice(11, 16) ?? '-' }} {{ props.general.turnTime ? formatSeoulHourMinute(props.general.turnTime) : '-' }}
</div> </div>
<div class="stat-progress-grid"> <div class="stat-progress-grid">
@@ -102,11 +106,11 @@ const experiencePercent = computed(() =>
><strong>{{ props.general.crew.toLocaleString() }}</strong> <span>훈련</span ><strong>{{ props.general.crew.toLocaleString() }}</strong> <span>훈련</span
><strong>{{ props.general.train }}</strong> <span>사기</span><strong>{{ props.general.atmos }}</strong> ><strong>{{ props.general.train }}</strong> <span>사기</span><strong>{{ props.general.atmos }}</strong>
<span>부상</span><strong>{{ props.general.injury }}</strong> <span>병종</span <span>부상</span><strong>{{ props.general.injury }}</strong> <span>병종</span
><strong>{{ props.general.crewTypeId || '-' }}</strong> <span>성격</span ><strong>{{ props.general.crewTypeName ?? '-' }}</strong> <span>성격</span
><strong>{{ props.general.traits?.personal ?? '-' }}</strong> <span>전투특기</span ><strong>{{ props.general.traits?.personal ?? '-' }}</strong> <span>전투특기</span
><strong>{{ props.general.traits?.specialWar ?? '-' }}</strong> <span>내정특기</span ><strong>{{ props.general.traits?.specialWar ?? '-' }}</strong> <span>내정특기</span
><strong>{{ props.general.traits?.specialDomestic ?? '-' }}</strong> <span>계급</span ><strong>{{ props.general.traits?.specialDomestic ?? '-' }}</strong> <span>계급</span
><strong>Lv {{ props.general.progression?.dedicationLevel ?? 0 }}</strong> <span>공헌</span ><strong>{{ props.general.progression?.dedicationText ?? '무품관' }}</strong> <span>공헌</span
><strong>{{ props.general.dedication.toLocaleString() }}</strong> ><strong>{{ props.general.dedication.toLocaleString() }}</strong>
</div> </div>
@@ -7,11 +7,14 @@ interface NationInfo {
name: string; name: string;
color: string; color: string;
level: number; level: number;
levelName: string;
gold: number; gold: number;
rice: number; rice: number;
tech: number; tech: number;
typeCode: string; typeCode: string;
typeName: string;
capitalCityId: number | null; capitalCityId: number | null;
capitalCityName: string | null;
} }
const props = defineProps<{ const props = defineProps<{
@@ -31,7 +34,7 @@ const props = defineProps<{
class="title" class="title"
:style="{ backgroundColor: props.nation.color, color: legacyNationTextColor(props.nation.color) }" :style="{ backgroundColor: props.nation.color, color: legacyNationTextColor(props.nation.color) }"
> >
{{ props.nation.name }}<template v-if="props.nation.id > 0"> (Lv {{ props.nation.level }})</template> {{ props.nation.name }}<template v-if="props.nation.id > 0"> ({{ props.nation.levelName }})</template>
</div> </div>
<div class="grid"> <div class="grid">
<span>국고</span <span>국고</span
@@ -40,10 +43,11 @@ const props = defineProps<{
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.rice.toLocaleString() }}</strong> ><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.rice.toLocaleString() }}</strong>
<span>기술</span <span>기술</span
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.tech.toLocaleString() }}</strong> ><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.tech.toLocaleString() }}</strong>
<span>체제</span><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.typeCode }}</strong> <span>체제</span><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.typeName }}</strong>
<span>수도</span <span>수도</span
><strong>{{ props.nation.id === 0 ? '해당 없음' : (props.nation.capitalCityId ?? '-') }}</strong> ><strong>{{ props.nation.id === 0 ? '해당 없음' : (props.nation.capitalCityName ?? '-') }}</strong>
<span>국가 등급</span><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.level }}</strong> <span>국가 등급</span
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.levelName }}</strong>
</div> </div>
</div> </div>
</div> </div>
+28 -50
View File
@@ -4,8 +4,8 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType
import { import {
applyReadModelDelta, applyReadModelDelta,
cloneReadModelJson, cloneReadModelJson,
type RealtimeEvent, type PublicRealtimeEvent,
type RealtimeReadModelChanges, type RealtimeReadModelInvalidation,
} from '@sammo-ts/common'; } from '@sammo-ts/common';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { useMapViewerStore } from './mapViewer'; import { useMapViewerStore } from './mapViewer';
@@ -13,7 +13,7 @@ import { useSessionStore } from './session';
import { createLatestRefreshQueue } from '../utils/latestRefreshQueue'; import { createLatestRefreshQueue } from '../utils/latestRefreshQueue';
import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue'; import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue';
import { structurallyShare } from '../utils/structuralShare'; import { structurallyShare } from '../utils/structuralShare';
import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../utils/dashboardReadModel'; import { createMergedReadModelRefreshQueue } from '../utils/dashboardReadModel';
import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator'; import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator';
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery'; import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
@@ -612,16 +612,11 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
} }
); );
const refreshChangedReadModels = async (changes: RealtimeReadModelChanges) => { const refreshChangedReadModels = async (plan: RealtimeReadModelInvalidation) => {
const id = generalId.value; const id = generalId.value;
if (!id) { if (!id) {
return; return;
} }
const plan = resolveDashboardRefreshPlan(changes, {
generalId: id,
cityId: city.value?.id ?? null,
nationId: nation.value?.id ?? null,
});
if (!Object.values(plan).some(Boolean)) { if (!Object.values(plan).some(Boolean)) {
return; return;
} }
@@ -944,12 +939,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
return url.toString(); return url.toString();
}; };
const parseRealtimePayload = (raw: MessageEvent): RealtimeEvent | null => { const parseRealtimePayload = (raw: MessageEvent): PublicRealtimeEvent | null => {
if (!raw.data || typeof raw.data !== 'string') { if (!raw.data || typeof raw.data !== 'string') {
return null; return null;
} }
try { try {
const parsed = JSON.parse(raw.data) as RealtimeEvent; const parsed = JSON.parse(raw.data) as PublicRealtimeEvent;
if (!parsed || typeof parsed !== 'object') { if (!parsed || typeof parsed !== 'object') {
return null; return null;
} }
@@ -962,21 +957,6 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
} }
}; };
const isMailboxRelevant = (mailbox: number): boolean => {
if (mailbox === MESSAGE_MAILBOX_PUBLIC) {
return true;
}
const currentGeneralId = generalId.value;
if (currentGeneralId && mailbox === currentGeneralId) {
return true;
}
const currentNationId = nationId.value;
if (currentNationId && mailbox === MESSAGE_MAILBOX_NATIONAL_BASE + currentNationId) {
return true;
}
return false;
};
const closeRealtimeSource = () => { const closeRealtimeSource = () => {
if (!realtimeSource) { if (!realtimeSource) {
return; return;
@@ -1095,36 +1075,34 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused'; realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' }); realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' });
}); });
source.addEventListener('turnCompleted', (event) => { source.addEventListener('readModelInvalidated', (event) => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
const payload = parseRealtimePayload(event); const payload = parseRealtimePayload(event);
if (!payload || payload.type !== 'turnCompleted') { if (!payload || payload.type !== 'readModelInvalidated') {
return; return;
} }
if (!payload.changes) { readModelRefreshQueue.request(payload.invalidation);
// Rolling deployment fallback for an older daemon. });
source.addEventListener('messagesInvalidated', (event) => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
const payload = parseRealtimePayload(event);
if (!payload || payload.type !== 'messagesInvalidated') {
return;
}
void refreshMessages();
});
// Rolling deployment fallback: an older API may still expose internal
// events. Do not inspect their payload; use the bounded full refresh.
for (const legacyEventType of ['turnCompleted', 'readModelChanged'] as const) {
source.addEventListener(legacyEventType, () => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
realtimeRefreshQueue.request(); realtimeRefreshQueue.request();
return; });
} }
readModelRefreshQueue.request(payload.changes); source.addEventListener('messageCreated', () => {
});
source.addEventListener('readModelChanged', (event) => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return; if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
const payload = parseRealtimePayload(event); void refreshMessages();
if (!payload || payload.type !== 'readModelChanged') {
return;
}
readModelRefreshQueue.request(payload.changes);
});
source.addEventListener('messageCreated', (event) => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
const payload = parseRealtimePayload(event);
if (!payload || payload.type !== 'messageCreated') {
return;
}
if (isMailboxRelevant(payload.mailbox)) {
void refreshMessages();
}
}); });
source.addEventListener('ping', () => { source.addEventListener('ping', () => {
if (realtimeEnabled.value) { if (realtimeEnabled.value) {
@@ -1,85 +1,29 @@
import { import {
createEmptyRealtimeReadModelChanges, createEmptyRealtimeReadModelInvalidation,
mergeRealtimeReadModelChanges, mergeRealtimeReadModelInvalidations,
resolveRealtimeReadModelInvalidation,
type RealtimeReadModelChanges, type RealtimeReadModelChanges,
type RealtimeReadModelInvalidation,
type RealtimeViewerIdentity,
} from '@sammo-ts/common'; } from '@sammo-ts/common';
export interface DashboardReadModelIdentity { export type DashboardReadModelIdentity = RealtimeViewerIdentity;
generalId: number | null; export type DashboardRefreshPlan = RealtimeReadModelInvalidation;
cityId: number | null;
nationId: number | null;
}
export interface DashboardRefreshPlan {
context: boolean;
lobby: boolean;
map: boolean;
commands: boolean;
contacts: boolean;
boardAccess: boolean;
reservedTurns: boolean;
records: boolean;
frontStatus: boolean;
}
const contains = (ids: readonly number[], id: number | null): boolean => id !== null && ids.includes(id);
export const resolveDashboardRefreshPlan = ( export const resolveDashboardRefreshPlan = (
changes: RealtimeReadModelChanges, changes: RealtimeReadModelChanges,
identity: DashboardReadModelIdentity identity: DashboardReadModelIdentity
): DashboardRefreshPlan => { ): DashboardRefreshPlan => resolveRealtimeReadModelInvalidation(changes, identity);
const ownGeneralChanged = contains(changes.generalIds, identity.generalId);
const ownCityChanged = contains(changes.cityIds, identity.cityId);
const ownNationChanged = contains(changes.nationIds, identity.nationId);
const ownFrontStatusNationChanged = contains(
changes.frontStatusNationIds ?? changes.nationIds,
identity.nationId
);
const ownGeneralMapChanged = contains(changes.mapGeneralIds ?? changes.generalIds, identity.generalId);
const frontStatusGeneralChanged =
changes.frontStatusGeneralIds !== undefined
? changes.frontStatusGeneralIds.length > 0
: changes.contactsChanged;
const ownFrontStatusActorChanged = contains(changes.frontStatusActorIds ?? [], identity.generalId);
const ownLobbyGeneralChanged = contains(changes.lobbyGeneralIds ?? changes.generalIds, identity.generalId);
const lobbyChanged = changes.lobbyChanged ?? changes.contactsChanged;
const entityContextChanged = ownGeneralChanged || ownCityChanged || ownNationChanged;
const mapEntitiesChanged =
(changes.mapCityIds ?? changes.cityIds).length > 0 ||
(changes.mapNationIds ?? changes.nationIds).length > 0;
const commandEntitiesChanged = changes.cityIds.length > 0 || changes.nationIds.length > 0;
return {
context: entityContextChanged,
lobby: changes.worldChanged || lobbyChanged || ownLobbyGeneralChanged,
map: changes.worldChanged || mapEntitiesChanged || ownGeneralMapChanged,
commands: changes.worldChanged || commandEntitiesChanged || ownGeneralChanged,
contacts: changes.contactsChanged,
boardAccess: ownGeneralChanged || ownNationChanged,
reservedTurns: contains(changes.reservedGeneralIds, identity.generalId),
records:
changes.globalRecordsChanged ||
changes.worldHistoryChanged ||
contains(changes.recordGeneralIds, identity.generalId),
// lastTurnTime is intentionally excluded. This slice contains the
// nation notice/vote/presence model and only follows related changes.
frontStatus:
Boolean(changes.frontStatusChanged) ||
frontStatusGeneralChanged ||
ownFrontStatusNationChanged ||
ownFrontStatusActorChanged,
};
};
type TimerHandle = ReturnType<typeof setTimeout>; type TimerHandle = ReturnType<typeof setTimeout>;
export interface MergedReadModelRefreshQueue { export interface MergedReadModelRefreshQueue {
request(changes: RealtimeReadModelChanges): void; request(invalidation: RealtimeReadModelInvalidation): void;
cancelPending(): void; cancelPending(): void;
} }
export const createMergedReadModelRefreshQueue = ( export const createMergedReadModelRefreshQueue = (
refresh: (changes: RealtimeReadModelChanges) => Promise<void>, refresh: (invalidation: RealtimeReadModelInvalidation) => Promise<void>,
options: { options: {
minIntervalMs?: number; minIntervalMs?: number;
now?: () => number; now?: () => number;
@@ -91,7 +35,7 @@ export const createMergedReadModelRefreshQueue = (
const now = options.now ?? Date.now; const now = options.now ?? Date.now;
const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)); const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle)); const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
let pending = createEmptyRealtimeReadModelChanges(); let pending = createEmptyRealtimeReadModelInvalidation();
let hasPending = false; let hasPending = false;
let running = false; let running = false;
let timer: TimerHandle | null = null; let timer: TimerHandle | null = null;
@@ -108,7 +52,7 @@ export const createMergedReadModelRefreshQueue = (
return; return;
} }
const next = pending; const next = pending;
pending = createEmptyRealtimeReadModelChanges(); pending = createEmptyRealtimeReadModelInvalidation();
hasPending = false; hasPending = false;
running = true; running = true;
lastStartedAt = now(); lastStartedAt = now();
@@ -120,14 +64,14 @@ export const createMergedReadModelRefreshQueue = (
}; };
return { return {
request: (changes) => { request: (invalidation) => {
pending = hasPending ? mergeRealtimeReadModelChanges(pending, changes) : changes; pending = hasPending ? mergeRealtimeReadModelInvalidations(pending, invalidation) : invalidation;
hasPending = true; hasPending = true;
schedule(); schedule();
}, },
cancelPending: () => { cancelPending: () => {
hasPending = false; hasPending = false;
pending = createEmptyRealtimeReadModelChanges(); pending = createEmptyRealtimeReadModelInvalidation();
if (timer !== null) { if (timer !== null) {
clearTimer(timer); clearTimer(timer);
timer = null; timer = null;
@@ -20,3 +20,5 @@ export const formatSeoulDateTime = (value: string | Date): string => {
koreaTime.getUTCSeconds() koreaTime.getUTCSeconds()
)}`; )}`;
}; };
export const formatSeoulHourMinute = (value: string | Date): string => formatSeoulDateTime(value).slice(11, 16);
+18 -7
View File
@@ -14,6 +14,19 @@ export const officerLevelMapDefault: Record<number, string> = {
0: '재야', 0: '재야',
}; };
export const nationLevelMap: Record<number, string> = {
7: '황제',
6: '왕',
5: '공',
4: '주목',
3: '주자사',
2: '군벌',
1: '호족',
0: '방랑군',
};
export const formatNationLevelText = (nationLevel: number): string => nationLevelMap[nationLevel] ?? '-';
export const officerLevelMapByNationLevel: Record<number, Record<number, string>> = { export const officerLevelMapByNationLevel: Record<number, Record<number, string>> = {
7: { 7: {
12: '황제', 12: '황제',
@@ -75,15 +88,13 @@ export const officerLevelMapByNationLevel: Record<number, Record<number, string>
export const formatOfficerLevelText = (officerLevel: number, nationLevel?: number): string => { export const formatOfficerLevelText = (officerLevel: number, nationLevel?: number): string => {
if (officerLevel < 5) { if (officerLevel < 5) {
return officerLevelMapDefault[officerLevel] ?? '???'; return officerLevelMapDefault[officerLevel] ?? '-';
} }
const nationMap = if (nationLevel === undefined) {
nationLevel === undefined return officerLevelMapDefault[officerLevel] ?? '-';
? officerLevelMapDefault }
: (officerLevelMapByNationLevel[nationLevel] ?? officerLevelMapDefault); return officerLevelMapByNationLevel[nationLevel]?.[officerLevel] ?? '-';
return nationMap[officerLevel] ?? (officerLevelMapDefault[officerLevel] ?? '???');
}; };
export const regionMap: Record<number, string> = { export const regionMap: Record<number, string> = {
+5 -3
View File
@@ -38,6 +38,8 @@ const resolveErrorMessage = (value: unknown): string => {
}; };
const formatNumber = (value: number | null | undefined): string => (value ?? 0).toLocaleString(); const formatNumber = (value: number | null | undefined): string => (value ?? 0).toLocaleString();
const displayCode = (value: string | null | undefined): string =>
!value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, '');
const cutDateTime = (value: string | null | undefined, showSecond = false): string => { const cutDateTime = (value: string | null | undefined, showSecond = false): string => {
if (!value) { if (!value) {
return '-'; return '-';
@@ -395,7 +397,7 @@ onMounted(() => {
<h2 class="section-title bg2">경매 {{ uniqueDetail.auction.id }} 상세</h2> <h2 class="section-title bg2">경매 {{ uniqueDetail.auction.id }} 상세</h2>
<dl class="detail-grid"> <dl class="detail-grid">
<dt class="bg1">경매명</dt> <dt class="bg1">경매명</dt>
<dd>{{ uniqueDetail.auction.detail.title ?? uniqueDetail.auction.targetCode }}</dd> <dd>{{ uniqueDetail.auction.detail.title ?? displayCode(uniqueDetail.auction.targetCode) }}</dd>
<dt class="bg1">주최자(익명)</dt> <dt class="bg1">주최자(익명)</dt>
<dd :class="{ 'is-me': uniqueDetail.auction.isCallerHost }">{{ uniqueDetail.auction.hostName }}</dd> <dd :class="{ 'is-me': uniqueDetail.auction.isCallerHost }">{{ uniqueDetail.auction.hostName }}</dd>
<dt class="bg1">종료일시</dt> <dt class="bg1">종료일시</dt>
@@ -442,7 +444,7 @@ onMounted(() => {
@keydown.space.prevent="selectUnique(auction)" @keydown.space.prevent="selectUnique(auction)"
> >
<span>{{ auction.id }}</span <span>{{ auction.id }}</span
><span>{{ auction.detail.title ?? auction.targetCode }}</span> ><span>{{ auction.detail.title ?? displayCode(auction.targetCode) }}</span>
<span :class="{ 'is-me': auction.isCallerHost }">{{ auction.hostName }}</span> <span :class="{ 'is-me': auction.isCallerHost }">{{ auction.hostName }}</span>
<span class="tnum">{{ cutDateTime(auction.closeAt) }}</span> <span class="tnum">{{ cutDateTime(auction.closeAt) }}</span>
<span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span> <span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span>
@@ -474,7 +476,7 @@ onMounted(() => {
@keydown.space.prevent="selectUnique(auction)" @keydown.space.prevent="selectUnique(auction)"
> >
<span>{{ auction.id }}</span <span>{{ auction.id }}</span
><span>{{ auction.detail.title ?? auction.targetCode }}</span> ><span>{{ auction.detail.title ?? displayCode(auction.targetCode) }}</span>
<span :class="{ 'is-me': auction.isCallerHost }">{{ auction.hostName }}</span> <span :class="{ 'is-me': auction.isCallerHost }">{{ auction.hostName }}</span>
<span class="tnum">{{ cutDateTime(auction.closeAt) }}</span> <span class="tnum">{{ cutDateTime(auction.closeAt) }}</span>
<span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span> <span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span>
@@ -269,7 +269,7 @@ onMounted(() => {
<SkeletonLines v-if="loading" :lines="5" /> <SkeletonLines v-if="loading" :lines="5" />
<div v-else-if="selectedGeneral" class="battle-general-card"> <div v-else-if="selectedGeneral" class="battle-general-card">
<div class="battle-general-name"> <div class="battle-general-name">
{{ selectedGeneral.name }} (관직 {{ selectedGeneral.officerLevel }}) {{ selectedGeneral.name }} ({{ selectedGeneral.officerLevelText }})
</div> </div>
<span <span
class="battle-general-portrait" class="battle-general-portrait"
@@ -293,9 +293,9 @@ onMounted(() => {
</div> </div>
<div class="battle-general-extra"> <div class="battle-general-extra">
<span>명성</span><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong> <span>명성</span><strong>{{ selectedGeneral.experience.toLocaleString('ko-KR') }}</strong>
<span>계급</span><strong>{{ selectedGeneral.dedication.toLocaleString('ko-KR') }}</strong> <span>계급</span><strong>{{ selectedGeneral.progression.dedicationText }}</strong>
<span>나이</span><strong>{{ selectedGeneral.age }}</strong> <span>병종</span <span>나이</span><strong>{{ selectedGeneral.age }}</strong> <span>병종</span
><strong>{{ selectedGeneral.crewTypeId }}</strong> <span>승리</span ><strong>{{ selectedGeneral.crewTypeName }}</strong> <span>승리</span
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span ><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span
><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>사살</span ><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>사살</span
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong> ><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
@@ -158,7 +158,7 @@ watch(viewMode, () => {
<button class="legacy-button" type="button" @click="closePage"> 닫기</button> <button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</div> </div>
<footer class="legacy-banner"> <footer class="legacy-banner">
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD / 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a> <a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</footer> </footer>
</main> </main>
+1 -1
View File
@@ -248,7 +248,7 @@ const placeBet = async (targetId: number) => {
<button class="close-button" type="button" @click="navigate"> 닫기</button> <button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink> </RouterLink>
<small> <small>
삼국지 모의전투 PHP HiDCHe -unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / Credit HideD(hided62@gmail.com) / Credit
</small> </small>
</footer> </footer>
@@ -321,7 +321,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
</tr> </tr>
<tr> <tr>
<td class="legacy-banner"> <td class="legacy-banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / HideD(hided62@gmail.com) /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a> <a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</td> </td>
@@ -621,7 +621,7 @@ onBeforeUnmount(() => {
<td> <td>
<button class="legacy-button legacy-button--primary" type="button" @click="router.push('/')">돌아가기</button <button class="legacy-button legacy-button--primary" type="button" @click="router.push('/')">돌아가기</button
><br /><br /> ><br /><br />
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / HideD(hided62@gmail.com) /
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a> <a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</td> </td>
@@ -289,7 +289,7 @@ onMounted(loadDetail);
</tr> </tr>
<tr> <tr>
<td class="banner"> <td class="banner">
삼국지 모의전투 TypeScript core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -144,7 +144,7 @@ onMounted(loadDynasty);
</tr> </tr>
<tr> <tr>
<td class="banner"> <td class="banner">
삼국지 모의전투 TypeScript core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -174,7 +174,7 @@ onMounted(loadOptions);
<button class="legacy-button" type="button" @click="closePage"> 닫기</button> <button class="legacy-button" type="button" @click="closePage"> 닫기</button>
</div> </div>
<footer class="legacy-banner"> <footer class="legacy-banner">
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD / 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a> <a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</footer> </footer>
</main> </main>
+40 -12
View File
@@ -132,12 +132,40 @@ const noDefencePenaltyWaived = computed(() => {
); );
}); });
const noDefenceLabel = computed(() => (noDefencePenaltyWaived.value ? '×' : '× [훈련 -3,사기 -6]')); const noDefenceLabel = computed(() => (noDefencePenaltyWaived.value ? '×' : '× [훈련 -3,사기 -6]'));
const items = computed<Array<{ key: ItemSlotKey; name: string; code: string | null }>>(() => [ const fallbackDisplayCode = (value: string | null | undefined): string | null =>
{ key: 'horse', name: '', code: data.value?.general.items.horse ?? null }, value && !/^\d+$/u.test(value) ? value.replace(/^che_(?:event_)?/u, '') : null;
{ key: 'weapon', name: '무기', code: data.value?.general.items.weapon ?? null }, const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName: string | null; code: string | null }>>(
{ key: 'book', name: '서적', code: data.value?.general.items.book ?? null }, () => [
{ key: 'item', name: '도구', code: data.value?.general.items.item ?? null }, {
]); key: 'horse',
slotName: '말',
displayName:
data.value?.general.itemNames?.horse ?? fallbackDisplayCode(data.value?.general.items.horse) ?? null,
code: data.value?.general.items.horse ?? null,
},
{
key: 'weapon',
slotName: '무기',
displayName:
data.value?.general.itemNames?.weapon ?? fallbackDisplayCode(data.value?.general.items.weapon) ?? null,
code: data.value?.general.items.weapon ?? null,
},
{
key: 'book',
slotName: '서적',
displayName:
data.value?.general.itemNames?.book ?? fallbackDisplayCode(data.value?.general.items.book) ?? null,
code: data.value?.general.items.book ?? null,
},
{
key: 'item',
slotName: '도구',
displayName:
data.value?.general.itemNames?.item ?? fallbackDisplayCode(data.value?.general.items.item) ?? null,
code: data.value?.general.items.item ?? null,
},
]
);
const iconChoices = computed(() => data.value?.iconChoices ?? []); const iconChoices = computed(() => data.value?.iconChoices ?? []);
const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user)); const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user));
@@ -302,8 +330,8 @@ const dieOnPrestart = async () => {
} }
}; };
const dropItem = (item: { key: ItemSlotKey; name: string; code: string | null }) => const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: string | null; code: string | null }) =>
confirmMutation(`${item.code ?? item.name}을(를) 버리시겠습니까?`, () => confirmMutation(`${item.displayName ?? item.slotName}을(를) 버리시겠습니까?`, () =>
trpc.general.dropItem.mutate({ itemType: item.key }) trpc.general.dropItem.mutate({ itemType: item.key })
); );
@@ -416,7 +444,7 @@ onMounted(() => {
> >
· 계급 · 계급
<strong <strong
>Lv {{ data.general.progression?.dedicationLevel ?? 0 }} ({{ >{{ data.general.progression?.dedicationText ?? '무품관' }} ({{
data.general.dedication data.general.dedication
}})</strong }})</strong
> >
@@ -425,7 +453,7 @@ onMounted(() => {
<div>승률 0% · 승리 0 · 패배 0</div> <div>승률 0% · 승리 0 · 패배 0</div>
<div>살상률 0% · 사살 0 · 피살 0</div> <div>살상률 0% · 사살 0 · 피살 0</div>
<div> <div>
병종 {{ data.general.crewTypeId || '-' }} · 내정특기 병종 {{ data.general.crewTypeName ?? '-' }} · 내정특기
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }} · 부대 {{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }} · 부대
{{ data.general.troopId || '-' }} · 벌점 {{ penalties.length || '-' }} {{ data.general.troopId || '-' }} · 벌점 {{ penalties.length || '-' }}
</div> </div>
@@ -595,7 +623,7 @@ onMounted(() => {
:disabled="!item.code" :disabled="!item.code"
@click="dropItem(item)" @click="dropItem(item)"
> >
{{ item.code ?? '-' }} {{ item.displayName ?? '-' }}
</button> </button>
</div> </div>
@@ -637,7 +665,7 @@ onMounted(() => {
</article> </article>
</section> </section>
<footer class="legacy-credit"> <footer class="legacy-credit">
삼국지 모의전투 PHP HiDCHe - core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit
</footer> </footer>
</main> </main>
<div class="my-page-mobile-scroll-spacer" aria-hidden="true"></div> <div class="my-page-mobile-scroll-spacer" aria-hidden="true"></div>
@@ -252,7 +252,7 @@ onMounted(async () => {
</tr> </tr>
<tr> <tr>
<td class="legacy-banner"> <td class="legacy-banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
<a href="mailto:hided62@gmail.com">HideD(hided62@gmail.com)</a> / <a href="mailto:hided62@gmail.com">HideD(hided62@gmail.com)</a> /
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a> <a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</td> </td>
@@ -63,7 +63,6 @@ const generals = computed(() =>
}) })
); );
const special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`; const special = (general: General) => `${general.specialDomestic?.name ?? '-'} / ${general.specialWar?.name ?? '-'}`;
const rank = (general: General) => (general.dedicationLevel ? `${11 - general.dedicationLevel}품관` : '무품관');
const iconUrl = (general: General) => resolveGeneralIconUrl(general); const iconUrl = (general: General) => resolveGeneralIconUrl(general);
onMounted(load); onMounted(load);
</script> </script>
@@ -78,7 +77,13 @@ onMounted(load);
<strong>세력 장수</strong> <strong>세력 장수</strong>
<span class="right-actions"> <span class="right-actions">
<span class="dropdown"> <span class="dropdown">
<button class="top-button mode-button" :aria-expanded="viewMenuOpen" @click="viewMenuOpen = !viewMenuOpen">보기 모드</button> <button
class="top-button mode-button"
:aria-expanded="viewMenuOpen"
@click="viewMenuOpen = !viewMenuOpen"
>
보기 모드
</button>
<span v-if="viewMenuOpen" class="dropdown-menu"> <span v-if="viewMenuOpen" class="dropdown-menu">
<button <button
@click=" @click="
@@ -178,7 +183,7 @@ onMounted(load);
</td> </td>
<td :class="`name-cell npc-${general.npcState}`">{{ general.name }}</td> <td :class="`name-cell npc-${general.npcState}`">{{ general.name }}</td>
<td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</td> <td>{{ formatOfficerLevelText(general.officerLevel, data?.nation.level) }}</td>
<td>{{ rank(general) }}<br />({{ (general.dedicationLevel * 200).toLocaleString() }})</td> <td>{{ general.dedicationText }}<br />({{ general.bill.toLocaleString() }})</td>
<td>Lv {{ general.experienceLevel }}<br />({{ general.personality?.name ?? '-' }})</td> <td>Lv {{ general.experienceLevel }}<br />({{ general.personality?.name ?? '-' }})</td>
<td>{{ general.stats.leadership }}</td> <td>{{ general.stats.leadership }}</td>
<td>{{ general.stats.strength }}</td> <td>{{ general.stats.strength }}</td>
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { formatLog } from '../utils/formatLog'; import { formatLog } from '../utils/formatLog';
import { legacyNationTextColor } from '../utils/legacyNationColor'; import { legacyNationTextColor } from '../utils/legacyNationColor';
import { formatNationLevelText } from '../utils/nationFormat';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getNationInfo.query>>; type Result = Awaited<ReturnType<typeof trpc.nation.getNationInfo.query>>;
@@ -11,9 +12,7 @@ const router = useRouter();
const error = ref(''); const error = ref('');
const number = (value: number) => value.toLocaleString('ko-KR'); const number = (value: number) => value.toLocaleString('ko-KR');
const diff = (value: number) => `${value > 0 ? '+' : ''}${number(value)}`; const diff = (value: number) => `${value > 0 ? '+' : ''}${number(value)}`;
const nationLevel = computed( const nationLevel = computed(() => formatNationLevelText(data.value?.nation.level ?? 0));
() => ['두목', '영주', '군벌', '주자사', '주목', '공', '왕', '황제'][data.value?.nation.level ?? 0] ?? '-'
);
onMounted(async () => { onMounted(async () => {
try { try {
data.value = await trpc.nation.getNationInfo.query(); data.value = await trpc.nation.getNationInfo.query();
@@ -114,7 +113,7 @@ onMounted(async () => {
<td><button type="button" @click="router.push('/')">돌아가기</button></td> <td><button type="button" @click="router.push('/')">돌아가기</button></td>
</tr> </tr>
<tr> <tr>
<td class="credit">삼국지 모의전투 PHP HiDCHe / KOEI의 이미지를 사용했습니다 / 제작: Hide.D</td> <td class="credit">삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용했습니다 / 제작: Hide.D</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
+3 -13
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { formatOfficerLevelText } from '../utils/nationFormat'; import { formatNationLevelText, formatOfficerLevelText } from '../utils/nationFormat';
import { getNpcColor } from '../utils/npcColor'; import { getNpcColor } from '../utils/npcColor';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
@@ -12,16 +12,6 @@ const nations = ref<Directory>([]);
const loading = ref(false); const loading = ref(false);
const error = ref(''); const error = ref('');
const nationLevelText: Record<number, string> = {
7: '황제',
6: '왕',
5: '공',
4: '주목',
3: '주자사',
2: '군벌',
1: '호족',
0: '방랑군',
};
const whiteTextColors = new Set([ const whiteTextColors = new Set([
'', '',
'#330000', '#330000',
@@ -111,7 +101,7 @@ onMounted(() => {
<td class="label-cell"> </td> <td class="label-cell"> </td>
<td class="value-wide type-name">{{ Array.from(nation.type.name).join(' ') }}</td> <td class="value-wide type-name">{{ Array.from(nation.type.name).join(' ') }}</td>
<td class="label-cell"> </td> <td class="label-cell"> </td>
<td class="value-wide">{{ nationLevelText[nation.level] ?? '-' }}</td> <td class="value-wide">{{ formatNationLevelText(nation.level) }}</td>
<td class="label-cell"> </td> <td class="label-cell"> </td>
<td class="value-wide">{{ nation.power }}</td> <td class="value-wide">{{ nation.power }}</td>
<td class="label-cell">장수 / 속령</td> <td class="label-cell">장수 / 속령</td>
@@ -227,7 +217,7 @@ onMounted(() => {
<tr> <tr>
<td> <td>
<small> <small>
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / HideD(hided62@gmail.com) /
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a> <a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</small> </small>
@@ -503,7 +503,7 @@ onMounted(() => void loadPersonnel());
</tr> </tr>
<tr> <tr>
<td class="legacy-banner"> <td class="legacy-banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / HideD(hided62@gmail.com) /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer" <a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer"
>Credit</a >Credit</a
@@ -159,7 +159,7 @@ onMounted(load);
</tr> </tr>
<tr> <tr>
<td class="legacy-banner"> <td class="legacy-banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / HideD(hided62@gmail.com) /
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a> <a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</td> </td>
+1 -1
View File
@@ -149,7 +149,7 @@ onMounted(() => {
</tr> </tr>
<tr> <tr>
<td class="banner"> <td class="banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
<a href="mailto:hided62@gmail.com">HideD(hided62@gmail.com)</a> / <a href="mailto:hided62@gmail.com">HideD(hided62@gmail.com)</a> /
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a> <a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</td> </td>
@@ -140,7 +140,7 @@ onMounted(() => {
<td>{{ valueOrDash(general.leadership) }}</td> <td>{{ valueOrDash(general.leadership) }}</td>
<td>{{ valueOrDash(general.strength) }}</td> <td>{{ valueOrDash(general.strength) }}</td>
<td>{{ valueOrDash(general.intel) }}</td> <td>{{ valueOrDash(general.intel) }}</td>
<td>{{ valueOrDash(general.officerLevel) }}</td> <td>{{ valueOrDash(general.officerLevelText) }}</td>
<td>{{ valueOrDash(general.personal) }}</td> <td>{{ valueOrDash(general.personal) }}</td>
<td>{{ valueOrDash(general.special) }}</td> <td>{{ valueOrDash(general.special) }}</td>
<td>{{ valueOrDash(general.special2) }}</td> <td>{{ valueOrDash(general.special2) }}</td>
@@ -358,8 +358,11 @@ onBeforeUnmount(() => {
<span role="tooltip">{{ candidate.specialDomesticInfo }}</span> <span role="tooltip">{{ candidate.specialDomesticInfo }}</span>
</span> </span>
/ /
<span>{{ candidate.specialWar ?? '-' }}</span <span v-if="candidate.specialWarName" class="trait-tooltip" tabindex="0">
><br /><br /> {{ candidate.specialWarName }}
<span role="tooltip">{{ candidate.specialWarInfo }}</span>
</span>
<span v-else>-</span><br /><br />
보병: {{ Math.trunc(candidate.dex[0] / 1000) }}K<br /> 보병: {{ Math.trunc(candidate.dex[0] / 1000) }}K<br />
궁병: {{ Math.trunc(candidate.dex[1] / 1000) }}K<br /> 궁병: {{ Math.trunc(candidate.dex[1] / 1000) }}K<br />
기병: {{ Math.trunc(candidate.dex[2] / 1000) }}K<br /> 기병: {{ Math.trunc(candidate.dex[2] / 1000) }}K<br />
@@ -408,8 +411,11 @@ onBeforeUnmount(() => {
<span role="tooltip">{{ selectedCandidate.specialDomesticInfo }}</span> <span role="tooltip">{{ selectedCandidate.specialDomesticInfo }}</span>
</span> </span>
/ /
<span>{{ selectedCandidate.specialWar ?? '-' }}</span <span v-if="selectedCandidate.specialWarName" class="trait-tooltip" tabindex="0">
><br /><br /> {{ selectedCandidate.specialWarName }}
<span role="tooltip">{{ selectedCandidate.specialWarInfo }}</span>
</span>
<span v-else>-</span><br /><br />
보병: {{ Math.trunc(selectedCandidate.dex[0] / 1000) }}K<br /> 보병: {{ Math.trunc(selectedCandidate.dex[0] / 1000) }}K<br />
궁병: {{ Math.trunc(selectedCandidate.dex[1] / 1000) }}K<br /> 궁병: {{ Math.trunc(selectedCandidate.dex[1] / 1000) }}K<br />
기병: {{ Math.trunc(selectedCandidate.dex[2] / 1000) }}K<br /> 기병: {{ Math.trunc(selectedCandidate.dex[2] / 1000) }}K<br />
@@ -498,7 +504,7 @@ onBeforeUnmount(() => {
</div> </div>
<div class="footer-banner with-border"> <div class="footer-banner with-border">
<small> <small>
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD / 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noopener noreferrer" <a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noopener noreferrer"
>Credit</a >Credit</a
> >
@@ -287,7 +287,7 @@ const start = async () => {
<button class="close-button" type="button" @click="navigate"> 닫기</button> <button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink> </RouterLink>
<small> <small>
삼국지 모의전투 PHP HiDCHe -unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / Credit HideD(hided62@gmail.com) / Credit
</small> </small>
</footer> </footer>
+1 -1
View File
@@ -204,7 +204,7 @@ onMounted(() => {
</tr> </tr>
<tr> <tr>
<td class="banner"> <td class="banner">
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / HideD(hided62@gmail.com) /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a> <a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</td> </td>
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createEmptyRealtimeReadModelChanges } from '@sammo-ts/common'; import { createEmptyRealtimeReadModelChanges, createEmptyRealtimeReadModelInvalidation } from '@sammo-ts/common';
import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../src/utils/dashboardReadModel.ts'; import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../src/utils/dashboardReadModel.ts';
void test('last-turn-time-only events do not schedule any dashboard query', () => { void test('last-turn-time-only events do not schedule any dashboard query', () => {
@@ -170,14 +170,14 @@ void test('targets a submitted survey projection to its own general', () => {
assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus, false); assert.equal(resolveDashboardRefreshPlan(changes, { generalId: 8, cityId: 3, nationId: 2 }).frontStatus, false);
}); });
void test('merges burst payloads without losing entity ids and starts at most once per interval', async () => { void test('merges browser-safe boolean invalidations and starts at most once per interval', async () => {
let nowMs = 0; let nowMs = 0;
let nextTimerId = 1; let nextTimerId = 1;
const timers = new Map<number, { callback: () => void; at: number }>(); const timers = new Map<number, { callback: () => void; at: number }>();
const observed: number[][] = []; const observed: Array<{ context: boolean; records: boolean }> = [];
const queue = createMergedReadModelRefreshQueue( const queue = createMergedReadModelRefreshQueue(
async (changes) => { async (invalidation) => {
observed.push(changes.generalIds); observed.push({ context: invalidation.context, records: invalidation.records });
}, },
{ {
minIntervalMs: 1_000, minIntervalMs: 1_000,
@@ -199,18 +199,21 @@ void test('merges burst payloads without losing entity ids and starts at most on
} }
}; };
queue.request({ ...createEmptyRealtimeReadModelChanges(), generalIds: [7] }); queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true });
runDueTimers(); runDueTimers();
await new Promise<void>((resolve) => setImmediate(resolve)); await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(observed, [[7]]); assert.deepEqual(observed, [{ context: true, records: false }]);
queue.request({ ...createEmptyRealtimeReadModelChanges(), generalIds: [9] }); queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true });
queue.request({ ...createEmptyRealtimeReadModelChanges(), generalIds: [8, 9] }); queue.request({ ...createEmptyRealtimeReadModelInvalidation(), records: true });
nowMs = 999; nowMs = 999;
runDueTimers(); runDueTimers();
assert.equal(observed.length, 1); assert.equal(observed.length, 1);
nowMs = 1_000; nowMs = 1_000;
runDueTimers(); runDueTimers();
await new Promise<void>((resolve) => setImmediate(resolve)); await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(observed, [[7], [8, 9]]); assert.deepEqual(observed, [
{ context: true, records: false },
{ context: true, records: true },
]);
}); });
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatSeoulDateTime, formatSeoulHourMinute } from '../src/utils/legacyDateTime.ts';
void test('formats API UTC timestamps in the server Seoul timezone', () => {
assert.equal(formatSeoulDateTime('2026-08-13T00:07:06.713Z'), '2026-08-13 09:07:06');
assert.equal(formatSeoulHourMinute('2026-08-13T00:07:06.713Z'), '09:07');
});
void test('keeps legacy timezone-less server timestamps unchanged', () => {
assert.equal(formatSeoulDateTime('2026-08-13 09:07:06'), '2026-08-13 09:07:06');
assert.equal(formatSeoulHourMinute('2026-08-13 09:07:06'), '09:07');
});
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { formatNationLevelText, formatOfficerLevelText } from '../src/utils/nationFormat.ts';
void describe('nationFormat Ref labels', () => {
void it('uses the Ref nation-level and nation-dependent office names', () => {
assert.equal(formatNationLevelText(0), '방랑군');
assert.equal(formatNationLevelText(3), '주자사');
assert.equal(formatNationLevelText(7), '황제');
assert.equal(formatOfficerLevelText(9, 3), '간의대부');
assert.equal(formatOfficerLevelText(5, 3), '-');
assert.equal(formatOfficerLevelText(5), '제3모사');
});
void it('does not expose unknown numeric levels', () => {
assert.equal(formatNationLevelText(99), '-');
assert.equal(formatOfficerLevelText(99), '-');
});
});
-1
View File
@@ -16,7 +16,6 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"],
"@sammo-ts/common": ["../../packages/common/src/index.ts"], "@sammo-ts/common": ["../../packages/common/src/index.ts"],
+1 -1
View File
@@ -21,7 +21,7 @@
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"test": "vitest run --config vitest.config.ts", "test": "vitest run --config vitest.config.ts",
"typecheck": "tsc -b" "typecheck": "pnpm -w tsc7 -b app/gateway-api/tsconfig.json"
}, },
"devDependencies": { "devDependencies": {
"@types/sanitize-html": "2.16.1", "@types/sanitize-html": "2.16.1",
+1 -1
View File
@@ -40,7 +40,7 @@
"pg": "^8.23.0", "pg": "^8.23.0",
"postcss": "8.5.26", "postcss": "8.5.26",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "6.0.2", "typescript": "6.0.3",
"vite": "^8.2.1", "vite": "^8.2.1",
"vue-tsc": "^3.3.9" "vue-tsc": "^3.3.9"
} }
-1
View File
@@ -20,7 +20,6 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "paths": {
"@/*": [ "@/*": [
"./src/*" "./src/*"
+1 -1
View File
@@ -14,7 +14,7 @@
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"test": "vitest run --config vitest.config.ts", "test": "vitest run --config vitest.config.ts",
"typecheck": "tsc -b" "typecheck": "pnpm -w tsc7 -b app/release-controller/tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@sammo-ts/gateway-api": "workspace:*", "@sammo-ts/gateway-api": "workspace:*",
+29 -16
View File
@@ -2,36 +2,49 @@
## 버전 ## 버전
Workspace 전체는 TypeScript `6.0.2`를 정확한 버전으로 사용합니다. Workspace의 Vite, Vue/Volar, typescript-eslint와 compiler API 소비자는
`pnpm-workspace.yaml` override가 transitive resolution에도 같은 버전을 TypeScript `6.0.3`을 정확한 버전으로 사용합니다. `pnpm-workspace.yaml`
적용합니다. override가 transitive resolution에도 같은 버전을 적용합니다.
CLI project build/typecheck는 TypeScript `7.0.2` native compiler를 사용합니다.
루트의 `@typescript/native``npm:typescript@7.0.2` alias이며, `tsc7` script가
그 package의 `tsc` binary를 명시적으로 실행합니다. 각 package의 `tsc -b`
`tsc -p`는 PATH의 우연한 binary 선택에 의존하지 않고 `pnpm -w tsc7 ...`
통해 이 script를 호출합니다. Vue SFC 검사는 계속 `vue-tsc`와 TypeScript 6.0.3
API를 사용합니다.
TypeScript 7의 공식 side-by-side 안내에 있는 `@typescript/typescript6` package는
현재 6.0.2까지만 배포되어 있으므로, 6.0.3 API는 일반 `typescript` package로
설치합니다. 두 package가 모두 `tsc` 이름을 제공할 수 있는 PATH 충돌을 피하기
위해 project script는 반드시 명시적인 `tsc7` entry point를 사용합니다.
`pnpm check:typescript-toolchain`은 root와 두 frontend의 API resolution 및 native
CLI version을 함께 검사합니다.
적용 범위는 다음과 같습니다. 적용 범위는 다음과 같습니다.
- 루트 개발 도구 - 루트와 frontend의 Vite/Vue/compiler API: TypeScript 6.0.3
- `app/*` - `app/*`, `packages/*`, `tools/*`의 project build/typecheck: TypeScript 7.0.2
- `packages/*`
- `tools/*`
Package별 다른 TypeScript 버전, caret range, alias와 fallback compiler를 Package별 다른 TypeScript 버전이나 caret range를 추가하지 않습니다. 승인된
추가하지 않습니다. `pnpm-lock.yaml`에는 승인된 한 버전만 resolve되어야 두 compiler의 역할은 `typescript` 6.0.3 API와 `@typescript/native` 7.0.2 CLI로
합니다. Ref PHP 저장소 `../ref/sam`의 도구 체인은 이 workspace 정책에 고정합니다. Ref PHP 저장소 `../ref/sam`의 도구 체인은 이 workspace 정책에
포함되지 않습니다. 포함되지 않습니다.
공유 `tsconfig.base.json` TypeScript 6의 `baseUrl` deprecation 경고에 공유 tsconfig와 package tsconfig는 TypeScript 7에서 제거된 `baseUrl`
대해 `ignoreDeprecations: "6.0"`을 사용합니다. Package별 suppression을 사용하지 않습니다. `paths` target은 각 설정 파일 기준의 명시적 상대경로로
추가하지 않습니다. 작성하여 TypeScript 6, TypeScript 7과 Vite의 경로 해석을 일치시킵니다.
## 버전 변경 ## 버전 변경
Compiler major version은 workspace 전체를 한 변경으로 갱신합니다. 다음 Compiler major version은 workspace 전체를 한 변경으로 갱신합니다. 다음
항목을 모두 확인해 주세요. 항목을 모두 확인해 주세요.
1. 저장소 script가 사용하는 compiler API 1. 저장소 script가 사용하는 TypeScript 7 CLI와 TypeScript 6 compiler API
2. `typescript-eslint`, `vue-tsc`, tsdown, Vite와 Prisma 도구 지원 2. `typescript-eslint`, `vue-tsc`, tsdown, Vite와 Prisma 도구 지원
3. 모든 manifest와 lockfile의 단일 version resolution 3. 모든 manifest와 lockfile에서 승인된 두 역할의 exact version resolution
4. `CI=1 pnpm typecheck`, `pnpm lint`, `pnpm build`, `pnpm test` 4. `CI=1 pnpm typecheck`, `pnpm lint`, `pnpm build`, `pnpm test`
5. `pnpm check:legacy:general`, `pnpm check:legacy:nation` 5. `pnpm check:legacy:general`, `pnpm check:legacy:nation`
6. 별도 compiler 설치나 alias 없이 실행되는 비교 도구 6. `tsc7 --version`, `vue-tsc --version``import('typescript').version`
실제 binary/API resolution
검증되지 않은 compiler major version을 package 한 곳에만 적용하지 않습니다. 검증되지 않은 compiler major version을 package 한 곳에만 적용하지 않습니다.
+5 -2
View File
@@ -13,6 +13,7 @@
"test:integration:conditional": "./tools/run-conditional-integration.sh", "test:integration:conditional": "./tools/run-conditional-integration.sh",
"build": "turbo build", "build": "turbo build",
"typecheck": "turbo typecheck", "typecheck": "turbo typecheck",
"tsc7": "node_modules/@typescript/native/bin/tsc",
"dev": "turbo dev", "dev": "turbo dev",
"build:server": "pnpm --filter ./tools/build-scripts build:server --", "build:server": "pnpm --filter ./tools/build-scripts build:server --",
"generate:resource-schemas": "pnpm --filter @sammo-ts/tools-scripts generate:resource-schemas", "generate:resource-schemas": "pnpm --filter @sammo-ts/tools-scripts generate:resource-schemas",
@@ -23,10 +24,11 @@
"check:legacy:general": "node tools/compare-command-constraints.mjs --include '^General/' --check && node tools/compare-command-logs.mjs --include '^General/' --mode action --check && node tools/compare-general-turn-contracts.mjs --check", "check:legacy:general": "node tools/compare-command-constraints.mjs --include '^General/' --check && node tools/compare-command-logs.mjs --include '^General/' --mode action --check && node tools/compare-general-turn-contracts.mjs --check",
"check:ref-compat-markers": "node tools/check-ref-compat-markers.mjs", "check:ref-compat-markers": "node tools/check-ref-compat-markers.mjs",
"check:architecture": "node tools/check-package-boundaries.mjs", "check:architecture": "node tools/check-package-boundaries.mjs",
"check:typescript-toolchain": "node tools/check-typescript-toolchain.mjs",
"test:architecture": "node --test tools/check-package-boundaries.test.mjs", "test:architecture": "node --test tools/check-package-boundaries.test.mjs",
"test:image-sync": "node --test tools/sync-image-repository.test.mjs", "test:image-sync": "node --test tools/sync-image-repository.test.mjs",
"test:e2e:frontend-legacy": "playwright test --config tools/frontend-legacy-parity/playwright.config.mjs --tsconfig tools/frontend-legacy-parity/tsconfig.json", "test:e2e:frontend-legacy": "playwright test --config tools/frontend-legacy-parity/playwright.config.mjs --tsconfig tools/frontend-legacy-parity/tsconfig.json",
"typecheck:e2e:frontend-legacy": "tsc -p tools/frontend-legacy-parity/tsconfig.json --noEmit", "typecheck:e2e:frontend-legacy": "pnpm tsc7 -p tools/frontend-legacy-parity/tsconfig.json --noEmit",
"test:e2e:main-front-status-live": "node tools/frontend-legacy-parity/run-main-front-status-live.mjs", "test:e2e:main-front-status-live": "node tools/frontend-legacy-parity/run-main-front-status-live.mjs",
"test:e2e:main-records-live": "node tools/frontend-legacy-parity/run-main-records-live.mjs", "test:e2e:main-records-live": "node tools/frontend-legacy-parity/run-main-records-live.mjs",
"migrate:legacy": "pnpm --filter @sammo-ts/legacy-db-migration migrate", "migrate:legacy": "pnpm --filter @sammo-ts/legacy-db-migration migrate",
@@ -49,7 +51,8 @@
"prettier": "^3.9.6", "prettier": "^3.9.6",
"tsdown": "^0.22.14", "tsdown": "^0.22.14",
"turbo": "^2.10.9", "turbo": "^2.10.9",
"typescript": "6.0.2", "@typescript/native": "npm:typescript@7.0.2",
"typescript": "6.0.3",
"typescript-eslint": "^8.67.0", "typescript-eslint": "^8.67.0",
"vitepress": "1.6.4", "vitepress": "1.6.4",
"vue-eslint-parser": "^10.4.1" "vue-eslint-parser": "^10.4.1"
+1 -1
View File
@@ -29,7 +29,7 @@
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"test": "vitest run --config vitest.config.ts", "test": "vitest run --config vitest.config.ts",
"typecheck": "tsc -b" "typecheck": "pnpm -w tsc7 -b packages/common/tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
+128 -2
View File
@@ -2,8 +2,9 @@ export type MessageTypeKey = 'public' | 'private' | 'national' | 'diplomacy';
/** /**
* Durable mutations summarized after the database transaction commits. * Durable mutations summarized after the database transaction commits.
* Entity IDs let each authenticated client decide whether its own read model * This internal Redis contract carries entity IDs so the authenticated game
* is affected without exposing entity payloads over the shared Redis channel. * API can derive each subscriber's browser-safe invalidation. It must never be
* serialized directly to a public SSE response.
*/ */
export interface RealtimeReadModelChanges { export interface RealtimeReadModelChanges {
generalIds: number[]; generalIds: number[];
@@ -32,6 +33,119 @@ export interface RealtimeReadModelChanges {
lobbyChanged?: boolean; lobbyChanged?: boolean;
} }
/**
* Browser-visible invalidation contract. It deliberately contains no entity
* IDs, timestamps, turn times, or global revisions. The API derives these
* viewer-specific booleans from the internal committed-change summary before
* crossing the SSE boundary.
*/
export interface RealtimeReadModelInvalidation {
context: boolean;
lobby: boolean;
map: boolean;
commands: boolean;
contacts: boolean;
boardAccess: boolean;
reservedTurns: boolean;
records: boolean;
frontStatus: boolean;
}
export interface RealtimeViewerIdentity {
generalId: number | null;
cityId: number | null;
nationId: number | null;
}
export const createEmptyRealtimeReadModelInvalidation = (): RealtimeReadModelInvalidation => ({
context: false,
lobby: false,
map: false,
commands: false,
contacts: false,
boardAccess: false,
reservedTurns: false,
records: false,
frontStatus: false,
});
export const createFullRealtimeReadModelInvalidation = (): RealtimeReadModelInvalidation => ({
context: true,
lobby: true,
map: true,
commands: true,
contacts: true,
boardAccess: true,
reservedTurns: true,
records: true,
frontStatus: true,
});
export const mergeRealtimeReadModelInvalidations = (
left: RealtimeReadModelInvalidation,
right: RealtimeReadModelInvalidation
): RealtimeReadModelInvalidation => ({
context: left.context || right.context,
lobby: left.lobby || right.lobby,
map: left.map || right.map,
commands: left.commands || right.commands,
contacts: left.contacts || right.contacts,
boardAccess: left.boardAccess || right.boardAccess,
reservedTurns: left.reservedTurns || right.reservedTurns,
records: left.records || right.records,
frontStatus: left.frontStatus || right.frontStatus,
});
export const hasRealtimeReadModelInvalidation = (invalidation: RealtimeReadModelInvalidation): boolean =>
Object.values(invalidation).some(Boolean);
const contains = (ids: readonly number[], id: number | null): boolean => id !== null && ids.includes(id);
export const resolveRealtimeReadModelInvalidation = (
changes: RealtimeReadModelChanges,
identity: RealtimeViewerIdentity
): RealtimeReadModelInvalidation => {
const ownGeneralChanged = contains(changes.generalIds, identity.generalId);
const ownCityChanged = contains(changes.cityIds, identity.cityId);
const ownNationChanged = contains(changes.nationIds, identity.nationId);
const ownFrontStatusNationChanged = contains(
changes.frontStatusNationIds ?? changes.nationIds,
identity.nationId
);
const ownGeneralMapChanged = contains(changes.mapGeneralIds ?? changes.generalIds, identity.generalId);
const frontStatusGeneralChanged =
changes.frontStatusGeneralIds !== undefined
? changes.frontStatusGeneralIds.length > 0
: changes.contactsChanged;
const ownFrontStatusActorChanged = contains(changes.frontStatusActorIds ?? [], identity.generalId);
const ownLobbyGeneralChanged = contains(changes.lobbyGeneralIds ?? changes.generalIds, identity.generalId);
const lobbyChanged = changes.lobbyChanged ?? changes.contactsChanged;
const entityContextChanged = ownGeneralChanged || ownCityChanged || ownNationChanged;
const mapEntitiesChanged =
(changes.mapCityIds ?? changes.cityIds).length > 0 ||
(changes.mapNationIds ?? changes.nationIds).length > 0;
const commandEntitiesChanged = changes.cityIds.length > 0 || changes.nationIds.length > 0;
return {
context: entityContextChanged,
lobby: changes.worldChanged || lobbyChanged || ownLobbyGeneralChanged,
map: changes.worldChanged || mapEntitiesChanged || ownGeneralMapChanged,
commands: changes.worldChanged || commandEntitiesChanged || ownGeneralChanged,
contacts: changes.contactsChanged,
boardAccess: ownGeneralChanged || ownNationChanged,
reservedTurns: contains(changes.reservedGeneralIds, identity.generalId),
records:
changes.globalRecordsChanged ||
changes.worldHistoryChanged ||
contains(changes.recordGeneralIds, identity.generalId),
frontStatus:
Boolean(changes.frontStatusChanged) ||
frontStatusGeneralChanged ||
ownFrontStatusNationChanged ||
ownFrontStatusActorChanged,
};
};
export const createEmptyRealtimeReadModelChanges = (): RealtimeReadModelChanges => ({ export const createEmptyRealtimeReadModelChanges = (): RealtimeReadModelChanges => ({
generalIds: [], generalIds: [],
cityIds: [], cityIds: [],
@@ -125,6 +239,18 @@ export interface MessageCreatedEvent {
senderId: number; senderId: number;
} }
export interface ReadModelInvalidatedEvent {
type: 'readModelInvalidated';
invalidation: RealtimeReadModelInvalidation;
}
export interface MessagesInvalidatedEvent {
type: 'messagesInvalidated';
}
/** Events safe to expose to an authenticated browser over SSE. */
export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent;
export type RealtimeEvent = export type RealtimeEvent =
| TurnCompletedEvent | TurnCompletedEvent
| ReadModelChangedEvent | ReadModelChangedEvent
+1 -1
View File
@@ -12,7 +12,7 @@
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"test": "node -e \"console.log('test not configured')\"", "test": "node -e \"console.log('test not configured')\"",
"prisma:generate": "pnpm prisma:generate:game && pnpm prisma:generate:gateway", "prisma:generate": "pnpm prisma:generate:game && pnpm prisma:generate:gateway",
"typecheck": "tsc -b", "typecheck": "pnpm -w tsc7 -b packages/infra/tsconfig.json",
"prisma:generate:game": "prisma generate --schema prisma/game.prisma", "prisma:generate:game": "prisma generate --schema prisma/game.prisma",
"prisma:generate:gateway": "prisma generate --schema prisma/gateway.prisma", "prisma:generate:gateway": "prisma generate --schema prisma/gateway.prisma",
"prisma:migrate:deploy:game": "prisma migrate deploy --schema prisma/game.prisma", "prisma:migrate:deploy:game": "prisma migrate deploy --schema prisma/game.prisma",
+1 -1
View File
@@ -18,7 +18,7 @@
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"test": "vitest run --config vitest.config.ts", "test": "vitest run --config vitest.config.ts",
"typecheck": "tsc -b" "typecheck": "pnpm -w tsc7 -b packages/logic/tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@sammo-ts/common": "workspace:*", "@sammo-ts/common": "workspace:*",
+2 -2
View File
@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"generate:resource-schemas": "tsx src/generate-resource-schemas.ts", "generate:resource-schemas": "tsx src/generate-resource-schemas.ts",
"validate:resources": "tsx src/validate-resources.ts", "validate:resources": "tsx src/validate-resources.ts",
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "pnpm -w tsc7 -p packages/tools-scripts/tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@sammo-ts/logic": "workspace:*", "@sammo-ts/logic": "workspace:*",
@@ -15,6 +15,6 @@
"devDependencies": { "devDependencies": {
"@types/node": "^26.2.0", "@types/node": "^26.2.0",
"tsx": "^4.23.12", "tsx": "^4.23.12",
"typescript": "6.0.2" "typescript": "6.0.3"
} }
} }
+390 -179
View File
@@ -6,7 +6,7 @@ settings:
overrides: overrides:
postcss: 8.5.26 postcss: 8.5.26
typescript: 6.0.2 typescript: 6.0.3
importers: importers:
@@ -23,10 +23,13 @@ importers:
version: 26.2.0 version: 26.2.0
'@typescript-eslint/eslint-plugin': '@typescript-eslint/eslint-plugin':
specifier: ^8.67.0 specifier: ^8.67.0
version: 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2))(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2) version: 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
'@typescript-eslint/parser': '@typescript-eslint/parser':
specifier: ^8.67.0 specifier: ^8.67.0
version: 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2) version: 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
'@typescript/native':
specifier: npm:typescript@7.0.2
version: typescript@7.0.2
eslint: eslint:
specifier: ^10.8.1 specifier: ^10.8.1
version: 10.8.1(jiti@2.7.0)(supports-color@7.2.0) version: 10.8.1(jiti@2.7.0)(supports-color@7.2.0)
@@ -38,7 +41,7 @@ importers:
version: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0)))(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(prettier@3.9.6) version: 5.5.6(eslint-config-prettier@10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0)))(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(prettier@3.9.6)
eslint-plugin-vue: eslint-plugin-vue:
specifier: ^10.10.0 specifier: ^10.10.0
version: 10.10.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2))(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(vue-eslint-parser@10.4.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)) version: 10.10.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(vue-eslint-parser@10.4.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0))
globals: globals:
specifier: ^17.11.0 specifier: ^17.11.0
version: 17.11.0 version: 17.11.0
@@ -47,19 +50,19 @@ importers:
version: 3.9.6 version: 3.9.6
tsdown: tsdown:
specifier: ^0.22.14 specifier: ^0.22.14
version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.2))(tsx@4.23.12)(typescript@6.0.2)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.2)) version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.3))(tsx@4.23.12)(typescript@6.0.3)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.3))
turbo: turbo:
specifier: ^2.10.9 specifier: ^2.10.9
version: 2.10.9 version: 2.10.9
typescript: typescript:
specifier: 6.0.2 specifier: 6.0.3
version: 6.0.2 version: 6.0.3
typescript-eslint: typescript-eslint:
specifier: ^8.67.0 specifier: ^8.67.0
version: 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2) version: 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
vitepress: vitepress:
specifier: 1.6.4 specifier: 1.6.4
version: 1.6.4(@algolia/client-search@5.56.0)(@types/node@26.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(typescript@6.0.2) version: 1.6.4(@algolia/client-search@5.56.0)(@types/node@26.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(typescript@6.0.3)
vue-eslint-parser: vue-eslint-parser:
specifier: ^10.4.1 specifier: ^10.4.1
version: 10.4.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) version: 10.4.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
@@ -86,7 +89,7 @@ importers:
version: link:../../packages/logic version: link:../../packages/logic
'@trpc/server': '@trpc/server':
specifier: ^11.8.1 specifier: ^11.8.1
version: 11.8.1(typescript@6.0.2) version: 11.8.1(typescript@6.0.3)
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
@@ -114,7 +117,7 @@ importers:
version: 2.16.1 version: 2.16.1
tsdown: tsdown:
specifier: ^0.22.14 specifier: ^0.22.14
version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.2))(tsx@4.23.12)(typescript@6.0.2)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.2)) version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.3))(tsx@4.23.12)(typescript@6.0.3)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.3))
vitest: vitest:
specifier: ^4.1.10 specifier: ^4.1.10
version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))
@@ -123,7 +126,7 @@ importers:
dependencies: dependencies:
'@prisma/client': '@prisma/client':
specifier: ^7.9.1 specifier: ^7.9.1
version: 7.9.1(prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2) version: 7.9.1(prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.3))(typescript@6.0.3)
'@sammo-ts/common': '@sammo-ts/common':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/common version: link:../../packages/common
@@ -142,7 +145,7 @@ importers:
devDependencies: devDependencies:
tsdown: tsdown:
specifier: ^0.22.14 specifier: ^0.22.14
version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.2))(tsx@4.23.12)(typescript@6.0.2)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.2)) version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.3))(tsx@4.23.12)(typescript@6.0.3)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.3))
vitest: vitest:
specifier: ^4.1.10 specifier: ^4.1.10
version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))
@@ -172,16 +175,16 @@ importers:
version: 3.18.0 version: 3.18.0
'@tiptap/vue-3': '@tiptap/vue-3':
specifier: ^3.5.0 specifier: ^3.5.0
version: 3.18.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.18.0(@tiptap/pm@3.18.0))(@tiptap/pm@3.18.0)(vue@3.5.26(typescript@6.0.2)) version: 3.18.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.18.0(@tiptap/pm@3.18.0))(@tiptap/pm@3.18.0)(vue@3.5.26(typescript@6.0.3))
'@trpc/client': '@trpc/client':
specifier: ^11.8.1 specifier: ^11.8.1
version: 11.8.1(@trpc/server@11.8.1(typescript@6.0.2))(typescript@6.0.2) version: 11.8.1(@trpc/server@11.8.1(typescript@6.0.3))(typescript@6.0.3)
'@trpc/server': '@trpc/server':
specifier: ^11.8.1 specifier: ^11.8.1
version: 11.8.1(typescript@6.0.2) version: 11.8.1(typescript@6.0.3)
'@vueuse/core': '@vueuse/core':
specifier: ^14.1.0 specifier: ^14.1.0
version: 14.1.0(vue@3.5.26(typescript@6.0.2)) version: 14.1.0(vue@3.5.26(typescript@6.0.3))
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
@@ -193,13 +196,13 @@ importers:
version: 3.0.1 version: 3.0.1
pinia: pinia:
specifier: ^3.0.4 specifier: ^3.0.4
version: 3.0.4(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2)) version: 3.0.4(typescript@6.0.3)(vue@3.5.26(typescript@6.0.3))
vue: vue:
specifier: ^3.5.26 specifier: ^3.5.26
version: 3.5.26(typescript@6.0.2) version: 3.5.26(typescript@6.0.3)
vue-router: vue-router:
specifier: ^4.6.4 specifier: ^4.6.4
version: 4.6.4(vue@3.5.26(typescript@6.0.2)) version: 4.6.4(vue@3.5.26(typescript@6.0.3))
zod: zod:
specifier: ^4.3.5 specifier: ^4.3.5
version: 4.3.5 version: 4.3.5
@@ -221,7 +224,7 @@ importers:
version: 4.3.3(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) version: 4.3.3(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^6.0.8 specifier: ^6.0.8
version: 6.0.8(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))(vue@3.5.26(typescript@6.0.2)) version: 6.0.8(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))(vue@3.5.26(typescript@6.0.3))
autoprefixer: autoprefixer:
specifier: ^10.5.4 specifier: ^10.5.4
version: 10.5.4(postcss@8.5.26) version: 10.5.4(postcss@8.5.26)
@@ -232,14 +235,14 @@ importers:
specifier: ^4.3.3 specifier: ^4.3.3
version: 4.3.3 version: 4.3.3
typescript: typescript:
specifier: 6.0.2 specifier: 6.0.3
version: 6.0.2 version: 6.0.3
vite: vite:
specifier: ^8.2.1 specifier: ^8.2.1
version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12) version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)
vue-tsc: vue-tsc:
specifier: ^3.3.9 specifier: ^3.3.9
version: 3.3.9(typescript@6.0.2) version: 3.3.9(typescript@6.0.3)
app/gateway-api: app/gateway-api:
dependencies: dependencies:
@@ -251,7 +254,7 @@ importers:
version: 9.0.0 version: 9.0.0
'@prisma/client': '@prisma/client':
specifier: ^7.9.1 specifier: ^7.9.1
version: 7.9.1(prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2) version: 7.9.1(prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.3))(typescript@6.0.3)
'@sammo-ts/common': '@sammo-ts/common':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/common version: link:../../packages/common
@@ -266,7 +269,7 @@ importers:
version: link:../../packages/logic version: link:../../packages/logic
'@trpc/server': '@trpc/server':
specifier: ^11.8.1 specifier: ^11.8.1
version: 11.8.1(typescript@6.0.2) version: 11.8.1(typescript@6.0.3)
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
@@ -297,7 +300,7 @@ importers:
version: 2.16.1 version: 2.16.1
tsdown: tsdown:
specifier: ^0.22.14 specifier: ^0.22.14
version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.2))(tsx@4.23.12)(typescript@6.0.2)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.2)) version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.3))(tsx@4.23.12)(typescript@6.0.3)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.3))
vitest: vitest:
specifier: ^4.1.10 specifier: ^4.1.10
version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))
@@ -309,13 +312,13 @@ importers:
version: link:../../packages/common version: link:../../packages/common
'@trpc/client': '@trpc/client':
specifier: ^11.8.1 specifier: ^11.8.1
version: 11.8.1(@trpc/server@11.8.1(typescript@6.0.2))(typescript@6.0.2) version: 11.8.1(@trpc/server@11.8.1(typescript@6.0.3))(typescript@6.0.3)
'@trpc/server': '@trpc/server':
specifier: ^11.8.1 specifier: ^11.8.1
version: 11.8.1(typescript@6.0.2) version: 11.8.1(typescript@6.0.3)
'@vueuse/core': '@vueuse/core':
specifier: ^13.9.0 specifier: ^13.9.0
version: 13.9.0(vue@3.5.26(typescript@6.0.2)) version: 13.9.0(vue@3.5.26(typescript@6.0.3))
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
@@ -327,13 +330,13 @@ importers:
version: 3.0.1 version: 3.0.1
pinia: pinia:
specifier: ^3.0.4 specifier: ^3.0.4
version: 3.0.4(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2)) version: 3.0.4(typescript@6.0.3)(vue@3.5.26(typescript@6.0.3))
vue: vue:
specifier: ^3.5.26 specifier: ^3.5.26
version: 3.5.26(typescript@6.0.2) version: 3.5.26(typescript@6.0.3)
vue-router: vue-router:
specifier: ^4.6.4 specifier: ^4.6.4
version: 4.6.4(vue@3.5.26(typescript@6.0.2)) version: 4.6.4(vue@3.5.26(typescript@6.0.3))
zod: zod:
specifier: ^4.3.5 specifier: ^4.3.5
version: 4.3.5 version: 4.3.5
@@ -361,7 +364,7 @@ importers:
version: 8.21.0 version: 8.21.0
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^6.0.8 specifier: ^6.0.8
version: 6.0.8(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))(vue@3.5.26(typescript@6.0.2)) version: 6.0.8(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))(vue@3.5.26(typescript@6.0.3))
autoprefixer: autoprefixer:
specifier: ^10.5.4 specifier: ^10.5.4
version: 10.5.4(postcss@8.5.26) version: 10.5.4(postcss@8.5.26)
@@ -375,14 +378,14 @@ importers:
specifier: ^4.3.3 specifier: ^4.3.3
version: 4.3.3 version: 4.3.3
typescript: typescript:
specifier: 6.0.2 specifier: 6.0.3
version: 6.0.2 version: 6.0.3
vite: vite:
specifier: ^8.2.1 specifier: ^8.2.1
version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12) version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)
vue-tsc: vue-tsc:
specifier: ^3.3.9 specifier: ^3.3.9
version: 3.3.9(typescript@6.0.2) version: 3.3.9(typescript@6.0.3)
app/release-controller: app/release-controller:
dependencies: dependencies:
@@ -395,7 +398,7 @@ importers:
devDependencies: devDependencies:
tsdown: tsdown:
specifier: ^0.22.14 specifier: ^0.22.14
version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.2))(tsx@4.23.12)(typescript@6.0.2)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.2)) version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.3))(tsx@4.23.12)(typescript@6.0.3)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.3))
vitest: vitest:
specifier: ^4.1.10 specifier: ^4.1.10
version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))
@@ -414,7 +417,7 @@ importers:
devDependencies: devDependencies:
tsdown: tsdown:
specifier: ^0.22.14 specifier: ^0.22.14
version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.2))(tsx@4.23.12)(typescript@6.0.2)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.2)) version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.3))(tsx@4.23.12)(typescript@6.0.3)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.3))
vitest: vitest:
specifier: ^4.1.10 specifier: ^4.1.10
version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))
@@ -426,7 +429,7 @@ importers:
version: 7.9.1 version: 7.9.1
'@prisma/client': '@prisma/client':
specifier: ^7.9.1 specifier: ^7.9.1
version: 7.9.1(prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2) version: 7.9.1(prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.3))(typescript@6.0.3)
'@prisma/client-runtime-utils': '@prisma/client-runtime-utils':
specifier: ^7.9.1 specifier: ^7.9.1
version: 7.9.1 version: 7.9.1
@@ -451,10 +454,10 @@ importers:
version: 17.4.2 version: 17.4.2
prisma: prisma:
specifier: ^7.9.1 specifier: ^7.9.1
version: 7.9.1(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.2) version: 7.9.1(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.3)
tsdown: tsdown:
specifier: ^0.22.14 specifier: ^0.22.14
version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.2))(tsx@4.23.12)(typescript@6.0.2)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.2)) version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.3))(tsx@4.23.12)(typescript@6.0.3)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.3))
packages/logic: packages/logic:
dependencies: dependencies:
@@ -473,7 +476,7 @@ importers:
devDependencies: devDependencies:
tsdown: tsdown:
specifier: ^0.22.14 specifier: ^0.22.14
version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.2))(tsx@4.23.12)(typescript@6.0.2)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.2)) version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.3))(tsx@4.23.12)(typescript@6.0.3)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.3))
vitest: vitest:
specifier: ^4.1.10 specifier: ^4.1.10
version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))
@@ -494,8 +497,8 @@ importers:
specifier: ^4.23.12 specifier: ^4.23.12
version: 4.23.12 version: 4.23.12
typescript: typescript:
specifier: 6.0.2 specifier: 6.0.3
version: 6.0.2 version: 6.0.3
tools/build-scripts: {} tools/build-scripts: {}
@@ -518,7 +521,7 @@ importers:
version: link:../../packages/infra version: link:../../packages/infra
'@trpc/client': '@trpc/client':
specifier: ^11.8.1 specifier: ^11.8.1
version: 11.8.1(@trpc/server@11.8.1(typescript@6.0.2))(typescript@6.0.2) version: 11.8.1(@trpc/server@11.8.1(typescript@6.0.3))(typescript@6.0.3)
devDependencies: devDependencies:
vitest: vitest:
specifier: ^4.1.10 specifier: ^4.1.10
@@ -541,13 +544,13 @@ importers:
version: 8.21.0 version: 8.21.0
tsdown: tsdown:
specifier: ^0.22.14 specifier: ^0.22.14
version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.2))(tsx@4.23.12)(typescript@6.0.2)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.2)) version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.3))(tsx@4.23.12)(typescript@6.0.3)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.3))
tsx: tsx:
specifier: ^4.23.12 specifier: ^4.23.12
version: 4.23.12 version: 4.23.12
typescript: typescript:
specifier: 6.0.2 specifier: 6.0.3
version: 6.0.2 version: 6.0.3
vitest: vitest:
specifier: ^4.1.10 specifier: ^4.1.10
version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))
@@ -1352,7 +1355,7 @@ packages:
engines: {node: ^20.19 || ^22.12 || >=24.0} engines: {node: ^20.19 || ^22.12 || >=24.0}
peerDependencies: peerDependencies:
prisma: '*' prisma: '*'
typescript: 6.0.2 typescript: 6.0.3
peerDependenciesMeta: peerDependenciesMeta:
prisma: prisma:
optional: true optional: true
@@ -2112,12 +2115,12 @@ packages:
resolution: {integrity: sha512-L/SJFGanr9xGABmuDoeXR4xAdHJmsXsiF9OuH+apecJ+8sUITzVT1EPeqp0ebqA6lBhEl5pPfg3rngVhi/h60Q==} resolution: {integrity: sha512-L/SJFGanr9xGABmuDoeXR4xAdHJmsXsiF9OuH+apecJ+8sUITzVT1EPeqp0ebqA6lBhEl5pPfg3rngVhi/h60Q==}
peerDependencies: peerDependencies:
'@trpc/server': 11.8.1 '@trpc/server': 11.8.1
typescript: 6.0.2 typescript: 6.0.3
'@trpc/server@11.8.1': '@trpc/server@11.8.1':
resolution: {integrity: sha512-P4rzZRpEL7zDFgjxK65IdyH0e41FMFfTkQkuq0BA5tKcr7E6v9/v38DEklCpoDN6sPiB1Sigy/PUEzHENhswDA==} resolution: {integrity: sha512-P4rzZRpEL7zDFgjxK65IdyH0e41FMFfTkQkuq0BA5tKcr7E6v9/v38DEklCpoDN6sPiB1Sigy/PUEzHENhswDA==}
peerDependencies: peerDependencies:
typescript: 6.0.2 typescript: 6.0.3
'@turbo/darwin-64@2.10.9': '@turbo/darwin-64@2.10.9':
resolution: {integrity: sha512-Jh+pTGXLNz8+1tkUU13TI/f+ZOI+OvC4YbHi1H+57iSpLt5DR3xgptd+4sA07RdjdRR/RX/01uQQu2OkbzIefA==} resolution: {integrity: sha512-Jh+pTGXLNz8+1tkUU13TI/f+ZOI+OvC4YbHi1H+57iSpLt5DR3xgptd+4sA07RdjdRR/RX/01uQQu2OkbzIefA==}
@@ -2245,20 +2248,20 @@ packages:
peerDependencies: peerDependencies:
'@typescript-eslint/parser': ^8.67.0 '@typescript-eslint/parser': ^8.67.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: 6.0.2 typescript: 6.0.3
'@typescript-eslint/parser@8.67.0': '@typescript-eslint/parser@8.67.0':
resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: 6.0.2 typescript: 6.0.3
'@typescript-eslint/project-service@8.67.0': '@typescript-eslint/project-service@8.67.0':
resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: 6.0.2 typescript: 6.0.3
'@typescript-eslint/scope-manager@8.67.0': '@typescript-eslint/scope-manager@8.67.0':
resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==}
@@ -2268,14 +2271,14 @@ packages:
resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: 6.0.2 typescript: 6.0.3
'@typescript-eslint/type-utils@8.67.0': '@typescript-eslint/type-utils@8.67.0':
resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: 6.0.2 typescript: 6.0.3
'@typescript-eslint/types@8.67.0': '@typescript-eslint/types@8.67.0':
resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==}
@@ -2285,19 +2288,139 @@ packages:
resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: 6.0.2 typescript: 6.0.3
'@typescript-eslint/utils@8.67.0': '@typescript-eslint/utils@8.67.0':
resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: 6.0.2 typescript: 6.0.3
'@typescript-eslint/visitor-keys@8.67.0': '@typescript-eslint/visitor-keys@8.67.0':
resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript/typescript-aix-ppc64@7.0.2':
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
engines: {node: '>=16.20.0'}
cpu: [ppc64]
os: [aix]
'@typescript/typescript-darwin-arm64@7.0.2':
resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [darwin]
'@typescript/typescript-darwin-x64@7.0.2':
resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [darwin]
'@typescript/typescript-freebsd-arm64@7.0.2':
resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [freebsd]
'@typescript/typescript-freebsd-x64@7.0.2':
resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [freebsd]
'@typescript/typescript-linux-arm64@7.0.2':
resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [linux]
'@typescript/typescript-linux-arm@7.0.2':
resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
engines: {node: '>=16.20.0'}
cpu: [arm]
os: [linux]
'@typescript/typescript-linux-loong64@7.0.2':
resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
engines: {node: '>=16.20.0'}
cpu: [loong64]
os: [linux]
'@typescript/typescript-linux-mips64el@7.0.2':
resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
engines: {node: '>=16.20.0'}
cpu: [mips64el]
os: [linux]
'@typescript/typescript-linux-ppc64@7.0.2':
resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
engines: {node: '>=16.20.0'}
cpu: [ppc64]
os: [linux]
'@typescript/typescript-linux-riscv64@7.0.2':
resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
engines: {node: '>=16.20.0'}
cpu: [riscv64]
os: [linux]
'@typescript/typescript-linux-s390x@7.0.2':
resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
engines: {node: '>=16.20.0'}
cpu: [s390x]
os: [linux]
'@typescript/typescript-linux-x64@7.0.2':
resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [linux]
'@typescript/typescript-netbsd-arm64@7.0.2':
resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [netbsd]
'@typescript/typescript-netbsd-x64@7.0.2':
resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [netbsd]
'@typescript/typescript-openbsd-arm64@7.0.2':
resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [openbsd]
'@typescript/typescript-openbsd-x64@7.0.2':
resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [openbsd]
'@typescript/typescript-sunos-x64@7.0.2':
resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [sunos]
'@typescript/typescript-win32-arm64@7.0.2':
resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [win32]
'@typescript/typescript-win32-x64@7.0.2':
resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [win32]
'@ungap/structured-clone@1.3.3': '@ungap/structured-clone@1.3.3':
resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==}
@@ -2388,7 +2511,7 @@ packages:
'@volar/typescript@2.4.28': '@volar/typescript@2.4.28':
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
peerDependencies: peerDependencies:
typescript: 6.0.2 typescript: 6.0.3
peerDependenciesMeta: peerDependenciesMeta:
typescript: typescript:
optional: true optional: true
@@ -4070,7 +4193,7 @@ packages:
pinia@3.0.4: pinia@3.0.4:
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
peerDependencies: peerDependencies:
typescript: 6.0.2 typescript: 6.0.3
vue: ^3.5.11 vue: ^3.5.11
peerDependenciesMeta: peerDependenciesMeta:
typescript: typescript:
@@ -4184,7 +4307,7 @@ packages:
hasBin: true hasBin: true
peerDependencies: peerDependencies:
better-sqlite3: '>=9.0.0' better-sqlite3: '>=9.0.0'
typescript: 6.0.2 typescript: 6.0.3
peerDependenciesMeta: peerDependenciesMeta:
better-sqlite3: better-sqlite3:
optional: true optional: true
@@ -4376,7 +4499,7 @@ packages:
'@typescript/native-preview': '*' '@typescript/native-preview': '*'
'@volar/typescript': ~2.4.0 '@volar/typescript': ~2.4.0
rolldown: ^1.0.0 rolldown: ^1.0.0
typescript: 6.0.2 typescript: 6.0.3
vue-tsc: ~3.2.0 || ~3.3.0 vue-tsc: ~3.2.0 || ~3.3.0
peerDependenciesMeta: peerDependenciesMeta:
'@typescript/native-preview': '@typescript/native-preview':
@@ -4623,7 +4746,7 @@ packages:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'} engines: {node: '>=18.12'}
peerDependencies: peerDependencies:
typescript: 6.0.2 typescript: 6.0.3
tsdown@0.22.14: tsdown@0.22.14:
resolution: {integrity: sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ==} resolution: {integrity: sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ==}
@@ -4636,7 +4759,7 @@ packages:
'@vitejs/devtools': '*' '@vitejs/devtools': '*'
publint: ^0.3.8 publint: ^0.3.8
tsx: '*' tsx: '*'
typescript: 6.0.2 typescript: 6.0.3
unplugin-unused: ^0.5.0 unplugin-unused: ^0.5.0
unrun: '*' unrun: '*'
peerDependenciesMeta: peerDependenciesMeta:
@@ -4690,13 +4813,18 @@ packages:
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: 6.0.2 typescript: 6.0.3
typescript@6.0.2: typescript@6.0.3:
resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
typescript@7.0.2:
resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
engines: {node: '>=16.20.0'}
hasBin: true
uc.micro@2.1.0: uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
@@ -4746,7 +4874,7 @@ packages:
valibot@1.4.2: valibot@1.4.2:
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
peerDependencies: peerDependencies:
typescript: 6.0.2 typescript: 6.0.3
peerDependenciesMeta: peerDependenciesMeta:
typescript: typescript:
optional: true optional: true
@@ -4910,12 +5038,12 @@ packages:
resolution: {integrity: sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA==} resolution: {integrity: sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA==}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
typescript: 6.0.2 typescript: 6.0.3
vue@3.5.26: vue@3.5.26:
resolution: {integrity: sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==} resolution: {integrity: sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==}
peerDependencies: peerDependencies:
typescript: 6.0.2 typescript: 6.0.3
peerDependenciesMeta: peerDependenciesMeta:
typescript: typescript:
optional: true optional: true
@@ -4923,7 +5051,7 @@ packages:
vue@3.5.41: vue@3.5.41:
resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==} resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==}
peerDependencies: peerDependencies:
typescript: 6.0.2 typescript: 6.0.3
peerDependenciesMeta: peerDependenciesMeta:
typescript: typescript:
optional: true optional: true
@@ -5657,12 +5785,12 @@ snapshots:
'@prisma/client-runtime-utils@7.9.1': {} '@prisma/client-runtime-utils@7.9.1': {}
'@prisma/client@7.9.1(prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.2))(typescript@6.0.2)': '@prisma/client@7.9.1(prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.3))(typescript@6.0.3)':
dependencies: dependencies:
'@prisma/client-runtime-utils': 7.9.1 '@prisma/client-runtime-utils': 7.9.1
optionalDependencies: optionalDependencies:
prisma: 7.9.1(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.2) prisma: 7.9.1(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.3)
typescript: 6.0.2 typescript: 6.0.3
'@prisma/config@7.9.1': '@prisma/config@7.9.1':
dependencies: dependencies:
@@ -5677,7 +5805,7 @@ snapshots:
'@prisma/debug@7.9.1': {} '@prisma/debug@7.9.1': {}
'@prisma/dev@0.24.17(typescript@6.0.2)': '@prisma/dev@0.24.17(typescript@6.0.3)':
dependencies: dependencies:
'@electric-sql/pglite': 0.4.3 '@electric-sql/pglite': 0.4.3
'@electric-sql/pglite-socket': 0.1.3(@electric-sql/pglite@0.4.3) '@electric-sql/pglite-socket': 0.1.3(@electric-sql/pglite@0.4.3)
@@ -5692,7 +5820,7 @@ snapshots:
proper-lockfile: 4.1.2 proper-lockfile: 4.1.2
remeda: 2.33.4 remeda: 2.33.4
std-env: 3.10.0 std-env: 3.10.0
valibot: 1.4.2(typescript@6.0.2) valibot: 1.4.2(typescript@6.0.3)
zeptomatch: 2.1.0 zeptomatch: 2.1.0
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
@@ -6276,26 +6404,26 @@ snapshots:
'@tiptap/extensions': 3.18.0(@tiptap/core@3.18.0(@tiptap/pm@3.18.0))(@tiptap/pm@3.18.0) '@tiptap/extensions': 3.18.0(@tiptap/core@3.18.0(@tiptap/pm@3.18.0))(@tiptap/pm@3.18.0)
'@tiptap/pm': 3.18.0 '@tiptap/pm': 3.18.0
'@tiptap/vue-3@3.18.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.18.0(@tiptap/pm@3.18.0))(@tiptap/pm@3.18.0)(vue@3.5.26(typescript@6.0.2))': '@tiptap/vue-3@3.18.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.18.0(@tiptap/pm@3.18.0))(@tiptap/pm@3.18.0)(vue@3.5.26(typescript@6.0.3))':
dependencies: dependencies:
'@floating-ui/dom': 1.7.5 '@floating-ui/dom': 1.7.5
'@tiptap/core': 3.18.0(@tiptap/pm@3.18.0) '@tiptap/core': 3.18.0(@tiptap/pm@3.18.0)
'@tiptap/pm': 3.18.0 '@tiptap/pm': 3.18.0
vue: 3.5.26(typescript@6.0.2) vue: 3.5.26(typescript@6.0.3)
optionalDependencies: optionalDependencies:
'@tiptap/extension-bubble-menu': 3.18.0(@tiptap/core@3.18.0(@tiptap/pm@3.18.0))(@tiptap/pm@3.18.0) '@tiptap/extension-bubble-menu': 3.18.0(@tiptap/core@3.18.0(@tiptap/pm@3.18.0))(@tiptap/pm@3.18.0)
'@tiptap/extension-floating-menu': 3.18.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.18.0(@tiptap/pm@3.18.0))(@tiptap/pm@3.18.0) '@tiptap/extension-floating-menu': 3.18.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.18.0(@tiptap/pm@3.18.0))(@tiptap/pm@3.18.0)
'@tootallnate/quickjs-emscripten@0.23.0': {} '@tootallnate/quickjs-emscripten@0.23.0': {}
'@trpc/client@11.8.1(@trpc/server@11.8.1(typescript@6.0.2))(typescript@6.0.2)': '@trpc/client@11.8.1(@trpc/server@11.8.1(typescript@6.0.3))(typescript@6.0.3)':
dependencies: dependencies:
'@trpc/server': 11.8.1(typescript@6.0.2) '@trpc/server': 11.8.1(typescript@6.0.3)
typescript: 6.0.2 typescript: 6.0.3
'@trpc/server@11.8.1(typescript@6.0.2)': '@trpc/server@11.8.1(typescript@6.0.3)':
dependencies: dependencies:
typescript: 6.0.2 typescript: 6.0.3
'@turbo/darwin-64@2.10.9': '@turbo/darwin-64@2.10.9':
optional: true optional: true
@@ -6406,40 +6534,40 @@ snapshots:
'@types/web-bluetooth@0.0.21': {} '@types/web-bluetooth@0.0.21': {}
'@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2))(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2)': '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2) '@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
'@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/scope-manager': 8.67.0
'@typescript-eslint/type-utils': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2) '@typescript-eslint/type-utils': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
'@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2) '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.67.0 '@typescript-eslint/visitor-keys': 8.67.0
eslint: 10.8.1(jiti@2.7.0)(supports-color@7.2.0) eslint: 10.8.1(jiti@2.7.0)(supports-color@7.2.0)
ignore: 7.0.6 ignore: 7.0.6
natural-compare: 1.4.0 natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@6.0.2) ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.2 typescript: 6.0.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2)': '@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/scope-manager': 8.67.0
'@typescript-eslint/types': 8.67.0 '@typescript-eslint/types': 8.67.0
'@typescript-eslint/typescript-estree': 8.67.0(supports-color@7.2.0)(typescript@6.0.2) '@typescript-eslint/typescript-estree': 8.67.0(supports-color@7.2.0)(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.67.0 '@typescript-eslint/visitor-keys': 8.67.0
debug: 4.4.3(supports-color@7.2.0) debug: 4.4.3(supports-color@7.2.0)
eslint: 10.8.1(jiti@2.7.0)(supports-color@7.2.0) eslint: 10.8.1(jiti@2.7.0)(supports-color@7.2.0)
typescript: 6.0.2 typescript: 6.0.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/project-service@8.67.0(supports-color@7.2.0)(typescript@6.0.2)': '@typescript-eslint/project-service@8.67.0(supports-color@7.2.0)(typescript@6.0.3)':
dependencies: dependencies:
'@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.2) '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3)
'@typescript-eslint/types': 8.67.0 '@typescript-eslint/types': 8.67.0
debug: 4.4.3(supports-color@7.2.0) debug: 4.4.3(supports-color@7.2.0)
typescript: 6.0.2 typescript: 6.0.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -6448,47 +6576,47 @@ snapshots:
'@typescript-eslint/types': 8.67.0 '@typescript-eslint/types': 8.67.0
'@typescript-eslint/visitor-keys': 8.67.0 '@typescript-eslint/visitor-keys': 8.67.0
'@typescript-eslint/tsconfig-utils@8.67.0(typescript@6.0.2)': '@typescript-eslint/tsconfig-utils@8.67.0(typescript@6.0.3)':
dependencies: dependencies:
typescript: 6.0.2 typescript: 6.0.3
'@typescript-eslint/type-utils@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2)': '@typescript-eslint/type-utils@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
dependencies: dependencies:
'@typescript-eslint/types': 8.67.0 '@typescript-eslint/types': 8.67.0
'@typescript-eslint/typescript-estree': 8.67.0(supports-color@7.2.0)(typescript@6.0.2) '@typescript-eslint/typescript-estree': 8.67.0(supports-color@7.2.0)(typescript@6.0.3)
'@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2) '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
debug: 4.4.3(supports-color@7.2.0) debug: 4.4.3(supports-color@7.2.0)
eslint: 10.8.1(jiti@2.7.0)(supports-color@7.2.0) eslint: 10.8.1(jiti@2.7.0)(supports-color@7.2.0)
ts-api-utils: 2.5.0(typescript@6.0.2) ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.2 typescript: 6.0.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/types@8.67.0': {} '@typescript-eslint/types@8.67.0': {}
'@typescript-eslint/typescript-estree@8.67.0(supports-color@7.2.0)(typescript@6.0.2)': '@typescript-eslint/typescript-estree@8.67.0(supports-color@7.2.0)(typescript@6.0.3)':
dependencies: dependencies:
'@typescript-eslint/project-service': 8.67.0(supports-color@7.2.0)(typescript@6.0.2) '@typescript-eslint/project-service': 8.67.0(supports-color@7.2.0)(typescript@6.0.3)
'@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.2) '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3)
'@typescript-eslint/types': 8.67.0 '@typescript-eslint/types': 8.67.0
'@typescript-eslint/visitor-keys': 8.67.0 '@typescript-eslint/visitor-keys': 8.67.0
debug: 4.4.3(supports-color@7.2.0) debug: 4.4.3(supports-color@7.2.0)
minimatch: 10.2.6 minimatch: 10.2.6
semver: 7.8.5 semver: 7.8.5
tinyglobby: 0.2.17 tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@6.0.2) ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.2 typescript: 6.0.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2)': '@typescript-eslint/utils@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0)) '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))
'@typescript-eslint/scope-manager': 8.67.0 '@typescript-eslint/scope-manager': 8.67.0
'@typescript-eslint/types': 8.67.0 '@typescript-eslint/types': 8.67.0
'@typescript-eslint/typescript-estree': 8.67.0(supports-color@7.2.0)(typescript@6.0.2) '@typescript-eslint/typescript-estree': 8.67.0(supports-color@7.2.0)(typescript@6.0.3)
eslint: 10.8.1(jiti@2.7.0)(supports-color@7.2.0) eslint: 10.8.1(jiti@2.7.0)(supports-color@7.2.0)
typescript: 6.0.2 typescript: 6.0.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -6497,6 +6625,66 @@ snapshots:
'@typescript-eslint/types': 8.67.0 '@typescript-eslint/types': 8.67.0
eslint-visitor-keys: 5.0.1 eslint-visitor-keys: 5.0.1
'@typescript/typescript-aix-ppc64@7.0.2':
optional: true
'@typescript/typescript-darwin-arm64@7.0.2':
optional: true
'@typescript/typescript-darwin-x64@7.0.2':
optional: true
'@typescript/typescript-freebsd-arm64@7.0.2':
optional: true
'@typescript/typescript-freebsd-x64@7.0.2':
optional: true
'@typescript/typescript-linux-arm64@7.0.2':
optional: true
'@typescript/typescript-linux-arm@7.0.2':
optional: true
'@typescript/typescript-linux-loong64@7.0.2':
optional: true
'@typescript/typescript-linux-mips64el@7.0.2':
optional: true
'@typescript/typescript-linux-ppc64@7.0.2':
optional: true
'@typescript/typescript-linux-riscv64@7.0.2':
optional: true
'@typescript/typescript-linux-s390x@7.0.2':
optional: true
'@typescript/typescript-linux-x64@7.0.2':
optional: true
'@typescript/typescript-netbsd-arm64@7.0.2':
optional: true
'@typescript/typescript-netbsd-x64@7.0.2':
optional: true
'@typescript/typescript-openbsd-arm64@7.0.2':
optional: true
'@typescript/typescript-openbsd-x64@7.0.2':
optional: true
'@typescript/typescript-sunos-x64@7.0.2':
optional: true
'@typescript/typescript-win32-arm64@7.0.2':
optional: true
'@typescript/typescript-win32-x64@7.0.2':
optional: true
'@ungap/structured-clone@1.3.3': {} '@ungap/structured-clone@1.3.3': {}
'@visx/curve@4.0.1-alpha.0': '@visx/curve@4.0.1-alpha.0':
@@ -6576,16 +6764,16 @@ snapshots:
d3-time-format: 4.1.0 d3-time-format: 4.1.0
internmap: 2.0.3 internmap: 2.0.3
'@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@26.2.0)(lightningcss@1.33.0))(vue@3.5.41(typescript@6.0.2))': '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@26.2.0)(lightningcss@1.33.0))(vue@3.5.41(typescript@6.0.3))':
dependencies: dependencies:
vite: 5.4.21(@types/node@26.2.0)(lightningcss@1.33.0) vite: 5.4.21(@types/node@26.2.0)(lightningcss@1.33.0)
vue: 3.5.41(typescript@6.0.2) vue: 3.5.41(typescript@6.0.3)
'@vitejs/plugin-vue@6.0.8(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))(vue@3.5.26(typescript@6.0.2))': '@vitejs/plugin-vue@6.0.8(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))(vue@3.5.26(typescript@6.0.3))':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.1 '@rolldown/pluginutils': 1.0.1
vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12) vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)
vue: 3.5.26(typescript@6.0.2) vue: 3.5.26(typescript@6.0.3)
'@vitest/expect@4.1.10': '@vitest/expect@4.1.10':
dependencies: dependencies:
@@ -6634,13 +6822,13 @@ snapshots:
'@volar/source-map@2.4.28': {} '@volar/source-map@2.4.28': {}
'@volar/typescript@2.4.28(typescript@6.0.2)': '@volar/typescript@2.4.28(typescript@6.0.3)':
dependencies: dependencies:
'@volar/language-core': 2.4.28 '@volar/language-core': 2.4.28
path-browserify: 1.0.1 path-browserify: 1.0.1
vscode-uri: 3.1.0 vscode-uri: 3.1.0
optionalDependencies: optionalDependencies:
typescript: 6.0.2 typescript: 6.0.3
'@vue/compiler-core@3.5.26': '@vue/compiler-core@3.5.26':
dependencies: dependencies:
@@ -6782,11 +6970,11 @@ snapshots:
'@vue/shared': 3.5.41 '@vue/shared': 3.5.41
csstype: 3.2.3 csstype: 3.2.3
'@vue/server-renderer@3.5.26(vue@3.5.26(typescript@6.0.2))': '@vue/server-renderer@3.5.26(vue@3.5.26(typescript@6.0.3))':
dependencies: dependencies:
'@vue/compiler-ssr': 3.5.26 '@vue/compiler-ssr': 3.5.26
'@vue/shared': 3.5.26 '@vue/shared': 3.5.26
vue: 3.5.26(typescript@6.0.2) vue: 3.5.26(typescript@6.0.3)
'@vue/server-renderer@3.5.41': '@vue/server-renderer@3.5.41':
dependencies: dependencies:
@@ -6798,34 +6986,34 @@ snapshots:
'@vue/shared@3.5.41': {} '@vue/shared@3.5.41': {}
'@vueuse/core@12.8.2(typescript@6.0.2)': '@vueuse/core@12.8.2(typescript@6.0.3)':
dependencies: dependencies:
'@types/web-bluetooth': 0.0.21 '@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 12.8.2 '@vueuse/metadata': 12.8.2
'@vueuse/shared': 12.8.2(typescript@6.0.2) '@vueuse/shared': 12.8.2(typescript@6.0.3)
vue: 3.5.41(typescript@6.0.2) vue: 3.5.41(typescript@6.0.3)
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
'@vueuse/core@13.9.0(vue@3.5.26(typescript@6.0.2))': '@vueuse/core@13.9.0(vue@3.5.26(typescript@6.0.3))':
dependencies: dependencies:
'@types/web-bluetooth': 0.0.21 '@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 13.9.0 '@vueuse/metadata': 13.9.0
'@vueuse/shared': 13.9.0(vue@3.5.26(typescript@6.0.2)) '@vueuse/shared': 13.9.0(vue@3.5.26(typescript@6.0.3))
vue: 3.5.26(typescript@6.0.2) vue: 3.5.26(typescript@6.0.3)
'@vueuse/core@14.1.0(vue@3.5.26(typescript@6.0.2))': '@vueuse/core@14.1.0(vue@3.5.26(typescript@6.0.3))':
dependencies: dependencies:
'@types/web-bluetooth': 0.0.21 '@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.1.0 '@vueuse/metadata': 14.1.0
'@vueuse/shared': 14.1.0(vue@3.5.26(typescript@6.0.2)) '@vueuse/shared': 14.1.0(vue@3.5.26(typescript@6.0.3))
vue: 3.5.26(typescript@6.0.2) vue: 3.5.26(typescript@6.0.3)
'@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@6.0.2)': '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@6.0.3)':
dependencies: dependencies:
'@vueuse/core': 12.8.2(typescript@6.0.2) '@vueuse/core': 12.8.2(typescript@6.0.3)
'@vueuse/shared': 12.8.2(typescript@6.0.2) '@vueuse/shared': 12.8.2(typescript@6.0.3)
vue: 3.5.41(typescript@6.0.2) vue: 3.5.41(typescript@6.0.3)
optionalDependencies: optionalDependencies:
focus-trap: 7.8.0 focus-trap: 7.8.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -6837,19 +7025,19 @@ snapshots:
'@vueuse/metadata@14.1.0': {} '@vueuse/metadata@14.1.0': {}
'@vueuse/shared@12.8.2(typescript@6.0.2)': '@vueuse/shared@12.8.2(typescript@6.0.3)':
dependencies: dependencies:
vue: 3.5.41(typescript@6.0.2) vue: 3.5.41(typescript@6.0.3)
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
'@vueuse/shared@13.9.0(vue@3.5.26(typescript@6.0.2))': '@vueuse/shared@13.9.0(vue@3.5.26(typescript@6.0.3))':
dependencies: dependencies:
vue: 3.5.26(typescript@6.0.2) vue: 3.5.26(typescript@6.0.3)
'@vueuse/shared@14.1.0(vue@3.5.26(typescript@6.0.2))': '@vueuse/shared@14.1.0(vue@3.5.26(typescript@6.0.3))':
dependencies: dependencies:
vue: 3.5.26(typescript@6.0.2) vue: 3.5.26(typescript@6.0.3)
'@yuku-codegen/binding-android-arm64@0.8.4': '@yuku-codegen/binding-android-arm64@0.8.4':
optional: true optional: true
@@ -7419,7 +7607,7 @@ snapshots:
optionalDependencies: optionalDependencies:
eslint-config-prettier: 10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0)) eslint-config-prettier: 10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))
eslint-plugin-vue@10.10.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2))(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(vue-eslint-parser@10.4.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)): eslint-plugin-vue@10.10.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(vue-eslint-parser@10.4.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0)) '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))
eslint: 10.8.1(jiti@2.7.0)(supports-color@7.2.0) eslint: 10.8.1(jiti@2.7.0)(supports-color@7.2.0)
@@ -7430,7 +7618,7 @@ snapshots:
vue-eslint-parser: 10.4.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) vue-eslint-parser: 10.4.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
xml-name-validator: 5.0.0 xml-name-validator: 5.0.0
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2) '@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
eslint-scope@9.1.2: eslint-scope@9.1.2:
dependencies: dependencies:
@@ -8241,12 +8429,12 @@ snapshots:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
pinia@3.0.4(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2)): pinia@3.0.4(typescript@6.0.3)(vue@3.5.26(typescript@6.0.3)):
dependencies: dependencies:
'@vue/devtools-api': 7.7.9 '@vue/devtools-api': 7.7.9
vue: 3.5.26(typescript@6.0.2) vue: 3.5.26(typescript@6.0.3)
optionalDependencies: optionalDependencies:
typescript: 6.0.2 typescript: 6.0.3
pino-abstract-transport@2.0.0: pino-abstract-transport@2.0.0:
dependencies: dependencies:
@@ -8392,16 +8580,16 @@ snapshots:
prettier@3.9.6: {} prettier@3.9.6: {}
prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.2): prisma@7.9.1(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.3):
dependencies: dependencies:
'@prisma/config': 7.9.1 '@prisma/config': 7.9.1
'@prisma/dev': 0.24.17(typescript@6.0.2) '@prisma/dev': 0.24.17(typescript@6.0.3)
'@prisma/engines': 7.9.1 '@prisma/engines': 7.9.1
'@prisma/studio-core': 0.33.0(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@prisma/studio-core': 0.33.0(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
mysql2: 3.15.3 mysql2: 3.15.3
postgres: 3.4.7 postgres: 3.4.7
optionalDependencies: optionalDependencies:
typescript: 6.0.2 typescript: 6.0.3
transitivePeerDependencies: transitivePeerDependencies:
- '@types/react' - '@types/react'
- '@types/react-dom' - '@types/react-dom'
@@ -8627,7 +8815,7 @@ snapshots:
robust-predicates@3.0.3: {} robust-predicates@3.0.3: {}
rolldown-plugin-dts@0.27.14(@volar/typescript@2.4.28(typescript@6.0.2))(rolldown@1.2.3)(typescript@6.0.2)(vue-tsc@3.3.9(typescript@6.0.2)): rolldown-plugin-dts@0.27.14(@volar/typescript@2.4.28(typescript@6.0.3))(rolldown@1.2.3)(typescript@6.0.3)(vue-tsc@3.3.9(typescript@6.0.3)):
dependencies: dependencies:
dts-resolver: 3.0.0 dts-resolver: 3.0.0
get-tsconfig: 5.0.0-beta.5 get-tsconfig: 5.0.0-beta.5
@@ -8637,9 +8825,9 @@ snapshots:
yuku-codegen: 0.8.4 yuku-codegen: 0.8.4
yuku-parser: 0.8.4 yuku-parser: 0.8.4
optionalDependencies: optionalDependencies:
'@volar/typescript': 2.4.28(typescript@6.0.2) '@volar/typescript': 2.4.28(typescript@6.0.3)
typescript: 6.0.2 typescript: 6.0.3
vue-tsc: 3.3.9(typescript@6.0.2) vue-tsc: 3.3.9(typescript@6.0.3)
transitivePeerDependencies: transitivePeerDependencies:
- oxc-resolver - oxc-resolver
@@ -8921,11 +9109,11 @@ snapshots:
trim-lines@3.0.1: {} trim-lines@3.0.1: {}
ts-api-utils@2.5.0(typescript@6.0.2): ts-api-utils@2.5.0(typescript@6.0.3):
dependencies: dependencies:
typescript: 6.0.2 typescript: 6.0.3
tsdown@0.22.14(@volar/typescript@2.4.28(typescript@6.0.2))(tsx@4.23.12)(typescript@6.0.2)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.2)): tsdown@0.22.14(@volar/typescript@2.4.28(typescript@6.0.3))(tsx@4.23.12)(typescript@6.0.3)(unrun@0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13))(vue-tsc@3.3.9(typescript@6.0.3)):
dependencies: dependencies:
ansis: 4.3.1 ansis: 4.3.1
cac: 7.0.0 cac: 7.0.0
@@ -8936,7 +9124,7 @@ snapshots:
obug: 2.1.4 obug: 2.1.4
picomatch: 4.0.5 picomatch: 4.0.5
rolldown: 1.2.3 rolldown: 1.2.3
rolldown-plugin-dts: 0.27.14(@volar/typescript@2.4.28(typescript@6.0.2))(rolldown@1.2.3)(typescript@6.0.2)(vue-tsc@3.3.9(typescript@6.0.2)) rolldown-plugin-dts: 0.27.14(@volar/typescript@2.4.28(typescript@6.0.3))(rolldown@1.2.3)(typescript@6.0.3)(vue-tsc@3.3.9(typescript@6.0.3))
tinyexec: 1.3.0 tinyexec: 1.3.0
tinyglobby: 0.2.17 tinyglobby: 0.2.17
tree-kill: 1.2.2 tree-kill: 1.2.2
@@ -8944,7 +9132,7 @@ snapshots:
verkit: 0.3.2 verkit: 0.3.2
optionalDependencies: optionalDependencies:
tsx: 4.23.12 tsx: 4.23.12
typescript: 6.0.2 typescript: 6.0.3
unrun: 0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13) unrun: 0.2.22(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13)
transitivePeerDependencies: transitivePeerDependencies:
- '@typescript/native-preview' - '@typescript/native-preview'
@@ -8982,18 +9170,41 @@ snapshots:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
typescript-eslint@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2): typescript-eslint@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2))(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2) '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
'@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2) '@typescript-eslint/parser': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.67.0(supports-color@7.2.0)(typescript@6.0.2) '@typescript-eslint/typescript-estree': 8.67.0(supports-color@7.2.0)(typescript@6.0.3)
'@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2) '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
eslint: 10.8.1(jiti@2.7.0)(supports-color@7.2.0) eslint: 10.8.1(jiti@2.7.0)(supports-color@7.2.0)
typescript: 6.0.2 typescript: 6.0.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
typescript@6.0.2: {} typescript@6.0.3: {}
typescript@7.0.2:
optionalDependencies:
'@typescript/typescript-aix-ppc64': 7.0.2
'@typescript/typescript-darwin-arm64': 7.0.2
'@typescript/typescript-darwin-x64': 7.0.2
'@typescript/typescript-freebsd-arm64': 7.0.2
'@typescript/typescript-freebsd-x64': 7.0.2
'@typescript/typescript-linux-arm': 7.0.2
'@typescript/typescript-linux-arm64': 7.0.2
'@typescript/typescript-linux-loong64': 7.0.2
'@typescript/typescript-linux-mips64el': 7.0.2
'@typescript/typescript-linux-ppc64': 7.0.2
'@typescript/typescript-linux-riscv64': 7.0.2
'@typescript/typescript-linux-s390x': 7.0.2
'@typescript/typescript-linux-x64': 7.0.2
'@typescript/typescript-netbsd-arm64': 7.0.2
'@typescript/typescript-netbsd-x64': 7.0.2
'@typescript/typescript-openbsd-arm64': 7.0.2
'@typescript/typescript-openbsd-x64': 7.0.2
'@typescript/typescript-sunos-x64': 7.0.2
'@typescript/typescript-win32-arm64': 7.0.2
'@typescript/typescript-win32-x64': 7.0.2
uc.micro@2.1.0: {} uc.micro@2.1.0: {}
@@ -9049,9 +9260,9 @@ snapshots:
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
valibot@1.4.2(typescript@6.0.2): valibot@1.4.2(typescript@6.0.3):
optionalDependencies: optionalDependencies:
typescript: 6.0.2 typescript: 6.0.3
verkit@0.3.2: {} verkit@0.3.2: {}
@@ -9089,7 +9300,7 @@ snapshots:
jiti: 2.7.0 jiti: 2.7.0
tsx: 4.23.12 tsx: 4.23.12
vitepress@1.6.4(@algolia/client-search@5.56.0)(@types/node@26.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(typescript@6.0.2): vitepress@1.6.4(@algolia/client-search@5.56.0)(@types/node@26.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(typescript@6.0.3):
dependencies: dependencies:
'@docsearch/css': 3.8.2 '@docsearch/css': 3.8.2
'@docsearch/js': 3.8.2(@algolia/client-search@5.56.0)(search-insights@2.17.3) '@docsearch/js': 3.8.2(@algolia/client-search@5.56.0)(search-insights@2.17.3)
@@ -9098,17 +9309,17 @@ snapshots:
'@shikijs/transformers': 2.5.0 '@shikijs/transformers': 2.5.0
'@shikijs/types': 2.5.0 '@shikijs/types': 2.5.0
'@types/markdown-it': 14.1.2 '@types/markdown-it': 14.1.2
'@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@26.2.0)(lightningcss@1.33.0))(vue@3.5.41(typescript@6.0.2)) '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@26.2.0)(lightningcss@1.33.0))(vue@3.5.41(typescript@6.0.3))
'@vue/devtools-api': 7.7.10 '@vue/devtools-api': 7.7.10
'@vue/shared': 3.5.41 '@vue/shared': 3.5.41
'@vueuse/core': 12.8.2(typescript@6.0.2) '@vueuse/core': 12.8.2(typescript@6.0.3)
'@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@6.0.2) '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@6.0.3)
focus-trap: 7.8.0 focus-trap: 7.8.0
mark.js: 8.11.1 mark.js: 8.11.1
minisearch: 7.2.0 minisearch: 7.2.0
shiki: 2.5.0 shiki: 2.5.0
vite: 5.4.21(@types/node@26.2.0)(lightningcss@1.33.0) vite: 5.4.21(@types/node@26.2.0)(lightningcss@1.33.0)
vue: 3.5.41(typescript@6.0.2) vue: 3.5.41(typescript@6.0.3)
optionalDependencies: optionalDependencies:
postcss: 8.5.26 postcss: 8.5.26
transitivePeerDependencies: transitivePeerDependencies:
@@ -9187,28 +9398,28 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
vue-router@4.6.4(vue@3.5.26(typescript@6.0.2)): vue-router@4.6.4(vue@3.5.26(typescript@6.0.3)):
dependencies: dependencies:
'@vue/devtools-api': 6.6.4 '@vue/devtools-api': 6.6.4
vue: 3.5.26(typescript@6.0.2) vue: 3.5.26(typescript@6.0.3)
vue-tsc@3.3.9(typescript@6.0.2): vue-tsc@3.3.9(typescript@6.0.3):
dependencies: dependencies:
'@volar/typescript': 2.4.28(typescript@6.0.2) '@volar/typescript': 2.4.28(typescript@6.0.3)
'@vue/language-core': 3.3.9 '@vue/language-core': 3.3.9
typescript: 6.0.2 typescript: 6.0.3
vue@3.5.26(typescript@6.0.2): vue@3.5.26(typescript@6.0.3):
dependencies: dependencies:
'@vue/compiler-dom': 3.5.26 '@vue/compiler-dom': 3.5.26
'@vue/compiler-sfc': 3.5.26 '@vue/compiler-sfc': 3.5.26
'@vue/runtime-dom': 3.5.26 '@vue/runtime-dom': 3.5.26
'@vue/server-renderer': 3.5.26(vue@3.5.26(typescript@6.0.2)) '@vue/server-renderer': 3.5.26(vue@3.5.26(typescript@6.0.3))
'@vue/shared': 3.5.26 '@vue/shared': 3.5.26
optionalDependencies: optionalDependencies:
typescript: 6.0.2 typescript: 6.0.3
vue@3.5.41(typescript@6.0.2): vue@3.5.41(typescript@6.0.3):
dependencies: dependencies:
'@vue/compiler-dom': 3.5.41 '@vue/compiler-dom': 3.5.41
'@vue/compiler-sfc': 3.5.41 '@vue/compiler-sfc': 3.5.41
@@ -9216,7 +9427,7 @@ snapshots:
'@vue/server-renderer': 3.5.41 '@vue/server-renderer': 3.5.41
'@vue/shared': 3.5.41 '@vue/shared': 3.5.41
optionalDependencies: optionalDependencies:
typescript: 6.0.2 typescript: 6.0.3
w3c-keyname@2.2.8: {} w3c-keyname@2.2.8: {}
+1 -1
View File
@@ -5,7 +5,7 @@ packages:
overrides: overrides:
postcss: 8.5.26 postcss: 8.5.26
typescript: 6.0.2 typescript: 6.0.3
allowBuilds: allowBuilds:
'@prisma/engines': true '@prisma/engines': true
+2 -2
View File
@@ -4,11 +4,11 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "pnpm -w tsc7 -p tools/build-scripts/tsconfig.json",
"dev": "node -e \"console.log('dev not configured')\"", "dev": "node -e \"console.log('dev not configured')\"",
"lint": "node -e \"console.log('lint not configured')\"", "lint": "node -e \"console.log('lint not configured')\"",
"test": "node -e \"console.log('test not configured')\"", "test": "node -e \"console.log('test not configured')\"",
"typecheck": "tsc -b", "typecheck": "pnpm -w tsc7 -b tools/build-scripts/tsconfig.json",
"build:server": "node ./build-server.mjs" "build:server": "node ./build-server.mjs"
} }
} }
-1
View File
@@ -1,7 +1,6 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"baseUrl": "../..",
"outDir": "dist", "outDir": "dist",
"rootDir": "src", "rootDir": "src",
"composite": true "composite": true
+38
View File
@@ -0,0 +1,38 @@
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const expectedApiVersion = '6.0.3';
const expectedCliVersion = '7.0.2';
const consumers = [
['root', 'package.json'],
['game frontend', 'app/game-frontend/package.json'],
['gateway frontend', 'app/gateway-frontend/package.json'],
];
for (const [label, packagePath] of consumers) {
const requireFromConsumer = createRequire(path.join(workspaceRoot, packagePath));
const version = requireFromConsumer('typescript').version;
if (version !== expectedApiVersion) {
throw new Error(`${label} resolved TypeScript API ${version}; expected ${expectedApiVersion}`);
}
}
const nativeTscPath = path.join(workspaceRoot, 'node_modules/@typescript/native/bin/tsc');
const nativeResult = spawnSync(process.execPath, [nativeTscPath, '--version'], {
cwd: workspaceRoot,
encoding: 'utf8',
});
if (nativeResult.status !== 0) {
throw new Error(nativeResult.stderr || nativeResult.stdout || 'TypeScript 7 tsc failed');
}
const cliVersion = nativeResult.stdout.trim().replace(/^Version\s+/, '');
if (cliVersion !== expectedCliVersion) {
throw new Error(`native tsc resolved ${cliVersion}; expected ${expectedCliVersion}`);
}
console.log(`TypeScript API ${expectedApiVersion}; native tsc ${expectedCliVersion}`);
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"test:integration": "vitest run --config vitest.config.ts", "test:integration": "vitest run --config vitest.config.ts",
"typecheck": "tsc -b" "typecheck": "pnpm -w tsc7 -b tools/integration-tests/tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@sammo-ts/common": "workspace:*", "@sammo-ts/common": "workspace:*",
+2 -2
View File
@@ -11,7 +11,7 @@
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"test": "vitest run --config vitest.config.ts", "test": "vitest run --config vitest.config.ts",
"typecheck": "tsc -b", "typecheck": "pnpm -w tsc7 -b tools/legacy-db-migration/tsconfig.json",
"migrate": "tsx src/cli.ts" "migrate": "tsx src/cli.ts"
}, },
"dependencies": { "dependencies": {
@@ -23,7 +23,7 @@
"@types/pg": "^8.21.0", "@types/pg": "^8.21.0",
"tsdown": "^0.22.14", "tsdown": "^0.22.14",
"tsx": "^4.23.12", "tsx": "^4.23.12",
"typescript": "6.0.2", "typescript": "6.0.3",
"vitest": "^4.1.10" "vitest": "^4.1.10"
} }
} }
-1
View File
@@ -1,7 +1,6 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"baseUrl": "../..",
"noEmit": true, "noEmit": true,
"types": ["node"] "types": ["node"]
}, },
-1
View File
@@ -8,7 +8,6 @@
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"skipLibCheck": true, "skipLibCheck": true,
"ignoreDeprecations": "6.0",
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"sourceMap": true "sourceMap": true
+14 -15
View File
@@ -1,22 +1,21 @@
{ {
"extends": "./tsconfig.base.json", "extends": "./tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"baseUrl": ".",
"paths": { "paths": {
"@sammo-ts/common": ["packages/common/src/index.ts"], "@sammo-ts/common": ["./packages/common/src/index.ts"],
"@sammo-ts/common/*": ["packages/common/src/*"], "@sammo-ts/common/*": ["./packages/common/src/*"],
"@sammo-ts/infra": ["packages/infra/src/index.ts"], "@sammo-ts/infra": ["./packages/infra/src/index.ts"],
"@sammo-ts/infra/*": ["packages/infra/src/*"], "@sammo-ts/infra/*": ["./packages/infra/src/*"],
"@sammo-ts/logic": ["packages/logic/src/index.ts"], "@sammo-ts/logic": ["./packages/logic/src/index.ts"],
"@sammo-ts/logic/*": ["packages/logic/src/*"], "@sammo-ts/logic/*": ["./packages/logic/src/*"],
"@sammo-ts/logic/resources": ["packages/logic/src/resources/index.ts"], "@sammo-ts/logic/resources": ["./packages/logic/src/resources/index.ts"],
"@sammo-ts/logic/resources/*": ["packages/logic/src/resources/*"], "@sammo-ts/logic/resources/*": ["./packages/logic/src/resources/*"],
"@sammo-ts/gateway-api": ["app/gateway-api/src/index.ts"], "@sammo-ts/gateway-api": ["./app/gateway-api/src/index.ts"],
"@sammo-ts/gateway-api/*": ["app/gateway-api/src/*"], "@sammo-ts/gateway-api/*": ["./app/gateway-api/src/*"],
"@sammo-ts/game-api": ["app/game-api/src/index.ts"], "@sammo-ts/game-api": ["./app/game-api/src/index.ts"],
"@sammo-ts/game-api/*": ["app/game-api/src/*"], "@sammo-ts/game-api/*": ["./app/game-api/src/*"],
"@sammo-ts/game-engine": ["app/game-engine/src/index.ts"], "@sammo-ts/game-engine": ["./app/game-engine/src/index.ts"],
"@sammo-ts/game-engine/*": ["app/game-engine/src/*"] "@sammo-ts/game-engine/*": ["./app/game-engine/src/*"]
} }
} }
} }