Merge remote-tracking branch 'origin/main' into fix/existing-test-failures-20260812
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"typecheck": "tsc -b"
|
||||
"typecheck": "pnpm -w tsc7 -b app/game-api/tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sanitize-html": "2.16.1",
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -3,7 +3,9 @@ import { z } from 'zod';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { resolveOfficerLevelName, sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js';
|
||||
import { readOnlyAuthedProcedure, router } from '../../trpc.js';
|
||||
import { loadTraitNames } from '../nation/shared.js';
|
||||
|
||||
const numberOrNull = (value: unknown): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
@@ -87,6 +89,31 @@ export const archiveRouter = router({
|
||||
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<
|
||||
string,
|
||||
@@ -110,6 +137,7 @@ export const archiveRouter = router({
|
||||
experience: number | null;
|
||||
dedication: number | null;
|
||||
officerLevel: number | null;
|
||||
officerLevelText: string | null;
|
||||
personal: string | null;
|
||||
special: 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);
|
||||
let season = seasons.get(general.serverId);
|
||||
if (!season) {
|
||||
@@ -136,10 +164,12 @@ export const archiveRouter = router({
|
||||
|
||||
const data = asRecord(general.data);
|
||||
const stats = asRecord(data.stats);
|
||||
const role = asRecord(data.role);
|
||||
const nationId = firstNumber(data, 'nationId', 'nation') ?? 0;
|
||||
const nation = nationByServerAndId.get(`${general.serverId}:${nationId}`);
|
||||
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({
|
||||
generalNo: general.generalNo,
|
||||
name: general.name,
|
||||
@@ -152,10 +182,14 @@ export const archiveRouter = router({
|
||||
intel: firstNumber(data, 'intel', 'intelligence') ?? numberOrNull(stats.intelligence),
|
||||
experience: numberOrNull(data.experience),
|
||||
dedication: numberOrNull(data.dedication),
|
||||
officerLevel: firstNumber(data, 'officerLevel', 'officer_level'),
|
||||
personal: displayTextOrNull(data.personalCode ?? data.personal ?? role.personality),
|
||||
special: displayTextOrNull(data.specialCode ?? data.special ?? role.specialDomestic),
|
||||
special2: displayTextOrNull(data.special2Code ?? data.special2 ?? role.specialWar),
|
||||
officerLevel,
|
||||
officerLevelText:
|
||||
officerLevel === null
|
||||
? 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,6 +17,16 @@ import {
|
||||
import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js';
|
||||
import { resolveAccessWindows } from '../../services/generalAccess.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 { loadTraitNames, resolveNationNotice, type TraitNameMap } from '../nation/shared.js';
|
||||
|
||||
@@ -155,7 +165,7 @@ const resolveTraitDisplayName = (code: string, names: TraitNameMap): string => {
|
||||
return loadedName;
|
||||
}
|
||||
// Ref는 class getName()을 표시하므로 로더가 모르는 선택적 특기도 raw namespace는 노출하지 않는다.
|
||||
return code.replace(/^che_(?:event_)?/u, '');
|
||||
return sanitizeInternalDisplayCode(code);
|
||||
};
|
||||
|
||||
const resolveUserSettings = (meta: Record<string, unknown>) => {
|
||||
@@ -244,7 +254,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [city, nation, worldState] = await Promise.all([
|
||||
const [city, queriedNation, worldState] = await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
where: { id: general.cityId },
|
||||
@@ -291,18 +301,36 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
: Promise.resolve(NEUTRAL_NATION_CONTEXT),
|
||||
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.specialCode], 'domestic'),
|
||||
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 worldConfig = asRecord(worldState?.config);
|
||||
const constValues = asRecord(worldConfig.const ?? worldConfig.consts);
|
||||
const maxDedicationLevel = readNumber(constValues.maxDedLevel, 30);
|
||||
const settings = resolveUserSettings(metaRecord);
|
||||
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 {
|
||||
general: {
|
||||
@@ -315,6 +343,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
picture: general.picture,
|
||||
imageServer: general.imageServer,
|
||||
officerLevel: general.officerLevel,
|
||||
officerLevelText: resolveOfficerLevelName(general.officerLevel, nation.level),
|
||||
stats: {
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
@@ -331,6 +360,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
age: general.age,
|
||||
turnTime: general.turnTime.toISOString(),
|
||||
crewTypeId: general.crewTypeId,
|
||||
crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-',
|
||||
traits: {
|
||||
personal: resolveTraitDisplayName(general.personalCode, personalityNames),
|
||||
specialDomestic: resolveTraitDisplayName(general.specialCode, domesticNames),
|
||||
@@ -338,7 +368,8 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
},
|
||||
progression: {
|
||||
experienceLevel: readNumber(metaRecord.explevel, 0),
|
||||
dedicationLevel: readNumber(metaRecord.dedlevel, 0),
|
||||
dedicationLevel,
|
||||
dedicationText: resolveDedicationLevelName(dedicationLevel, maxDedicationLevel),
|
||||
statExperience: {
|
||||
leadership: readNumber(metaRecord.leadership_exp, 0),
|
||||
strength: readNumber(metaRecord.strength_exp, 0),
|
||||
@@ -353,6 +384,12 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
book: normalizeItemCode(general.bookCode),
|
||||
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 ?? []),
|
||||
canChangeIcon: general.npcState === 0 && ctx.auth?.user.canUseGeneralPicture !== false,
|
||||
@@ -360,8 +397,23 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
|
||||
typeof metaRecord.generalIconChangedAt === 'string'
|
||||
? new Date(new Date(metaRecord.generalIconChangedAt).getTime() + 24 * 60 * 60 * 1000).toISOString()
|
||||
: null,
|
||||
city,
|
||||
nation,
|
||||
city: city
|
||||
? {
|
||||
...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,
|
||||
penalties,
|
||||
};
|
||||
|
||||
@@ -4,8 +4,15 @@ import { asRecord } from '@sammo-ts/common';
|
||||
import { LogCategory } from '@sammo-ts/logic';
|
||||
|
||||
import { accessAuthedProcedure } from '../../../trpc.js';
|
||||
import {
|
||||
loadCrewTypeDisplayNames,
|
||||
loadItemDisplayNames,
|
||||
resolveDedicationLevelName,
|
||||
resolveOfficerLevelName,
|
||||
sanitizeInternalDisplayCode,
|
||||
} from '../../../services/gameDisplayNames.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 }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
@@ -98,6 +105,36 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
typeof constValues.upgradeLimit === 'number' && Number.isFinite(constValues.upgradeLimit)
|
||||
? constValues.upgradeLimit
|
||||
: 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 meta =
|
||||
@@ -108,6 +145,11 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
const value = meta[key];
|
||||
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 {
|
||||
id: general.id,
|
||||
name: general.name,
|
||||
@@ -115,6 +157,7 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
imageServer: general.imageServer,
|
||||
npcState: general.npcState,
|
||||
officerLevel: general.officerLevel,
|
||||
officerLevelText: resolveOfficerLevelName(general.officerLevel, nation.level),
|
||||
cityId: general.cityId,
|
||||
turnTime: formatDateTime(general.turnTime),
|
||||
recentWar: formatDateTime(general.recentWarTime),
|
||||
@@ -134,19 +177,28 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
atmos: general.atmos,
|
||||
age: general.age,
|
||||
crewTypeId: general.crewTypeId,
|
||||
crewTypeName: crewTypeNames.get(general.crewTypeId) ?? '-',
|
||||
equipment: {
|
||||
weapon: general.weaponCode,
|
||||
book: general.bookCode,
|
||||
horse: general.horseCode,
|
||||
item: general.itemCode,
|
||||
},
|
||||
equipmentNames: {
|
||||
weapon: itemName(general.weaponCode),
|
||||
book: itemName(general.bookCode),
|
||||
horse: itemName(general.horseCode),
|
||||
item: itemName(general.itemCode),
|
||||
},
|
||||
traits: {
|
||||
personal: general.personalCode,
|
||||
specialDomestic: general.specialCode,
|
||||
specialWar: general.special2Code,
|
||||
personal: traitName(general.personalCode, personalityNames),
|
||||
specialDomestic: traitName(general.specialCode, domesticNames),
|
||||
specialWar: traitName(general.special2Code, warNames),
|
||||
},
|
||||
progression: {
|
||||
experienceLevel: metaNumber('explevel'),
|
||||
dedicationLevel,
|
||||
dedicationText: resolveDedicationLevelName(dedicationLevel, maxDedicationLevel),
|
||||
statExperience: {
|
||||
leadership: metaNumber('leadership_exp'),
|
||||
strength: metaNumber('strength_exp'),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { accessAuthedProcedure } from '../../../trpc.js';
|
||||
import { resolveDedicationLevelName, sanitizeInternalDisplayCode } from '../../../services/gameDisplayNames.js';
|
||||
import { getMyGeneral } from '../../shared/general.js';
|
||||
import {
|
||||
assertNationAccess,
|
||||
@@ -15,8 +17,8 @@ const experienceLevel = (experience: number): number =>
|
||||
0,
|
||||
Math.min(100, experience < 1000 ? Math.floor(experience / 100) : Math.floor(Math.sqrt(experience / 10)))
|
||||
);
|
||||
const dedicationLevel = (dedication: number): number =>
|
||||
Math.max(0, Math.min(10, Math.ceil(Math.sqrt(dedication) / 10)));
|
||||
const dedicationLevel = (dedication: number, maxLevel: number): number =>
|
||||
Math.max(0, Math.min(maxLevel, Math.ceil(Math.sqrt(dedication) / 10)));
|
||||
|
||||
export const getGeneralList = accessAuthedProcedure.query(async ({ 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 nationTrait = (await loadTraitNames([nation.typeCode], 'nation')).get(nation.typeCode);
|
||||
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 entryDedicationLevel = dedicationLevel(entry.dedication, maxDedicationLevel);
|
||||
const dedicationDisplay = {
|
||||
dedicationLevel: entryDedicationLevel,
|
||||
dedicationText: resolveDedicationLevelName(entryDedicationLevel, maxDedicationLevel),
|
||||
bill: entryDedicationLevel * 200 + 400,
|
||||
};
|
||||
const { permission: _targetPermission, ...safeEntry } = entry;
|
||||
if (permission >= 1) {
|
||||
return {
|
||||
...safeEntry,
|
||||
refreshScoreTotal: accessByGeneral.get(entry.id) ?? 0,
|
||||
experienceLevel: experienceLevel(entry.experience),
|
||||
dedicationLevel: dedicationLevel(entry.dedication),
|
||||
...dedicationDisplay,
|
||||
};
|
||||
}
|
||||
const { crew: _crew, experience: _experience, dedication: _dedication, ...visible } = safeEntry;
|
||||
@@ -105,7 +115,7 @@ export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
officerCity: 0,
|
||||
officerCityName: null,
|
||||
experienceLevel: experienceLevel(entry.experience),
|
||||
dedicationLevel: dedicationLevel(entry.dedication),
|
||||
...dedicationDisplay,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -118,7 +128,7 @@ export const getGeneralList = accessAuthedProcedure.query(async ({ ctx }) => {
|
||||
typeCode: nation.typeCode,
|
||||
type: {
|
||||
key: nation.typeCode,
|
||||
name: nationTrait?.name ?? nation.typeCode,
|
||||
name: nationTrait?.name ?? sanitizeInternalDisplayCode(nation.typeCode),
|
||||
info: nationTrait?.info ?? '',
|
||||
},
|
||||
capitalCityId: nation.capitalCityId ?? 0,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
|
||||
import type { GameApiContext, InputJsonValue, WorldStateRow } from '../../context.js';
|
||||
import { purifyNationHtml } from '../../security/nationHtml.js';
|
||||
import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js';
|
||||
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||
|
||||
export type PermissionKind = 'normal' | 'ambassador' | 'auditor';
|
||||
@@ -313,10 +314,7 @@ export const loadTraitNames = async (keys: Array<string | null>, kind: keyof Tra
|
||||
}
|
||||
}
|
||||
if (eventFiltered.length) {
|
||||
const modules = await loadEventDomesticTraitModules(
|
||||
eventFiltered,
|
||||
new EventDomesticTraitLoader()
|
||||
);
|
||||
const modules = await loadEventDomesticTraitModules(eventFiltered, new EventDomesticTraitLoader());
|
||||
for (const module of modules) {
|
||||
cache.set(module.key, { name: module.name, info: module.info ?? '' });
|
||||
}
|
||||
@@ -513,21 +511,21 @@ export const mapGeneralList = async (
|
||||
personality: personalityKey
|
||||
? {
|
||||
key: personalityKey,
|
||||
name: personalityMap.get(personalityKey)?.name ?? personalityKey,
|
||||
name: personalityMap.get(personalityKey)?.name ?? sanitizeInternalDisplayCode(personalityKey),
|
||||
info: personalityMap.get(personalityKey)?.info ?? '',
|
||||
}
|
||||
: null,
|
||||
specialDomestic: domesticKey
|
||||
? {
|
||||
key: domesticKey,
|
||||
name: domesticMap.get(domesticKey)?.name ?? domesticKey,
|
||||
name: domesticMap.get(domesticKey)?.name ?? sanitizeInternalDisplayCode(domesticKey),
|
||||
info: domesticMap.get(domesticKey)?.info ?? '',
|
||||
}
|
||||
: null,
|
||||
specialWar: warKey
|
||||
? {
|
||||
key: warKey,
|
||||
name: warMap.get(warKey)?.name ?? warKey,
|
||||
name: warMap.get(warKey)?.name ?? sanitizeInternalDisplayCode(warKey),
|
||||
info: warMap.get(warKey)?.info ?? '',
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
||||
import { loadMapLayout } from '../../maps/mapLayout.js';
|
||||
import { loadPublicMap } from '../../maps/worldMap.js';
|
||||
import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js';
|
||||
import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js';
|
||||
import { accessInputProcedure, procedure, router, sessionActivityProcedure } from '../../trpc.js';
|
||||
import { loadTraitNames } from '../nation/shared.js';
|
||||
|
||||
@@ -478,174 +479,170 @@ export const publicRouter = router({
|
||||
}));
|
||||
}),
|
||||
getNpcList: accessInputProcedure(
|
||||
z
|
||||
.object({
|
||||
sort: z.number().int().min(1).max(8).catch(1).optional(),
|
||||
includeAllWithToken: z.boolean().optional(),
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const sort = (input?.sort ?? 1) as NpcListSort;
|
||||
const includeAllWithToken = input?.includeAllWithToken === true;
|
||||
if (includeAllWithToken && !ctx.auth) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const now = new Date(Math.floor(Date.now() / 1000) * 1000);
|
||||
const poolGeneralIds = includeAllWithToken
|
||||
? []
|
||||
: (
|
||||
await ctx.db.selectPoolEntry.findMany({
|
||||
where: { generalId: { not: null } },
|
||||
select: { generalId: true },
|
||||
})
|
||||
).flatMap(({ generalId }) => (generalId === null ? [] : [generalId]));
|
||||
const [generals, nations, activeTokens, worldState] = await Promise.all([
|
||||
ctx.db.general.findMany({
|
||||
...(includeAllWithToken
|
||||
? {}
|
||||
: {
|
||||
where: {
|
||||
OR: [
|
||||
{ npcState: 1 },
|
||||
{ npcState: 0, id: { in: poolGeneralIds } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
npcState: true,
|
||||
age: true,
|
||||
officerLevel: true,
|
||||
nationId: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
personalCode: true,
|
||||
specialCode: true,
|
||||
special2Code: true,
|
||||
meta: true,
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
ctx.db.nation.findMany({
|
||||
select: { id: true, name: true, level: true },
|
||||
}),
|
||||
includeAllWithToken
|
||||
? ctx.db.npcSelectionToken.findMany({
|
||||
where: { validUntil: { gte: now } },
|
||||
select: { pickResult: true },
|
||||
})
|
||||
: [],
|
||||
includeAllWithToken
|
||||
? ctx.db.worldState.findFirst({
|
||||
select: { config: true },
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
z
|
||||
.object({
|
||||
sort: z.number().int().min(1).max(8).catch(1).optional(),
|
||||
includeAllWithToken: z.boolean().optional(),
|
||||
})
|
||||
.optional()
|
||||
).query(async ({ ctx, input }) => {
|
||||
const sort = (input?.sort ?? 1) as NpcListSort;
|
||||
const includeAllWithToken = input?.includeAllWithToken === true;
|
||||
if (includeAllWithToken && !ctx.auth) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
const now = new Date(Math.floor(Date.now() / 1000) * 1000);
|
||||
const poolGeneralIds = includeAllWithToken
|
||||
? []
|
||||
: (
|
||||
await ctx.db.selectPoolEntry.findMany({
|
||||
where: { generalId: { not: null } },
|
||||
select: { generalId: true },
|
||||
})
|
||||
).flatMap(({ generalId }) => (generalId === null ? [] : [generalId]));
|
||||
const [generals, nations, activeTokens, worldState] = await Promise.all([
|
||||
ctx.db.general.findMany({
|
||||
...(includeAllWithToken
|
||||
? {}
|
||||
: {
|
||||
where: {
|
||||
OR: [{ npcState: 1 }, { npcState: 0, id: { in: poolGeneralIds } }],
|
||||
},
|
||||
}),
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
picture: true,
|
||||
imageServer: true,
|
||||
npcState: true,
|
||||
age: true,
|
||||
officerLevel: true,
|
||||
nationId: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
personalCode: true,
|
||||
specialCode: true,
|
||||
special2Code: true,
|
||||
meta: true,
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
ctx.db.nation.findMany({
|
||||
select: { id: true, name: true, level: true },
|
||||
}),
|
||||
includeAllWithToken
|
||||
? ctx.db.npcSelectionToken.findMany({
|
||||
where: { validUntil: { gte: now } },
|
||||
select: { pickResult: true },
|
||||
})
|
||||
: [],
|
||||
includeAllWithToken
|
||||
? ctx.db.worldState.findFirst({
|
||||
select: { config: true },
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
|
||||
const personalityKeys = generals.map((general) => normalizeTraitKey(general.personalCode));
|
||||
const domesticKeys = generals.map((general) => normalizeTraitKey(general.specialCode));
|
||||
const warKeys = generals.map((general) => normalizeTraitKey(general.special2Code));
|
||||
const [personalityMap, domesticMap, warMap] = await Promise.all([
|
||||
loadTraitNames(personalityKeys, 'personality'),
|
||||
loadTraitNames(domesticKeys, 'domestic'),
|
||||
loadTraitNames(warKeys, 'war'),
|
||||
]);
|
||||
const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
|
||||
const worldConfig = asRecord(worldState?.config);
|
||||
const worldConstants = asRecord(worldConfig.const);
|
||||
const maxLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxLevel, 255)));
|
||||
const maxDedLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxDedLevel, 30)));
|
||||
const personalityKeys = generals.map((general) => normalizeTraitKey(general.personalCode));
|
||||
const domesticKeys = generals.map((general) => normalizeTraitKey(general.specialCode));
|
||||
const warKeys = generals.map((general) => normalizeTraitKey(general.special2Code));
|
||||
const [personalityMap, domesticMap, warMap] = await Promise.all([
|
||||
loadTraitNames(personalityKeys, 'personality'),
|
||||
loadTraitNames(domesticKeys, 'domestic'),
|
||||
loadTraitNames(warKeys, 'war'),
|
||||
]);
|
||||
const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
|
||||
const worldConfig = asRecord(worldState?.config);
|
||||
const worldConstants = asRecord(worldConfig.const);
|
||||
const maxLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxLevel, 255)));
|
||||
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.
|
||||
// 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.
|
||||
const sourceRows = includeAllWithToken
|
||||
? generals
|
||||
: [
|
||||
...generals.filter((general) => general.npcState === 0),
|
||||
...generals.filter((general) => general.npcState === 1),
|
||||
];
|
||||
const rows = sourceRows.map((general) => {
|
||||
const meta = asRecord(general.meta);
|
||||
const personalityKey = normalizeTraitKey(general.personalCode);
|
||||
const domesticKey = normalizeTraitKey(general.specialCode);
|
||||
const warKey = normalizeTraitKey(general.special2Code);
|
||||
const ownerName =
|
||||
general.npcState === 1
|
||||
? typeof meta.owner_name === 'string'
|
||||
? meta.owner_name
|
||||
: typeof meta.ownerName === 'string'
|
||||
? 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))]] : [];
|
||||
})
|
||||
)
|
||||
);
|
||||
// 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.
|
||||
// selection list instead consumes the raw id-ordered full list before its own comparator.
|
||||
const sourceRows = includeAllWithToken
|
||||
? generals
|
||||
: [
|
||||
...generals.filter((general) => general.npcState === 0),
|
||||
...generals.filter((general) => general.npcState === 1),
|
||||
];
|
||||
const rows = sourceRows.map((general) => {
|
||||
const meta = asRecord(general.meta);
|
||||
const personalityKey = normalizeTraitKey(general.personalCode);
|
||||
const domesticKey = normalizeTraitKey(general.specialCode);
|
||||
const warKey = normalizeTraitKey(general.special2Code);
|
||||
const ownerName =
|
||||
general.npcState === 1
|
||||
? typeof meta.owner_name === 'string'
|
||||
? meta.owner_name
|
||||
: typeof meta.ownerName === 'string'
|
||||
? meta.ownerName
|
||||
: ''
|
||||
: '';
|
||||
|
||||
return {
|
||||
sort,
|
||||
generals: includeAllWithToken ? rows : sortNpcList(rows, sort),
|
||||
tokenKeepCounts,
|
||||
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 ?? 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,6 +2,7 @@ import { asRecord } from '@sammo-ts/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { accessAuthedInputProcedure, authedProcedure } from '../../trpc.js';
|
||||
import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js';
|
||||
import { loadTraitNames } from '../nation/shared.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { resolveSecretPermission } from '../shared/secretPermission.js';
|
||||
@@ -172,7 +173,7 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => {
|
||||
level: nation.level,
|
||||
type: {
|
||||
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'),
|
||||
capitalCityId: nation.capitalCityId ?? 0,
|
||||
|
||||
@@ -4,7 +4,7 @@ import fastifyStatic from '@fastify/static';
|
||||
import path from 'path';
|
||||
import fs from 'node:fs/promises';
|
||||
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 {
|
||||
createGamePostgresConnector,
|
||||
@@ -23,6 +23,7 @@ import { buildBattleSimQueueKeys } from './battleSim/keys.js';
|
||||
import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
|
||||
import { RedisRealtimeEventHub } from './realtime/eventHub.js';
|
||||
import { formatSseFrame } from './realtime/sse.js';
|
||||
import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent } from './realtime/publicEvent.js';
|
||||
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
|
||||
import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js';
|
||||
import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js';
|
||||
@@ -229,6 +230,17 @@ export const createGameApiServer = async () => {
|
||||
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();
|
||||
const requestOrigin = request.headers.origin;
|
||||
if (typeof requestOrigin === 'string' && requestOrigin.length > 0) {
|
||||
@@ -256,30 +268,47 @@ export const createGameApiServer = async () => {
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: 'ready',
|
||||
data: JSON.stringify({ at: new Date().toISOString() }),
|
||||
data: '{}',
|
||||
})
|
||||
);
|
||||
|
||||
let closed = false;
|
||||
let eventQueue = Promise.resolve();
|
||||
const unsubscribe = realtimeHub.subscribe((event) => {
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: event.type,
|
||||
data: JSON.stringify(event),
|
||||
id: event.at,
|
||||
eventQueue = eventQueue
|
||||
.then(async () => {
|
||||
if (closed) return;
|
||||
const identities = [viewerIdentity];
|
||||
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(() => {
|
||||
sendFrame(
|
||||
formatSseFrame({
|
||||
event: 'ping',
|
||||
data: JSON.stringify({ at: new Date().toISOString() }),
|
||||
data: '{}',
|
||||
})
|
||||
);
|
||||
}, 15000);
|
||||
|
||||
const close = () => {
|
||||
closed = true;
|
||||
clearInterval(heartbeat);
|
||||
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);
|
||||
};
|
||||
@@ -156,7 +156,8 @@ describe('archive.myPastPlays', () => {
|
||||
leadership: 80,
|
||||
strength: 70,
|
||||
officerLevel: 8,
|
||||
personal: '3',
|
||||
officerLevelText: '제2장군',
|
||||
personal: '-',
|
||||
historyCount: 2,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
@@ -166,9 +167,10 @@ describe('archive.myPastPlays', () => {
|
||||
strength: 71,
|
||||
intel: 61,
|
||||
officerLevel: 7,
|
||||
personal: 'che_의리',
|
||||
special: 'che_상재',
|
||||
special2: 'che_신산',
|
||||
officerLevelText: '제2모사',
|
||||
personal: '의리',
|
||||
special: '상재',
|
||||
special2: '신산',
|
||||
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 () => {
|
||||
const fixture = createContext({
|
||||
me: buildGeneral({
|
||||
@@ -542,8 +591,14 @@ describe('battle-center general and user permissions', () => {
|
||||
id: 7,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
officerLevelText: '일반',
|
||||
crewTypeName: '-',
|
||||
equipmentNames: { weapon: '-', book: '-', horse: '-', item: '-' },
|
||||
traits: { personal: '-', specialDomestic: '-', specialWar: '-' },
|
||||
progression: {
|
||||
experienceLevel: 0,
|
||||
dedicationLevel: 1,
|
||||
dedicationText: '30품관',
|
||||
statExperience: { leadership: 0, strength: 0, intelligence: 0 },
|
||||
statUpgradeLimit: 20,
|
||||
dex: [0, 0, 0, 0, 0],
|
||||
|
||||
@@ -117,6 +117,9 @@ describe('nation general and secret office permissions', () => {
|
||||
cityName: null,
|
||||
troopName: null,
|
||||
refreshScoreTotal: 10,
|
||||
dedicationLevel: 1,
|
||||
dedicationText: '30품관',
|
||||
bill: 600,
|
||||
});
|
||||
expect(result.generals[0]).not.toHaveProperty('crew');
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@sammo-ts/common": [
|
||||
"../../packages/common/src/index.ts"
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"lint:fix": "eslint . --fix",
|
||||
"profile:npc-unification-memory": "node scripts/profile-npc-unification-memory.mjs",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"typecheck": "tsc -b"
|
||||
"typecheck": "pnpm -w tsc7 -b app/game-engine/tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^7.9.1",
|
||||
|
||||
@@ -79,6 +79,7 @@ const DEFAULT_GENERAL_RICE = 1000;
|
||||
const DEFAULT_CREW_TYPE_ID = 1100;
|
||||
const MAX_GENERAL_TURNS = 30;
|
||||
const DEFAULT_TURN_ACTION = '휴식';
|
||||
export const JOIN_WELCOME_MESSAGE = '삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^';
|
||||
const LEGACY_TIMEZONE_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const LEGACY_JOIN_REMOVED_CHARACTERS = /[ⓝⓜⓖⓞⓧ㉥\\/`#|-]/gu;
|
||||
|
||||
@@ -460,7 +461,7 @@ const pushCreationLogs = (
|
||||
logger.pushGeneralHistoryLog(`<Y>${options.name}</>, <G>${options.cityName}</>에서 큰 뜻을 품다.`);
|
||||
logger.pushGeneralActionLog(
|
||||
[
|
||||
'삼국지 모의전투 PHP의 세계에 오신 것을 환영합니다 ^o^',
|
||||
JOIN_WELCOME_MESSAGE,
|
||||
'처음 하시는 경우에는 <D>도움말</>을 참고하시고,',
|
||||
'문의사항이 있으시면 게시판에 글을 남겨주시면 되겠네요~',
|
||||
'부디 즐거운 삼모전 되시길 바랍니다 ^^',
|
||||
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
EventDomesticTraitLoader,
|
||||
isEventDomesticTraitKey,
|
||||
isPersonalityTraitKey,
|
||||
isWarTraitKey,
|
||||
LogCategory,
|
||||
LogScope,
|
||||
PERSONALITY_TRAIT_KEYS,
|
||||
simpleSerialize,
|
||||
WarTraitLoader,
|
||||
} from '@sammo-ts/logic';
|
||||
|
||||
import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra';
|
||||
@@ -78,6 +80,8 @@ export interface SelectPoolCandidateDto {
|
||||
specialDomesticName: string;
|
||||
specialDomesticInfo: string;
|
||||
specialWar: string | null;
|
||||
specialWarName: string | null;
|
||||
specialWarInfo: string;
|
||||
ego: string | null;
|
||||
dex: [number, number, number, number, number];
|
||||
imageServer: 0 | 1;
|
||||
@@ -158,11 +162,16 @@ const candidateWeight = (candidate: SelectPoolCandidateInfo): number =>
|
||||
candidate.dex.reduce((sum, value) => sum + value, 0);
|
||||
|
||||
const eventDomesticTraitLoader = new EventDomesticTraitLoader();
|
||||
const warTraitLoader = new WarTraitLoader();
|
||||
|
||||
const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise<SelectPoolCandidateDto> => {
|
||||
const trait = isEventDomesticTraitKey(candidate.specialDomestic)
|
||||
? await eventDomesticTraitLoader.load(candidate.specialDomestic)
|
||||
: null;
|
||||
const warTrait =
|
||||
candidate.specialWar && isWarTraitKey(candidate.specialWar)
|
||||
? await warTraitLoader.load(candidate.specialWar)
|
||||
: null;
|
||||
return {
|
||||
uniqueName: candidate.uniqueName,
|
||||
generalName: candidate.generalName,
|
||||
@@ -173,6 +182,8 @@ const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise<Selec
|
||||
specialDomesticName: trait?.name ?? candidate.specialDomestic.replace(/^che_event_/, ''),
|
||||
specialDomesticInfo: trait?.info ?? '',
|
||||
specialWar: candidate.specialWar ?? null,
|
||||
specialWarName: warTrait?.name ?? candidate.specialWar?.replace(/^che_(?:event_)?/u, '') ?? null,
|
||||
specialWarInfo: warTrait?.info ?? '',
|
||||
ego: candidate.ego ?? null,
|
||||
dex: candidate.dex,
|
||||
imageServer: candidate.imgsvr,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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', () => {
|
||||
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'
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -383,9 +383,22 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p
|
||||
.first()
|
||||
.evaluate((el) => getComputedStyle(el).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 }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
|
||||
@@ -63,7 +63,9 @@ const myGeneral = (state: FixtureState) => ({
|
||||
troopId: 0,
|
||||
picture: null,
|
||||
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 },
|
||||
gold: 1_000,
|
||||
rice: 2_000,
|
||||
@@ -76,30 +78,74 @@ const myGeneral = (state: FixtureState) => ({
|
||||
age: 30,
|
||||
turnTime: '2026-01-01 00:10:00',
|
||||
crewTypeId: 1,
|
||||
crewTypeName: '보병',
|
||||
traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' },
|
||||
progression: {
|
||||
experienceLevel: 1,
|
||||
dedicationLevel: 2,
|
||||
dedicationText: '29품관',
|
||||
statExperience: { leadership: 7, strength: 8, intelligence: 9 },
|
||||
statUpgradeLimit: 20,
|
||||
dex: [350, 1_375, 3_500, 7_125, 1_275_975],
|
||||
},
|
||||
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
|
||||
? {
|
||||
id: 0,
|
||||
name: '재야',
|
||||
color: '#000000',
|
||||
level: 0,
|
||||
levelName: '방랑군',
|
||||
gold: 0,
|
||||
rice: 0,
|
||||
tech: 0,
|
||||
typeCode: 'None',
|
||||
typeName: '해당 없음',
|
||||
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: {
|
||||
tnmt: 0,
|
||||
defence_train: 80,
|
||||
@@ -116,7 +162,7 @@ const myGeneral = (state: FixtureState) => ({
|
||||
const battleCenter = (state: FixtureState) => ({
|
||||
me: {
|
||||
id: 7,
|
||||
officerLevel: state.permission === 'head' ? 5 : 1,
|
||||
officerLevel: state.permission === 'head' ? 9 : 1,
|
||||
permissionLevel: state.permission === 'head' ? 2 : 0,
|
||||
},
|
||||
nation: { id: 1, name: '위', color: '#777777', level: 3 },
|
||||
@@ -128,7 +174,8 @@ const battleCenter = (state: FixtureState) => ({
|
||||
id: 7,
|
||||
name: '검증장수',
|
||||
npcState: 0,
|
||||
officerLevel: state.permission === 'head' ? 5 : 1,
|
||||
officerLevel: state.permission === 'head' ? 9 : 1,
|
||||
officerLevelText: state.permission === 'head' ? '간의대부' : '일반',
|
||||
cityId: 1,
|
||||
turnTime: '2026-01-01 00:10:00',
|
||||
recentWar: '2026-01-01 00:00:00',
|
||||
@@ -144,10 +191,14 @@ const battleCenter = (state: FixtureState) => ({
|
||||
atmos: 90,
|
||||
age: 30,
|
||||
crewTypeId: 1,
|
||||
crewTypeName: '보병',
|
||||
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: {
|
||||
experienceLevel: 1,
|
||||
dedicationLevel: 2,
|
||||
dedicationText: '29품관',
|
||||
statExperience: { leadership: 7, strength: 8, intelligence: 9 },
|
||||
statUpgradeLimit: 20,
|
||||
dex: [350, 1_375, 3_500, 7_125, 1_275_975],
|
||||
@@ -159,6 +210,7 @@ const battleCenter = (state: FixtureState) => ({
|
||||
name: '다른장수',
|
||||
npcState: 2,
|
||||
officerLevel: 1,
|
||||
officerLevelText: '일반',
|
||||
cityId: 1,
|
||||
turnTime: '2026-01-01 00:20:00',
|
||||
recentWar: null,
|
||||
@@ -174,10 +226,14 @@ const battleCenter = (state: FixtureState) => ({
|
||||
atmos: 60,
|
||||
age: 20,
|
||||
crewTypeId: 1,
|
||||
crewTypeName: '보병',
|
||||
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: {
|
||||
experienceLevel: 0,
|
||||
dedicationLevel: 0,
|
||||
dedicationText: '무품관',
|
||||
statExperience: { leadership: 0, strength: 0, intelligence: 0 },
|
||||
statUpgradeLimit: 20,
|
||||
dex: [0, 0, 0, 0, 0],
|
||||
@@ -512,6 +568,35 @@ test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표
|
||||
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 }) => {
|
||||
const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] };
|
||||
await install(page, state);
|
||||
@@ -566,6 +651,10 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
|
||||
await install(page, state);
|
||||
await page.setViewportSize({ width: 1000, height: 900 });
|
||||
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('#set_my_setting')).toBeVisible();
|
||||
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 page.getByRole('button', { name: '다음 ▶' }).click();
|
||||
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 [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5);
|
||||
expect(
|
||||
|
||||
@@ -20,6 +20,7 @@ type NavigationFixture = {
|
||||
generalMeCalls: number;
|
||||
operations: string[];
|
||||
generalName?: string;
|
||||
generalTurnTime?: string;
|
||||
cityDefence?: number;
|
||||
cityState?: number;
|
||||
nationRate?: number;
|
||||
@@ -68,60 +69,40 @@ const operationInput = (route: Route, index: number): DashboardBundleInput => {
|
||||
return entry.json ?? (entry as DashboardBundleInput);
|
||||
};
|
||||
|
||||
const readModelChanges = (
|
||||
const readModelInvalidation = (
|
||||
overrides: Partial<{
|
||||
generalIds: number[];
|
||||
cityIds: number[];
|
||||
nationIds: number[];
|
||||
mapGeneralIds: number[];
|
||||
mapCityIds: number[];
|
||||
mapNationIds: number[];
|
||||
frontStatusGeneralIds: number[];
|
||||
frontStatusNationIds: number[];
|
||||
frontStatusActorIds: number[];
|
||||
frontStatusChanged: boolean;
|
||||
lobbyGeneralIds: number[];
|
||||
lobbyChanged: boolean;
|
||||
reservedGeneralIds: number[];
|
||||
recordGeneralIds: number[];
|
||||
worldChanged: boolean;
|
||||
globalRecordsChanged: boolean;
|
||||
worldHistoryChanged: boolean;
|
||||
contactsChanged: boolean;
|
||||
context: boolean;
|
||||
lobby: boolean;
|
||||
map: boolean;
|
||||
commands: boolean;
|
||||
contacts: boolean;
|
||||
boardAccess: boolean;
|
||||
reservedTurns: boolean;
|
||||
records: boolean;
|
||||
frontStatus: boolean;
|
||||
}>
|
||||
) => ({
|
||||
generalIds: [],
|
||||
cityIds: [],
|
||||
nationIds: [],
|
||||
mapGeneralIds: [],
|
||||
mapCityIds: [],
|
||||
mapNationIds: [],
|
||||
frontStatusGeneralIds: [],
|
||||
frontStatusNationIds: [],
|
||||
frontStatusActorIds: [],
|
||||
frontStatusChanged: false,
|
||||
lobbyGeneralIds: [],
|
||||
lobbyChanged: false,
|
||||
reservedGeneralIds: [],
|
||||
recordGeneralIds: [],
|
||||
worldChanged: false,
|
||||
globalRecordsChanged: false,
|
||||
worldHistoryChanged: false,
|
||||
contactsChanged: false,
|
||||
context: false,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: false,
|
||||
contacts: false,
|
||||
boardAccess: false,
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const emitReadModelChanges = (page: Page, changes: ReturnType<typeof readModelChanges>) =>
|
||||
const emitReadModelInvalidation = (page: Page, invalidation: ReturnType<typeof readModelInvalidation>) =>
|
||||
page.evaluate((payload) => {
|
||||
(window as unknown as { __emitMainRealtime: (type: string, value: unknown) => void }).__emitMainRealtime(
|
||||
'readModelChanged',
|
||||
'readModelInvalidated',
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
revision: Date.now(),
|
||||
changes: payload,
|
||||
invalidation: payload,
|
||||
}
|
||||
);
|
||||
}, changes);
|
||||
}, invalidation);
|
||||
|
||||
const commandTableFixture = (large: boolean, blockedCount = 0) => ({
|
||||
general: large
|
||||
@@ -239,7 +220,7 @@ const generalContext = (state: NavigationFixture) => ({
|
||||
dex: [350, 100_000, 500_000, 1_000_000, 1_275_975],
|
||||
},
|
||||
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: {
|
||||
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`);
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
const target = (generalId: number, generalName: string) => ({
|
||||
generalId,
|
||||
@@ -1279,26 +1320,6 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
|
||||
const callsBeforeRefresh = state.generalMeCalls;
|
||||
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));
|
||||
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 })
|
||||
.__emitMainRealtime;
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
emit('turnCompleted', {
|
||||
at: new Date().toISOString(),
|
||||
lastTurnTime: '0185-02-01T00:00:00.000Z',
|
||||
changes: {
|
||||
generalIds: [7],
|
||||
cityIds: [],
|
||||
nationIds: [],
|
||||
mapGeneralIds: [],
|
||||
mapCityIds: [],
|
||||
mapNationIds: [],
|
||||
frontStatusGeneralIds: [],
|
||||
frontStatusNationIds: [],
|
||||
frontStatusActorIds: [],
|
||||
frontStatusChanged: false,
|
||||
lobbyGeneralIds: [],
|
||||
lobbyChanged: false,
|
||||
reservedGeneralIds: [],
|
||||
recordGeneralIds: [],
|
||||
worldChanged: false,
|
||||
globalRecordsChanged: false,
|
||||
worldHistoryChanged: false,
|
||||
contactsChanged: false,
|
||||
emit('readModelInvalidated', {
|
||||
invalidation: {
|
||||
context: true,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: true,
|
||||
contacts: false,
|
||||
boardAccess: true,
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1374,29 +1384,18 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
const operationsBeforeSurvey = state.operations.length;
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
|
||||
'readModelChanged',
|
||||
'readModelInvalidated',
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
revision: 42,
|
||||
changes: {
|
||||
generalIds: [],
|
||||
cityIds: [],
|
||||
nationIds: [],
|
||||
mapGeneralIds: [],
|
||||
mapCityIds: [],
|
||||
mapNationIds: [],
|
||||
frontStatusGeneralIds: [],
|
||||
frontStatusNationIds: [],
|
||||
frontStatusActorIds: [],
|
||||
frontStatusChanged: true,
|
||||
lobbyGeneralIds: [],
|
||||
lobbyChanged: false,
|
||||
reservedGeneralIds: [],
|
||||
recordGeneralIds: [],
|
||||
worldChanged: false,
|
||||
globalRecordsChanged: false,
|
||||
worldHistoryChanged: false,
|
||||
contactsChanged: false,
|
||||
invalidation: {
|
||||
context: false,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: false,
|
||||
contacts: false,
|
||||
boardAccess: false,
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -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/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(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/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);
|
||||
|
||||
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/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(page.locator('.city-base .city-state img')).toHaveAttribute('src', /event5\.gif$/u);
|
||||
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.contextOperations = [{ op: 'replace', path: '/missing/value', value: 'invalid-delta' }];
|
||||
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);
|
||||
expect(state.forceSnapshotCalls).toBe(forcedBeforeFallback + 1);
|
||||
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;
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
|
||||
'turnCompleted',
|
||||
'readModelInvalidated',
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
lastTurnTime: '0185-02-01T00:00:00.000Z',
|
||||
changes: {
|
||||
generalIds: [7],
|
||||
cityIds: [],
|
||||
nationIds: [],
|
||||
mapGeneralIds: [],
|
||||
mapCityIds: [],
|
||||
mapNationIds: [],
|
||||
frontStatusGeneralIds: [],
|
||||
frontStatusNationIds: [],
|
||||
frontStatusActorIds: [],
|
||||
frontStatusChanged: false,
|
||||
lobbyGeneralIds: [],
|
||||
lobbyChanged: false,
|
||||
reservedGeneralIds: [],
|
||||
recordGeneralIds: [],
|
||||
worldChanged: false,
|
||||
globalRecordsChanged: false,
|
||||
worldHistoryChanged: false,
|
||||
contactsChanged: false,
|
||||
invalidation: {
|
||||
context: true,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: true,
|
||||
contacts: false,
|
||||
boardAccess: true,
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -1597,7 +1591,7 @@ test('global activity, world history, and a month boundary refresh their visible
|
||||
{ id: 3, text: '장수 동향 기록' },
|
||||
];
|
||||
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('자동 갱신된 장수 동향');
|
||||
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: '중원 정세 기록' },
|
||||
];
|
||||
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('자동 갱신된 중원 정세');
|
||||
expect(state.operations.slice(operationsBeforeHistory)).toEqual(['general.getRecentRecords']);
|
||||
|
||||
state.currentMonth = 2;
|
||||
const operationsBeforeMonth = state.operations.length;
|
||||
await page.evaluate(
|
||||
(changes) => {
|
||||
(invalidation) => {
|
||||
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
|
||||
'turnCompleted',
|
||||
'readModelInvalidated',
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
lastTurnTime: '0185-02-01T00:00:00.000Z',
|
||||
changes,
|
||||
invalidation,
|
||||
}
|
||||
);
|
||||
},
|
||||
readModelChanges({ worldChanged: true })
|
||||
readModelInvalidation({ lobby: true, map: true, commands: true })
|
||||
);
|
||||
await expect(page.getByText('현재: 185년 2월')).toBeVisible();
|
||||
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 = '탭공유갱신장수';
|
||||
await leaderPage.evaluate(() => {
|
||||
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
|
||||
'readModelChanged',
|
||||
'readModelInvalidated',
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
revision: 100,
|
||||
changes: {
|
||||
generalIds: [7],
|
||||
cityIds: [],
|
||||
nationIds: [],
|
||||
mapGeneralIds: [],
|
||||
mapCityIds: [],
|
||||
mapNationIds: [],
|
||||
frontStatusGeneralIds: [],
|
||||
frontStatusNationIds: [],
|
||||
frontStatusActorIds: [],
|
||||
frontStatusChanged: false,
|
||||
lobbyGeneralIds: [],
|
||||
lobbyChanged: false,
|
||||
reservedGeneralIds: [],
|
||||
recordGeneralIds: [],
|
||||
worldChanged: false,
|
||||
globalRecordsChanged: false,
|
||||
worldHistoryChanged: false,
|
||||
contactsChanged: false,
|
||||
invalidation: {
|
||||
context: true,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: true,
|
||||
contacts: false,
|
||||
boardAccess: true,
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -1727,29 +1708,18 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn
|
||||
state.generalName = '리더만갱신장수';
|
||||
await leaderPage.evaluate(() => {
|
||||
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
|
||||
'readModelChanged',
|
||||
'readModelInvalidated',
|
||||
{
|
||||
at: new Date().toISOString(),
|
||||
revision: 101,
|
||||
changes: {
|
||||
generalIds: [7],
|
||||
cityIds: [],
|
||||
nationIds: [],
|
||||
mapGeneralIds: [],
|
||||
mapCityIds: [],
|
||||
mapNationIds: [],
|
||||
frontStatusGeneralIds: [],
|
||||
frontStatusNationIds: [],
|
||||
frontStatusActorIds: [],
|
||||
frontStatusChanged: false,
|
||||
lobbyGeneralIds: [],
|
||||
lobbyChanged: false,
|
||||
reservedGeneralIds: [],
|
||||
recordGeneralIds: [],
|
||||
worldChanged: false,
|
||||
globalRecordsChanged: false,
|
||||
worldHistoryChanged: false,
|
||||
contactsChanged: false,
|
||||
invalidation: {
|
||||
context: true,
|
||||
lobby: false,
|
||||
map: false,
|
||||
commands: true,
|
||||
contacts: false,
|
||||
boardAccess: true,
|
||||
reservedTurns: false,
|
||||
records: false,
|
||||
frontStatus: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ const installArchive = async (page: Page) => {
|
||||
experience: 23000,
|
||||
dedication: 1200,
|
||||
officerLevel: 12,
|
||||
officerLevelText: '황제',
|
||||
personal: '대담',
|
||||
special: '상재',
|
||||
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 ({
|
||||
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.getByText('천하쟁패 · 51기')).toBeVisible();
|
||||
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'));
|
||||
const historyToggle = page.locator('.history-toggle');
|
||||
await expect(historyToggle).toHaveText('보기 (2)');
|
||||
|
||||
@@ -230,7 +230,7 @@ test.describe('scenario 903 live selection pool', () => {
|
||||
expect(geometry.footerBanner.height).toBeCloseTo(20.1875, 3);
|
||||
await expect(page.locator('.invitation-table tbody tr')).toHaveCount(0);
|
||||
await expect(page.locator('.footer-banner')).toContainText(
|
||||
'삼국지 모의전투 HiDCHe core2026'
|
||||
'삼국지 모의전투 HiDCHe'
|
||||
);
|
||||
await expect(page.locator('.footer-banner a')).toHaveText('Credit');
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"autoprefixer": "^10.5.4",
|
||||
"postcss": "8.5.26",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "6.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.2.1",
|
||||
"vue-tsc": "^3.3.9"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ interface CityInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
level: number;
|
||||
levelName: string;
|
||||
regionName: string;
|
||||
nationId: number;
|
||||
nationName: string;
|
||||
population: number;
|
||||
populationMax: number;
|
||||
agriculture: number;
|
||||
@@ -66,8 +69,8 @@ const metrics = computed(() => {
|
||||
<div v-else-if="!props.city" class="empty">도시 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="city-body">
|
||||
<div class="title">
|
||||
{{ props.city.name }} (Lv {{ props.city.level }}) · 국가 {{ props.city.nationId || '무주' }} · 보급
|
||||
{{ props.city.supplyState }} · 전방 {{ props.city.frontState }}
|
||||
【{{ props.city.regionName }} | {{ props.city.levelName }}】 {{ props.city.name }} ·
|
||||
{{ props.city.nationId > 0 ? `지배 국가 【 ${props.city.nationName} 】` : '공 백 지' }}
|
||||
</div>
|
||||
<div class="progress-grid">
|
||||
<div
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
import LegacyProgressBar from '../ui/LegacyProgressBar.vue';
|
||||
import { formatSeoulHourMinute } from '../../utils/legacyDateTime';
|
||||
import { legacyExperiencePercent, ratioPercent } from '../../utils/legacyProgress';
|
||||
|
||||
interface GeneralStats {
|
||||
@@ -13,6 +14,7 @@ interface GeneralStats {
|
||||
interface GeneralProgression {
|
||||
experienceLevel: number;
|
||||
dedicationLevel: number;
|
||||
dedicationText?: string;
|
||||
statExperience?: { leadership: number; strength: number; intelligence: number };
|
||||
statUpgradeLimit?: number;
|
||||
}
|
||||
@@ -22,6 +24,7 @@ interface GeneralInfo {
|
||||
name: string;
|
||||
npcState: number;
|
||||
officerLevel: number;
|
||||
officerLevelText: string;
|
||||
stats: GeneralStats;
|
||||
gold: number;
|
||||
rice: number;
|
||||
@@ -34,6 +37,7 @@ interface GeneralInfo {
|
||||
age?: number;
|
||||
turnTime?: string;
|
||||
crewTypeId?: number;
|
||||
crewTypeName?: string;
|
||||
traits?: { personal: string; specialWar: string; specialDomestic: string };
|
||||
progression?: GeneralProgression;
|
||||
}
|
||||
@@ -78,9 +82,9 @@ const experiencePercent = computed(() =>
|
||||
<div v-else-if="!props.general" class="empty">장수 정보를 불러오지 못했습니다.</div>
|
||||
<div v-else class="general-body">
|
||||
<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 class="stat-progress-grid">
|
||||
@@ -102,11 +106,11 @@ const experiencePercent = computed(() =>
|
||||
><strong>{{ props.general.crew.toLocaleString() }}</strong> <span>훈련</span
|
||||
><strong>{{ props.general.train }}</strong> <span>사기</span><strong>{{ props.general.atmos }}</strong>
|
||||
<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?.specialWar ?? '-' }}</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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,11 +7,14 @@ interface NationInfo {
|
||||
name: string;
|
||||
color: string;
|
||||
level: number;
|
||||
levelName: string;
|
||||
gold: number;
|
||||
rice: number;
|
||||
tech: number;
|
||||
typeCode: string;
|
||||
typeName: string;
|
||||
capitalCityId: number | null;
|
||||
capitalCityName: string | null;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -31,7 +34,7 @@ const props = defineProps<{
|
||||
class="title"
|
||||
: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 class="grid">
|
||||
<span>국고</span
|
||||
@@ -40,10 +43,11 @@ const props = defineProps<{
|
||||
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.rice.toLocaleString() }}</strong>
|
||||
<span>기술</span
|
||||
><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
|
||||
><strong>{{ props.nation.id === 0 ? '해당 없음' : (props.nation.capitalCityId ?? '-') }}</strong>
|
||||
<span>국가 등급</span><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.level }}</strong>
|
||||
><strong>{{ props.nation.id === 0 ? '해당 없음' : (props.nation.capitalCityName ?? '-') }}</strong>
|
||||
<span>국가 등급</span
|
||||
><strong>{{ props.nation.id === 0 ? '해당 없음' : props.nation.levelName }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,8 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC, type MessageType
|
||||
import {
|
||||
applyReadModelDelta,
|
||||
cloneReadModelJson,
|
||||
type RealtimeEvent,
|
||||
type RealtimeReadModelChanges,
|
||||
type PublicRealtimeEvent,
|
||||
type RealtimeReadModelInvalidation,
|
||||
} from '@sammo-ts/common';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { useMapViewerStore } from './mapViewer';
|
||||
@@ -13,7 +13,7 @@ import { useSessionStore } from './session';
|
||||
import { createLatestRefreshQueue } from '../utils/latestRefreshQueue';
|
||||
import { createRateLimitedRefreshQueue } from '../utils/rateLimitedRefreshQueue';
|
||||
import { structurallyShare } from '../utils/structuralShare';
|
||||
import { createMergedReadModelRefreshQueue, resolveDashboardRefreshPlan } from '../utils/dashboardReadModel';
|
||||
import { createMergedReadModelRefreshQueue } from '../utils/dashboardReadModel';
|
||||
import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator';
|
||||
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;
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const plan = resolveDashboardRefreshPlan(changes, {
|
||||
generalId: id,
|
||||
cityId: city.value?.id ?? null,
|
||||
nationId: nation.value?.id ?? null,
|
||||
});
|
||||
if (!Object.values(plan).some(Boolean)) {
|
||||
return;
|
||||
}
|
||||
@@ -944,12 +939,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const parseRealtimePayload = (raw: MessageEvent): RealtimeEvent | null => {
|
||||
const parseRealtimePayload = (raw: MessageEvent): PublicRealtimeEvent | null => {
|
||||
if (!raw.data || typeof raw.data !== 'string') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw.data) as RealtimeEvent;
|
||||
const parsed = JSON.parse(raw.data) as PublicRealtimeEvent;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
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 = () => {
|
||||
if (!realtimeSource) {
|
||||
return;
|
||||
@@ -1095,36 +1075,34 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused';
|
||||
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' });
|
||||
});
|
||||
source.addEventListener('turnCompleted', (event) => {
|
||||
source.addEventListener('readModelInvalidated', (event) => {
|
||||
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
|
||||
const payload = parseRealtimePayload(event);
|
||||
if (!payload || payload.type !== 'turnCompleted') {
|
||||
if (!payload || payload.type !== 'readModelInvalidated') {
|
||||
return;
|
||||
}
|
||||
if (!payload.changes) {
|
||||
// Rolling deployment fallback for an older daemon.
|
||||
readModelRefreshQueue.request(payload.invalidation);
|
||||
});
|
||||
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();
|
||||
return;
|
||||
}
|
||||
readModelRefreshQueue.request(payload.changes);
|
||||
});
|
||||
source.addEventListener('readModelChanged', (event) => {
|
||||
});
|
||||
}
|
||||
source.addEventListener('messageCreated', () => {
|
||||
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
|
||||
const payload = parseRealtimePayload(event);
|
||||
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();
|
||||
}
|
||||
void refreshMessages();
|
||||
});
|
||||
source.addEventListener('ping', () => {
|
||||
if (realtimeEnabled.value) {
|
||||
|
||||
@@ -1,85 +1,29 @@
|
||||
import {
|
||||
createEmptyRealtimeReadModelChanges,
|
||||
mergeRealtimeReadModelChanges,
|
||||
createEmptyRealtimeReadModelInvalidation,
|
||||
mergeRealtimeReadModelInvalidations,
|
||||
resolveRealtimeReadModelInvalidation,
|
||||
type RealtimeReadModelChanges,
|
||||
type RealtimeReadModelInvalidation,
|
||||
type RealtimeViewerIdentity,
|
||||
} from '@sammo-ts/common';
|
||||
|
||||
export interface DashboardReadModelIdentity {
|
||||
generalId: number | null;
|
||||
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 type DashboardReadModelIdentity = RealtimeViewerIdentity;
|
||||
export type DashboardRefreshPlan = RealtimeReadModelInvalidation;
|
||||
|
||||
export const resolveDashboardRefreshPlan = (
|
||||
changes: RealtimeReadModelChanges,
|
||||
identity: DashboardReadModelIdentity
|
||||
): DashboardRefreshPlan => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
): DashboardRefreshPlan => resolveRealtimeReadModelInvalidation(changes, identity);
|
||||
|
||||
type TimerHandle = ReturnType<typeof setTimeout>;
|
||||
|
||||
export interface MergedReadModelRefreshQueue {
|
||||
request(changes: RealtimeReadModelChanges): void;
|
||||
request(invalidation: RealtimeReadModelInvalidation): void;
|
||||
cancelPending(): void;
|
||||
}
|
||||
|
||||
export const createMergedReadModelRefreshQueue = (
|
||||
refresh: (changes: RealtimeReadModelChanges) => Promise<void>,
|
||||
refresh: (invalidation: RealtimeReadModelInvalidation) => Promise<void>,
|
||||
options: {
|
||||
minIntervalMs?: number;
|
||||
now?: () => number;
|
||||
@@ -91,7 +35,7 @@ export const createMergedReadModelRefreshQueue = (
|
||||
const now = options.now ?? Date.now;
|
||||
const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
||||
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
||||
let pending = createEmptyRealtimeReadModelChanges();
|
||||
let pending = createEmptyRealtimeReadModelInvalidation();
|
||||
let hasPending = false;
|
||||
let running = false;
|
||||
let timer: TimerHandle | null = null;
|
||||
@@ -108,7 +52,7 @@ export const createMergedReadModelRefreshQueue = (
|
||||
return;
|
||||
}
|
||||
const next = pending;
|
||||
pending = createEmptyRealtimeReadModelChanges();
|
||||
pending = createEmptyRealtimeReadModelInvalidation();
|
||||
hasPending = false;
|
||||
running = true;
|
||||
lastStartedAt = now();
|
||||
@@ -120,14 +64,14 @@ export const createMergedReadModelRefreshQueue = (
|
||||
};
|
||||
|
||||
return {
|
||||
request: (changes) => {
|
||||
pending = hasPending ? mergeRealtimeReadModelChanges(pending, changes) : changes;
|
||||
request: (invalidation) => {
|
||||
pending = hasPending ? mergeRealtimeReadModelInvalidations(pending, invalidation) : invalidation;
|
||||
hasPending = true;
|
||||
schedule();
|
||||
},
|
||||
cancelPending: () => {
|
||||
hasPending = false;
|
||||
pending = createEmptyRealtimeReadModelChanges();
|
||||
pending = createEmptyRealtimeReadModelInvalidation();
|
||||
if (timer !== null) {
|
||||
clearTimer(timer);
|
||||
timer = null;
|
||||
|
||||
@@ -20,3 +20,5 @@ export const formatSeoulDateTime = (value: string | Date): string => {
|
||||
koreaTime.getUTCSeconds()
|
||||
)}`;
|
||||
};
|
||||
|
||||
export const formatSeoulHourMinute = (value: string | Date): string => formatSeoulDateTime(value).slice(11, 16);
|
||||
|
||||
@@ -14,6 +14,19 @@ export const officerLevelMapDefault: Record<number, string> = {
|
||||
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>> = {
|
||||
7: {
|
||||
12: '황제',
|
||||
@@ -75,15 +88,13 @@ export const officerLevelMapByNationLevel: Record<number, Record<number, string>
|
||||
|
||||
export const formatOfficerLevelText = (officerLevel: number, nationLevel?: number): string => {
|
||||
if (officerLevel < 5) {
|
||||
return officerLevelMapDefault[officerLevel] ?? '???';
|
||||
return officerLevelMapDefault[officerLevel] ?? '-';
|
||||
}
|
||||
|
||||
const nationMap =
|
||||
nationLevel === undefined
|
||||
? officerLevelMapDefault
|
||||
: (officerLevelMapByNationLevel[nationLevel] ?? officerLevelMapDefault);
|
||||
|
||||
return nationMap[officerLevel] ?? (officerLevelMapDefault[officerLevel] ?? '???');
|
||||
if (nationLevel === undefined) {
|
||||
return officerLevelMapDefault[officerLevel] ?? '-';
|
||||
}
|
||||
return officerLevelMapByNationLevel[nationLevel]?.[officerLevel] ?? '-';
|
||||
};
|
||||
|
||||
export const regionMap: Record<number, string> = {
|
||||
|
||||
@@ -38,6 +38,8 @@ const resolveErrorMessage = (value: unknown): string => {
|
||||
};
|
||||
|
||||
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 => {
|
||||
if (!value) {
|
||||
return '-';
|
||||
@@ -395,7 +397,7 @@ onMounted(() => {
|
||||
<h2 class="section-title bg2">경매 {{ uniqueDetail.auction.id }}번 상세</h2>
|
||||
<dl class="detail-grid">
|
||||
<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>
|
||||
<dd :class="{ 'is-me': uniqueDetail.auction.isCallerHost }">{{ uniqueDetail.auction.hostName }}</dd>
|
||||
<dt class="bg1">종료일시</dt>
|
||||
@@ -442,7 +444,7 @@ onMounted(() => {
|
||||
@keydown.space.prevent="selectUnique(auction)"
|
||||
>
|
||||
<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="tnum">{{ cutDateTime(auction.closeAt) }}</span>
|
||||
<span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span>
|
||||
@@ -474,7 +476,7 @@ onMounted(() => {
|
||||
@keydown.space.prevent="selectUnique(auction)"
|
||||
>
|
||||
<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="tnum">{{ cutDateTime(auction.closeAt) }}</span>
|
||||
<span>{{ (auction.detail.remainCloseDateExtensionCnt ?? 0) > 0 ? '남음' : '소진' }}</span>
|
||||
|
||||
@@ -269,7 +269,7 @@ onMounted(() => {
|
||||
<SkeletonLines v-if="loading" :lines="5" />
|
||||
<div v-else-if="selectedGeneral" class="battle-general-card">
|
||||
<div class="battle-general-name">
|
||||
{{ selectedGeneral.name }} (관직 {{ selectedGeneral.officerLevel }})
|
||||
{{ selectedGeneral.name }} ({{ selectedGeneral.officerLevelText }})
|
||||
</div>
|
||||
<span
|
||||
class="battle-general-portrait"
|
||||
@@ -293,9 +293,9 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="battle-general-extra">
|
||||
<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
|
||||
><strong>{{ selectedGeneral.crewTypeId }}</strong> <span>승리</span
|
||||
><strong>{{ selectedGeneral.crewTypeName }}</strong> <span>승리</span
|
||||
><strong>{{ selectedGeneral.battleStats.kills }}</strong> <span>패배</span
|
||||
><strong>{{ selectedGeneral.battleStats.deaths }}</strong> <span>사살</span
|
||||
><strong>{{ selectedGeneral.battleStats.killCrew.toLocaleString('ko-KR') }}</strong>
|
||||
|
||||
@@ -158,7 +158,7 @@ watch(viewMode, () => {
|
||||
<button class="legacy-button" type="button" @click="closePage">창 닫기</button>
|
||||
</div>
|
||||
<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>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -248,7 +248,7 @@ const placeBet = async (targetId: number) => {
|
||||
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
||||
</RouterLink>
|
||||
<small>
|
||||
삼국지 모의전투 PHP HiDCHe -unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
HideD(hided62@gmail.com) / Credit
|
||||
</small>
|
||||
</footer>
|
||||
|
||||
@@ -321,7 +321,7 @@ const generalImage = (general: General): string => resolveGeneralIconUrl(general
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-banner">
|
||||
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
HideD(hided62@gmail.com) /
|
||||
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
|
||||
</td>
|
||||
|
||||
@@ -621,7 +621,7 @@ onBeforeUnmount(() => {
|
||||
<td>
|
||||
<button class="legacy-button legacy-button--primary" type="button" @click="router.push('/')">돌아가기</button
|
||||
><br /><br />
|
||||
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
HideD(hided62@gmail.com) /
|
||||
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
|
||||
</td>
|
||||
|
||||
@@ -289,7 +289,7 @@ onMounted(loadDetail);
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="banner">
|
||||
삼국지 모의전투 TypeScript core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -144,7 +144,7 @@ onMounted(loadDynasty);
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="banner">
|
||||
삼국지 모의전투 TypeScript core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -174,7 +174,7 @@ onMounted(loadOptions);
|
||||
<button class="legacy-button" type="button" @click="closePage">창 닫기</button>
|
||||
</div>
|
||||
<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>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -132,12 +132,40 @@ const noDefencePenaltyWaived = computed(() => {
|
||||
);
|
||||
});
|
||||
const noDefenceLabel = computed(() => (noDefencePenaltyWaived.value ? '×' : '× [훈련 -3,사기 -6]'));
|
||||
const items = computed<Array<{ key: ItemSlotKey; name: string; code: string | null }>>(() => [
|
||||
{ key: 'horse', name: '말', code: data.value?.general.items.horse ?? null },
|
||||
{ key: 'weapon', name: '무기', code: data.value?.general.items.weapon ?? null },
|
||||
{ key: 'book', name: '서적', code: data.value?.general.items.book ?? null },
|
||||
{ key: 'item', name: '도구', code: data.value?.general.items.item ?? null },
|
||||
]);
|
||||
const fallbackDisplayCode = (value: string | null | undefined): string | null =>
|
||||
value && !/^\d+$/u.test(value) ? value.replace(/^che_(?:event_)?/u, '') : null;
|
||||
const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName: string | null; code: string | 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 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 }) =>
|
||||
confirmMutation(`${item.code ?? item.name}을(를) 버리시겠습니까?`, () =>
|
||||
const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: string | null; code: string | null }) =>
|
||||
confirmMutation(`${item.displayName ?? item.slotName}을(를) 버리시겠습니까?`, () =>
|
||||
trpc.general.dropItem.mutate({ itemType: item.key })
|
||||
);
|
||||
|
||||
@@ -416,7 +444,7 @@ onMounted(() => {
|
||||
>
|
||||
· 계급
|
||||
<strong
|
||||
>Lv {{ data.general.progression?.dedicationLevel ?? 0 }} ({{
|
||||
>{{ data.general.progression?.dedicationText ?? '무품관' }} ({{
|
||||
data.general.dedication
|
||||
}})</strong
|
||||
>
|
||||
@@ -425,7 +453,7 @@ onMounted(() => {
|
||||
<div>승률 0% · 승리 0 · 패배 0</div>
|
||||
<div>살상률 0% · 사살 0 · 피살 0</div>
|
||||
<div>
|
||||
병종 {{ data.general.crewTypeId || '-' }} · 내정특기
|
||||
병종 {{ data.general.crewTypeName ?? '-' }} · 내정특기
|
||||
{{ data.general.traits?.specialDomestic ?? '-' }} · 부상 {{ data.general.injury }} · 부대
|
||||
{{ data.general.troopId || '-' }} · 벌점 {{ penalties.length || '-' }}
|
||||
</div>
|
||||
@@ -595,7 +623,7 @@ onMounted(() => {
|
||||
:disabled="!item.code"
|
||||
@click="dropItem(item)"
|
||||
>
|
||||
{{ item.code ?? '-' }}
|
||||
{{ item.displayName ?? '-' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -637,7 +665,7 @@ onMounted(() => {
|
||||
</article>
|
||||
</section>
|
||||
<footer class="legacy-credit">
|
||||
삼국지 모의전투 PHP HiDCHe - core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작: HideD / Credit
|
||||
</footer>
|
||||
</main>
|
||||
<div class="my-page-mobile-scroll-spacer" aria-hidden="true"></div>
|
||||
|
||||
@@ -252,7 +252,7 @@ onMounted(async () => {
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-banner">
|
||||
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
<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>
|
||||
</td>
|
||||
|
||||
@@ -63,7 +63,6 @@ const generals = computed(() =>
|
||||
})
|
||||
);
|
||||
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);
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -78,7 +77,13 @@ onMounted(load);
|
||||
<strong>세력 장수</strong>
|
||||
<span class="right-actions">
|
||||
<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">
|
||||
<button
|
||||
@click="
|
||||
@@ -178,7 +183,7 @@ onMounted(load);
|
||||
</td>
|
||||
<td :class="`name-cell npc-${general.npcState}`">{{ general.name }}</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>{{ general.stats.leadership }}</td>
|
||||
<td>{{ general.stats.strength }}</td>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { legacyNationTextColor } from '../utils/legacyNationColor';
|
||||
import { formatNationLevelText } from '../utils/nationFormat';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
type Result = Awaited<ReturnType<typeof trpc.nation.getNationInfo.query>>;
|
||||
@@ -11,9 +12,7 @@ const router = useRouter();
|
||||
const error = ref('');
|
||||
const number = (value: number) => value.toLocaleString('ko-KR');
|
||||
const diff = (value: number) => `${value > 0 ? '+' : ''}${number(value)}`;
|
||||
const nationLevel = computed(
|
||||
() => ['두목', '영주', '군벌', '주자사', '주목', '공', '왕', '황제'][data.value?.nation.level ?? 0] ?? '-'
|
||||
);
|
||||
const nationLevel = computed(() => formatNationLevelText(data.value?.nation.level ?? 0));
|
||||
onMounted(async () => {
|
||||
try {
|
||||
data.value = await trpc.nation.getNationInfo.query();
|
||||
@@ -114,7 +113,7 @@ onMounted(async () => {
|
||||
<td><button type="button" @click="router.push('/')">돌아가기</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="credit">삼국지 모의전투 PHP HiDCHe / KOEI의 이미지를 사용했습니다 / 제작: Hide.D</td>
|
||||
<td class="credit">삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용했습니다 / 제작: Hide.D</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { formatOfficerLevelText } from '../utils/nationFormat';
|
||||
import { formatNationLevelText, formatOfficerLevelText } from '../utils/nationFormat';
|
||||
import { getNpcColor } from '../utils/npcColor';
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
@@ -12,16 +12,6 @@ const nations = ref<Directory>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const nationLevelText: Record<number, string> = {
|
||||
7: '황제',
|
||||
6: '왕',
|
||||
5: '공',
|
||||
4: '주목',
|
||||
3: '주자사',
|
||||
2: '군벌',
|
||||
1: '호족',
|
||||
0: '방랑군',
|
||||
};
|
||||
const whiteTextColors = new Set([
|
||||
'',
|
||||
'#330000',
|
||||
@@ -111,7 +101,7 @@ onMounted(() => {
|
||||
<td class="label-cell">성 향</td>
|
||||
<td class="value-wide type-name">{{ Array.from(nation.type.name).join(' ') }}</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="value-wide">{{ nation.power }}</td>
|
||||
<td class="label-cell">장수 / 속령</td>
|
||||
@@ -227,7 +217,7 @@ onMounted(() => {
|
||||
<tr>
|
||||
<td>
|
||||
<small>
|
||||
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
HideD(hided62@gmail.com) /
|
||||
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
|
||||
</small>
|
||||
|
||||
@@ -503,7 +503,7 @@ onMounted(() => void loadPersonnel());
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-banner">
|
||||
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
HideD(hided62@gmail.com) /
|
||||
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer"
|
||||
>Credit</a
|
||||
|
||||
@@ -159,7 +159,7 @@ onMounted(load);
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="legacy-banner">
|
||||
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
HideD(hided62@gmail.com) /
|
||||
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
|
||||
</td>
|
||||
|
||||
@@ -149,7 +149,7 @@ onMounted(() => {
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="banner">
|
||||
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
<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>
|
||||
</td>
|
||||
|
||||
@@ -140,7 +140,7 @@ onMounted(() => {
|
||||
<td>{{ valueOrDash(general.leadership) }}</td>
|
||||
<td>{{ valueOrDash(general.strength) }}</td>
|
||||
<td>{{ valueOrDash(general.intel) }}</td>
|
||||
<td>{{ valueOrDash(general.officerLevel) }}</td>
|
||||
<td>{{ valueOrDash(general.officerLevelText) }}</td>
|
||||
<td>{{ valueOrDash(general.personal) }}</td>
|
||||
<td>{{ valueOrDash(general.special) }}</td>
|
||||
<td>{{ valueOrDash(general.special2) }}</td>
|
||||
|
||||
@@ -358,8 +358,11 @@ onBeforeUnmount(() => {
|
||||
<span role="tooltip">{{ candidate.specialDomesticInfo }}</span>
|
||||
</span>
|
||||
/
|
||||
<span>{{ candidate.specialWar ?? '-' }}</span
|
||||
><br /><br />
|
||||
<span v-if="candidate.specialWarName" class="trait-tooltip" tabindex="0">
|
||||
{{ 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[1] / 1000) }}K<br />
|
||||
기병: {{ Math.trunc(candidate.dex[2] / 1000) }}K<br />
|
||||
@@ -408,8 +411,11 @@ onBeforeUnmount(() => {
|
||||
<span role="tooltip">{{ selectedCandidate.specialDomesticInfo }}</span>
|
||||
</span>
|
||||
/
|
||||
<span>{{ selectedCandidate.specialWar ?? '-' }}</span
|
||||
><br /><br />
|
||||
<span v-if="selectedCandidate.specialWarName" class="trait-tooltip" tabindex="0">
|
||||
{{ 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[1] / 1000) }}K<br />
|
||||
기병: {{ Math.trunc(selectedCandidate.dex[2] / 1000) }}K<br />
|
||||
@@ -498,7 +504,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="footer-banner with-border">
|
||||
<small>
|
||||
삼국지 모의전투 HiDCHe core2026 / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD /
|
||||
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noopener noreferrer"
|
||||
>Credit</a
|
||||
>
|
||||
|
||||
@@ -287,7 +287,7 @@ const start = async () => {
|
||||
<button class="close-button" type="button" @click="navigate">창 닫기</button>
|
||||
</RouterLink>
|
||||
<small>
|
||||
삼국지 모의전투 PHP HiDCHe -unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
HideD(hided62@gmail.com) / Credit
|
||||
</small>
|
||||
</footer>
|
||||
|
||||
@@ -204,7 +204,7 @@ onMounted(() => {
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="banner">
|
||||
삼국지 모의전투 PHP HiDCHe - unknown / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
|
||||
HideD(hided62@gmail.com) /
|
||||
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
|
||||
</td>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
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';
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 nextTimerId = 1;
|
||||
const timers = new Map<number, { callback: () => void; at: number }>();
|
||||
const observed: number[][] = [];
|
||||
const observed: Array<{ context: boolean; records: boolean }> = [];
|
||||
const queue = createMergedReadModelRefreshQueue(
|
||||
async (changes) => {
|
||||
observed.push(changes.generalIds);
|
||||
async (invalidation) => {
|
||||
observed.push({ context: invalidation.context, records: invalidation.records });
|
||||
},
|
||||
{
|
||||
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();
|
||||
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({ ...createEmptyRealtimeReadModelChanges(), generalIds: [8, 9] });
|
||||
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true });
|
||||
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), records: true });
|
||||
nowMs = 999;
|
||||
runDueTimers();
|
||||
assert.equal(observed.length, 1);
|
||||
nowMs = 1_000;
|
||||
runDueTimers();
|
||||
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), '-');
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,6 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@sammo-ts/common": ["../../packages/common/src/index.ts"],
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"typecheck": "tsc -b"
|
||||
"typecheck": "pnpm -w tsc7 -b app/gateway-api/tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sanitize-html": "2.16.1",
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"pg": "^8.23.0",
|
||||
"postcss": "8.5.26",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "6.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "^8.2.1",
|
||||
"vue-tsc": "^3.3.9"
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"typecheck": "tsc -b"
|
||||
"typecheck": "pnpm -w tsc7 -b app/release-controller/tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sammo-ts/gateway-api": "workspace:*",
|
||||
|
||||
Reference in New Issue
Block a user