Merge branch 'main' into feature/nation-general-lists

# Conflicts:
#	app/game-frontend/package.json
#	app/game-frontend/src/router/index.ts
This commit is contained in:
2026-07-26 06:32:54 +00:00
206 changed files with 53242 additions and 33635 deletions
+188
View File
@@ -0,0 +1,188 @@
import { asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import type { GameApiContext } from '../context.js';
export const accessPages = [
'front-info',
'nation-info',
'nation-cities',
'global-info',
'nation-list',
'general-list',
'current-city',
'diplomacy',
'nation-generals',
'nation-personnel',
'nation-finance',
'battle-center',
'board',
'best-general',
'hall-of-fame',
'dynasty',
'yearbook',
'nation-betting',
'traffic',
'npc-list',
'my-page',
'npc-control',
'tournament',
'betting',
] as const;
export type AccessPage = (typeof accessPages)[number];
export const accessPageWeights: Record<AccessPage, number> = {
'front-info': 1,
'nation-info': 1,
'nation-cities': 1,
'global-info': 1,
'nation-list': 2,
'general-list': 2,
'current-city': 1,
diplomacy: 1,
'nation-generals': 1,
'nation-personnel': 1,
'nation-finance': 1,
'battle-center': 1,
board: 1,
'best-general': 1,
'hall-of-fame': 1,
dynasty: 1,
yearbook: 1,
'nation-betting': 1,
traffic: 1,
'npc-list': 2,
'my-page': 1,
'npc-control': 1,
tournament: 1,
betting: 1,
};
const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']);
const readFiniteNumber = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
const readDate = (value: unknown): Date | null => {
if (typeof value !== 'string' && !(value instanceof Date)) {
return null;
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
export const resolveAccessWindows = (
now: Date,
tickSeconds: number,
worldMeta: unknown
): { dayStartedAt: Date; scoreStartedAt: Date } => {
const dayStartedAt = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const meta = asRecord(worldMeta);
const tickStartedAt = readDate(meta.lastTurnTime) ?? readDate(meta.turntime);
const fallbackTickMs = Math.max(1, Math.floor(tickSeconds)) * 1_000;
const scoreStartedAt =
tickStartedAt && tickStartedAt.getTime() <= now.getTime()
? tickStartedAt
: new Date(now.getTime() - fallbackTickMs);
return { dayStartedAt, scoreStartedAt };
};
export const upsertGeneralAccess = async (
db: Pick<GameApiContext['db'], '$executeRaw'>,
input: {
generalId: number;
userId: string;
weight: number;
now: Date;
dayStartedAt: Date;
scoreStartedAt: Date;
}
): Promise<void> => {
await db.$executeRaw(
GamePrisma.sql`
INSERT INTO general_access_log (
general_id,
user_id,
last_refresh,
refresh,
refresh_total,
refresh_score,
refresh_score_total
)
VALUES (
${input.generalId},
${input.userId},
${input.now},
${input.weight},
${input.weight},
${input.weight},
${input.weight}
)
ON CONFLICT (general_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
last_refresh = EXCLUDED.last_refresh,
refresh = CASE
WHEN general_access_log.last_refresh IS NULL
OR general_access_log.last_refresh < ${input.dayStartedAt}
THEN EXCLUDED.refresh
ELSE general_access_log.refresh + EXCLUDED.refresh
END,
refresh_total = general_access_log.refresh_total + EXCLUDED.refresh_total,
refresh_score = CASE
WHEN general_access_log.last_refresh IS NULL
OR general_access_log.last_refresh < ${input.scoreStartedAt}
THEN EXCLUDED.refresh_score
ELSE general_access_log.refresh_score + EXCLUDED.refresh_score
END,
refresh_score_total =
general_access_log.refresh_score_total + EXCLUDED.refresh_score_total
`
);
};
export const recordGeneralAccess = async (
ctx: Pick<GameApiContext, 'auth' | 'db'>,
page: AccessPage,
now = new Date()
): Promise<boolean> => {
const user = ctx.auth?.user;
if (!user || user.roles.some((role) => adminRoles.has(role))) {
return false;
}
const [general, worldState] = await Promise.all([
ctx.db.general.findFirst({
where: { userId: user.id },
orderBy: { id: 'asc' },
select: { id: true, userId: true },
}),
ctx.db.worldState.findFirst({
orderBy: { id: 'asc' },
select: { tickSeconds: true, meta: true },
}),
]);
if (!general || !worldState) {
return false;
}
const meta = asRecord(worldState.meta);
const isUnited = readFiniteNumber(meta.isUnited) ?? readFiniteNumber(meta.isunited) ?? 0;
const openTime = readDate(meta.opentime);
if (isUnited === 2 || (openTime && openTime.getTime() > now.getTime())) {
return false;
}
const weight = accessPageWeights[page];
const { dayStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
await upsertGeneralAccess(ctx.db, {
generalId: general.id,
userId: user.id,
weight,
now,
dayStartedAt,
scoreStartedAt,
});
return true;
};