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
+1 -1
View File
@@ -29,7 +29,7 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsc -b"
"typecheck": "pnpm -w tsc7 -b packages/common/tsconfig.json"
},
"dependencies": {
"@noble/hashes": "^2.0.1",
+128 -2
View File
@@ -2,8 +2,9 @@ export type MessageTypeKey = 'public' | 'private' | 'national' | 'diplomacy';
/**
* Durable mutations summarized after the database transaction commits.
* Entity IDs let each authenticated client decide whether its own read model
* is affected without exposing entity payloads over the shared Redis channel.
* This internal Redis contract carries entity IDs so the authenticated game
* API can derive each subscriber's browser-safe invalidation. It must never be
* serialized directly to a public SSE response.
*/
export interface RealtimeReadModelChanges {
generalIds: number[];
@@ -32,6 +33,119 @@ export interface RealtimeReadModelChanges {
lobbyChanged?: boolean;
}
/**
* Browser-visible invalidation contract. It deliberately contains no entity
* IDs, timestamps, turn times, or global revisions. The API derives these
* viewer-specific booleans from the internal committed-change summary before
* crossing the SSE boundary.
*/
export interface RealtimeReadModelInvalidation {
context: boolean;
lobby: boolean;
map: boolean;
commands: boolean;
contacts: boolean;
boardAccess: boolean;
reservedTurns: boolean;
records: boolean;
frontStatus: boolean;
}
export interface RealtimeViewerIdentity {
generalId: number | null;
cityId: number | null;
nationId: number | null;
}
export const createEmptyRealtimeReadModelInvalidation = (): RealtimeReadModelInvalidation => ({
context: false,
lobby: false,
map: false,
commands: false,
contacts: false,
boardAccess: false,
reservedTurns: false,
records: false,
frontStatus: false,
});
export const createFullRealtimeReadModelInvalidation = (): RealtimeReadModelInvalidation => ({
context: true,
lobby: true,
map: true,
commands: true,
contacts: true,
boardAccess: true,
reservedTurns: true,
records: true,
frontStatus: true,
});
export const mergeRealtimeReadModelInvalidations = (
left: RealtimeReadModelInvalidation,
right: RealtimeReadModelInvalidation
): RealtimeReadModelInvalidation => ({
context: left.context || right.context,
lobby: left.lobby || right.lobby,
map: left.map || right.map,
commands: left.commands || right.commands,
contacts: left.contacts || right.contacts,
boardAccess: left.boardAccess || right.boardAccess,
reservedTurns: left.reservedTurns || right.reservedTurns,
records: left.records || right.records,
frontStatus: left.frontStatus || right.frontStatus,
});
export const hasRealtimeReadModelInvalidation = (invalidation: RealtimeReadModelInvalidation): boolean =>
Object.values(invalidation).some(Boolean);
const contains = (ids: readonly number[], id: number | null): boolean => id !== null && ids.includes(id);
export const resolveRealtimeReadModelInvalidation = (
changes: RealtimeReadModelChanges,
identity: RealtimeViewerIdentity
): RealtimeReadModelInvalidation => {
const ownGeneralChanged = contains(changes.generalIds, identity.generalId);
const ownCityChanged = contains(changes.cityIds, identity.cityId);
const ownNationChanged = contains(changes.nationIds, identity.nationId);
const ownFrontStatusNationChanged = contains(
changes.frontStatusNationIds ?? changes.nationIds,
identity.nationId
);
const ownGeneralMapChanged = contains(changes.mapGeneralIds ?? changes.generalIds, identity.generalId);
const frontStatusGeneralChanged =
changes.frontStatusGeneralIds !== undefined
? changes.frontStatusGeneralIds.length > 0
: changes.contactsChanged;
const ownFrontStatusActorChanged = contains(changes.frontStatusActorIds ?? [], identity.generalId);
const ownLobbyGeneralChanged = contains(changes.lobbyGeneralIds ?? changes.generalIds, identity.generalId);
const lobbyChanged = changes.lobbyChanged ?? changes.contactsChanged;
const entityContextChanged = ownGeneralChanged || ownCityChanged || ownNationChanged;
const mapEntitiesChanged =
(changes.mapCityIds ?? changes.cityIds).length > 0 ||
(changes.mapNationIds ?? changes.nationIds).length > 0;
const commandEntitiesChanged = changes.cityIds.length > 0 || changes.nationIds.length > 0;
return {
context: entityContextChanged,
lobby: changes.worldChanged || lobbyChanged || ownLobbyGeneralChanged,
map: changes.worldChanged || mapEntitiesChanged || ownGeneralMapChanged,
commands: changes.worldChanged || commandEntitiesChanged || ownGeneralChanged,
contacts: changes.contactsChanged,
boardAccess: ownGeneralChanged || ownNationChanged,
reservedTurns: contains(changes.reservedGeneralIds, identity.generalId),
records:
changes.globalRecordsChanged ||
changes.worldHistoryChanged ||
contains(changes.recordGeneralIds, identity.generalId),
frontStatus:
Boolean(changes.frontStatusChanged) ||
frontStatusGeneralChanged ||
ownFrontStatusNationChanged ||
ownFrontStatusActorChanged,
};
};
export const createEmptyRealtimeReadModelChanges = (): RealtimeReadModelChanges => ({
generalIds: [],
cityIds: [],
@@ -125,6 +239,18 @@ export interface MessageCreatedEvent {
senderId: number;
}
export interface ReadModelInvalidatedEvent {
type: 'readModelInvalidated';
invalidation: RealtimeReadModelInvalidation;
}
export interface MessagesInvalidatedEvent {
type: 'messagesInvalidated';
}
/** Events safe to expose to an authenticated browser over SSE. */
export type PublicRealtimeEvent = ReadModelInvalidatedEvent | MessagesInvalidatedEvent;
export type RealtimeEvent =
| TurnCompletedEvent
| ReadModelChangedEvent
+1 -1
View File
@@ -12,7 +12,7 @@
"lint:fix": "eslint . --fix",
"test": "node -e \"console.log('test not configured')\"",
"prisma:generate": "pnpm prisma:generate:game && pnpm prisma:generate:gateway",
"typecheck": "tsc -b",
"typecheck": "pnpm -w tsc7 -b packages/infra/tsconfig.json",
"prisma:generate:game": "prisma generate --schema prisma/game.prisma",
"prisma:generate:gateway": "prisma generate --schema prisma/gateway.prisma",
"prisma:migrate:deploy:game": "prisma migrate deploy --schema prisma/game.prisma",
+1 -1
View File
@@ -18,7 +18,7 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsc -b"
"typecheck": "pnpm -w tsc7 -b packages/logic/tsconfig.json"
},
"dependencies": {
"@sammo-ts/common": "workspace:*",
+2 -2
View File
@@ -6,7 +6,7 @@
"scripts": {
"generate:resource-schemas": "tsx src/generate-resource-schemas.ts",
"validate:resources": "tsx src/validate-resources.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
"typecheck": "pnpm -w tsc7 -p packages/tools-scripts/tsconfig.json --noEmit"
},
"dependencies": {
"@sammo-ts/logic": "workspace:*",
@@ -15,6 +15,6 @@
"devDependencies": {
"@types/node": "^26.2.0",
"tsx": "^4.23.12",
"typescript": "6.0.2"
"typescript": "6.0.3"
}
}