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

This commit is contained in:
2026-08-13 00:52:28 +00:00
82 changed files with 2013 additions and 849 deletions
+6 -4
View File
@@ -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,
},
});
});
});