Files
core2026/app/game-api/src/services/generalAccess.ts
T

351 lines
12 KiB
TypeScript

import { asRecord } from '@sammo-ts/common';
import { GamePrisma } from '@sammo-ts/infra';
import type { GameApiContext } from '../context.js';
export const accessPages = [
'nation-info',
'nation-cities',
'nation-list',
'current-city',
'dynasty',
'traffic',
'npc-control',
] as const;
export type AccessPage = (typeof accessPages)[number];
export const accessPageWeights: Record<AccessPage, number> = {
'nation-info': 1,
'nation-cities': 1,
'nation-list': 2,
'current-city': 1,
dynasty: 1,
traffic: 1,
'npc-control': 1,
};
export const generalAccessEndpointWeights = {
'world.getGeneralDirectory': 2,
'public.getNpcList': 2,
'ranking.getBestGeneral': 1,
'ranking.getHallOfFame': 1,
'tournament.getSnapshot': 1,
'nation.getSecretGeneralList': 1,
'nation.getPersonnelInfo': 1,
'nation.getGeneralList': 1,
'general.ensureDieOnPrestartStatus': 1,
'nation.getStratFinan': 1,
'board.getArticles': 1,
'diplomacy.getLetters': 2,
'battle.getGeneralDetail': 1,
'betting.getList': 1,
'general.getFrontStatus': 1,
'yearbook.getHistory': 1,
'world.getGlobalInfo': 1,
'nation.getBattleCenter': 1,
'nation.getChiefCenter': 1,
'troop.getList': 1,
'board.writeArticle': 1,
'board.writeComment': 1,
'diplomacy.sendLetter': 1,
'diplomacy.respondLetter': 1,
'diplomacy.rollbackLetter': 1,
'diplomacy.destroyLetter': 1,
'general.buildNationCandidate': 1,
'general.dieOnPrestart': 1,
'general.instantRetreat': 1,
'messages.send': 1,
'general.setMySetting': 0,
'npc.setNationPolicy': 0,
'npc.setNationPriority': 0,
'npc.setGeneralPriority': 0,
'battle.simulate': 0,
} as const satisfies Record<string, 0 | 1 | 2>;
export type GeneralAccessEndpoint = keyof typeof generalAccessEndpointWeights;
export const resolveGeneralAccessEndpointWeight = (
path: string,
input: unknown,
currentProfileName: string
): 0 | 1 | 2 | null | undefined => {
const weight = generalAccessEndpointWeights[path as GeneralAccessEndpoint];
if (weight === undefined) {
return undefined;
}
if (path === 'yearbook.getHistory') {
const requestedServer =
typeof input === 'object' && input !== null && 'serverID' in input
? (input as { serverID?: unknown }).serverID
: undefined;
if (requestedServer !== undefined && requestedServer !== currentProfileName) {
return null;
}
}
return weight;
};
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
): { periodStartedAt: Date; scoreStartedAt: Date } => {
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 { periodStartedAt: scoreStartedAt, scoreStartedAt };
};
export const upsertGeneralAccess = async (
db: Pick<GameApiContext['db'], '$transaction'>,
input: {
worldStateId: number;
year: number;
month: number;
tickSeconds: number;
generalId: number;
userId: string;
weight: number;
now: Date;
periodStartedAt: Date;
scoreStartedAt: Date;
}
): Promise<void> => {
if (!db.$transaction) {
throw new Error('Traffic access persistence requires transaction support.');
}
await db.$transaction(async (transaction) => {
const periodKey = input.year * 12 + input.month - 1;
const periodRows = await transaction.$queryRaw<Array<{ id: number }>>(
GamePrisma.sql`
WITH latest_period AS (
SELECT MAX(year * 12 + month - 1)::INTEGER AS period_key
FROM traffic_period
WHERE world_state_id = ${input.worldStateId}
),
missing_periods AS (
INSERT INTO traffic_period (
world_state_id,
year,
month,
started_at,
last_refresh,
refresh,
online
)
SELECT
${input.worldStateId},
(missing_key / 12)::INTEGER,
(missing_key % 12 + 1)::INTEGER,
CAST(${input.periodStartedAt} AS TIMESTAMP) - (
(${periodKey} - missing_key) * ${input.tickSeconds}
) * INTERVAL '1 second',
CAST(${input.periodStartedAt} AS TIMESTAMP) - (
(${periodKey} - missing_key - 1) * ${input.tickSeconds}
) * INTERVAL '1 second',
0,
0
FROM latest_period
CROSS JOIN LATERAL generate_series(
latest_period.period_key + 1,
${periodKey} - 1
) AS missing_key
WHERE latest_period.period_key IS NOT NULL
ON CONFLICT (world_state_id, year, month) DO NOTHING
RETURNING id
)
INSERT INTO traffic_period (
world_state_id,
year,
month,
started_at,
last_refresh,
refresh
)
VALUES (
${input.worldStateId},
${input.year},
${input.month},
${input.periodStartedAt},
${input.now},
${input.weight}
)
ON CONFLICT (world_state_id, year, month) DO UPDATE SET
started_at = LEAST(traffic_period.started_at, EXCLUDED.started_at),
last_refresh = GREATEST(traffic_period.last_refresh, EXCLUDED.last_refresh),
refresh = traffic_period.refresh + EXCLUDED.refresh
RETURNING id
`
);
const periodId = periodRows[0]?.id;
if (periodId === undefined) {
throw new Error('Failed to resolve the traffic period.');
}
await transaction.$executeRaw(
GamePrisma.sql`
WITH inserted_general AS (
INSERT INTO traffic_period_general (
period_id,
general_id,
user_id,
refresh,
last_refresh
)
VALUES (
${periodId},
${input.generalId},
${input.userId},
${input.weight},
${input.now}
)
ON CONFLICT (period_id, general_id) DO NOTHING
RETURNING period_id
),
updated_general AS (
UPDATE traffic_period_general
SET
user_id = ${input.userId},
refresh = traffic_period_general.refresh + ${input.weight},
last_refresh = GREATEST(
traffic_period_general.last_refresh,
${input.now}
)
WHERE period_id = ${periodId}
AND general_id = ${input.generalId}
AND NOT EXISTS (SELECT 1 FROM inserted_general)
RETURNING period_id
)
UPDATE traffic_period
SET online = traffic_period.online + (
SELECT COUNT(*)::INTEGER FROM inserted_general
)
WHERE id = ${periodId}
`
);
await transaction.$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.periodStartedAt}
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> => recordGeneralAccessWeight(ctx, accessPageWeights[page], now);
export const recordGeneralAccessWeight = async (
ctx: Pick<GameApiContext, 'auth' | 'db'>,
weight: number,
now = new Date()
): Promise<boolean> => {
if (!Number.isInteger(weight) || weight < 0) {
throw new RangeError('General access weight must be a non-negative integer.');
}
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: {
id: true,
currentYear: true,
currentMonth: true,
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 { periodStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta);
await upsertGeneralAccess(ctx.db, {
worldStateId: worldState.id,
year: worldState.currentYear,
month: worldState.currentMonth,
tickSeconds: worldState.tickSeconds,
generalId: general.id,
userId: user.id,
weight,
now,
periodStartedAt,
scoreStartedAt,
});
return true;
};