refactor: tighten backend and logic boundaries

This commit is contained in:
2026-08-15 06:46:52 +00:00
parent 7b02ff155f
commit 35d8824b95
86 changed files with 820 additions and 1872 deletions
-55
View File
@@ -7,14 +7,6 @@ import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock
interface RedisSortedSetClient {
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
zRem(key: string, values: string | string[]): Promise<number>;
}
export interface AuctionEventUpdate {
auctionId: number;
closeAt: Date;
eventId: string;
eventAt: Date;
}
export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => {
@@ -58,50 +50,3 @@ export const seedAuctionTimers = async (
await redis.zAdd(keys.timerKey, payload);
return payload.length;
};
export const applyAuctionEvent = async (
db: DatabaseClient,
redis: RedisSortedSetClient,
keys: AuctionTimerKeys,
event: AuctionEventUpdate
): Promise<boolean> => {
const now = new Date();
const gameTime = await loadCurrentGameTime(db, now);
const closeTick = gameTime.dateToTick(event.closeAt);
const updated = await db.$executeRaw(
GamePrisma.sql`
UPDATE auction
SET close_at = ${event.closeAt},
close_tick = ${closeTick === null ? null : BigInt(closeTick)},
latest_event_id = ${event.eventId},
latest_event_at = ${event.eventAt},
updated_at = ${now}
WHERE id = ${event.auctionId}
AND status = 'OPEN'
AND (
latest_event_at < ${event.eventAt}
OR (latest_event_at = ${event.eventAt} AND latest_event_id < ${event.eventId})
)
`
);
if (updated > 0) {
await redis.zAdd(keys.timerKey, [
{
score: resolveAuctionTimerScore(gameTime, event.closeAt, closeTick === null ? null : BigInt(closeTick)),
value: String(event.auctionId),
},
]);
return true;
}
return false;
};
export const removeAuctionTimer = async (
redis: RedisSortedSetClient,
keys: AuctionTimerKeys,
auctionId: number
): Promise<void> => {
await redis.zRem(keys.timerKey, String(auctionId));
};
-45
View File
@@ -1,45 +0,0 @@
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
import { isAfter, isValid, parseISO } from 'date-fns';
import type { FlushStore } from './flushStore.js';
export interface GameTokenVerifier {
verify(token: string): GameSessionTokenPayload | null;
}
const parseDate = (value: string): Date | null => {
const parsed = parseISO(value);
return isValid(parsed) ? parsed : null;
};
export const createGameTokenVerifier = (options: {
secret: string;
profileName: string;
flushStore: FlushStore;
}): GameTokenVerifier => {
return {
verify: (token: string): GameSessionTokenPayload | null => {
const payload = decryptGameSessionToken(token, options.secret);
if (!payload) {
return null;
}
if (payload.profile !== options.profileName) {
return null;
}
const expiresAt = parseDate(payload.expiresAt);
const issuedAt = parseDate(payload.issuedAt);
if (!expiresAt || !issuedAt) {
return null;
}
if (isAfter(new Date(), expiresAt)) {
return null;
}
const flushedAt = options.flushStore.getFlushedAt(payload.user.id);
if (flushedAt && issuedAt <= flushedAt) {
return null;
}
return payload;
},
};
};
+3 -3
View File
@@ -2,7 +2,7 @@ import { z } from 'zod';
import type { BattleSimRequestPayload } from './types.js';
export const zBattleSimGeneral = z.object({
const zBattleSimGeneral = z.object({
no: z.number().int().positive(),
name: z.string().min(1),
nation: z.number().int().positive(),
@@ -46,7 +46,7 @@ export const zBattleSimGeneral = z.object({
inheritBuff: z.union([z.record(z.string(), z.number()), z.array(z.number())]).optional(),
});
export const zBattleSimCity = z.object({
const zBattleSimCity = z.object({
city: z.number().int().positive(),
nation: z.number().int().min(0),
supply: z.number().int().min(0),
@@ -70,7 +70,7 @@ export const zBattleSimCity = z.object({
conflict: z.string(),
});
export const zBattleSimNation = z.object({
const zBattleSimNation = z.object({
type: z.string().min(1),
tech: z.number().min(0),
level: z.number().int().min(0),
-152
View File
@@ -1,152 +0,0 @@
import { randomUUID } from 'node:crypto';
import type { TurnDaemonStreamKeys } from './streamKeys.js';
import type { TurnDaemonTransport } from './transport.js';
import type {
TurnDaemonCommand,
TurnDaemonCommandEnvelope,
TurnDaemonCommandResult,
TurnDaemonEventEnvelope,
TurnDaemonStatus,
} from './types.js';
interface RedisTurnDaemonTransportOptions {
keys: TurnDaemonStreamKeys;
requestTimeoutMs: number;
}
interface RedisClientLike {
xAdd(stream: string, id: string, message: Record<string, string>): Promise<string>;
xRead(streams: { key: string; id: string }, options?: { BLOCK?: number; COUNT?: number }): Promise<unknown>;
}
type RedisStreamReadResponse = Array<{
name: string;
messages: Array<{ id: string; message: Record<string, string> }>;
}>;
const buildCommandEnvelope = (command: TurnDaemonCommand): TurnDaemonCommandEnvelope => {
const requestId = command.requestId ?? randomUUID();
return {
requestId,
sentAt: new Date().toISOString(),
command,
};
};
const parseEventEnvelope = (raw: string): TurnDaemonEventEnvelope | null => {
try {
const parsed = JSON.parse(raw) as Partial<TurnDaemonEventEnvelope>;
if (!parsed || typeof parsed !== 'object') {
return null;
}
if (!parsed.event || typeof parsed.event !== 'object') {
return null;
}
if (typeof parsed.sentAt !== 'string') {
return null;
}
return parsed as TurnDaemonEventEnvelope;
} catch {
return null;
}
};
// 턴 데몬 제어 스트림을 Redis로 구현한 전송기.
export class RedisTurnDaemonTransport implements TurnDaemonTransport {
private readonly client: RedisClientLike;
private readonly keys: TurnDaemonStreamKeys;
private readonly requestTimeoutMs: number;
constructor(client: RedisClientLike, options: RedisTurnDaemonTransportOptions) {
this.client = client;
this.keys = options.keys;
this.requestTimeoutMs = options.requestTimeoutMs;
}
// Redis 스트림에 명령을 기록해서 턴 데몬에게 전달한다.
async sendCommand(command: TurnDaemonCommand): Promise<string> {
const envelope = buildCommandEnvelope(command);
await this.client.xAdd(this.keys.commandStream, '*', {
payload: JSON.stringify(envelope),
});
return envelope.requestId;
}
async requestCommand(command: TurnDaemonCommand, timeoutMs?: number): Promise<TurnDaemonCommandResult | null> {
const requestId = await this.sendCommand(command);
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs);
let lastId = '0-0';
while (Date.now() < deadline) {
const remaining = Math.max(1, deadline - Date.now());
const response = (await this.client.xRead(
{ key: this.keys.eventStream, id: lastId },
{ BLOCK: remaining, COUNT: 10 }
)) as RedisStreamReadResponse | null;
if (!response) {
return null;
}
for (const stream of response) {
for (const message of stream.messages) {
lastId = message.id;
const payload = message.message.payload;
if (!payload) {
continue;
}
const envelope = parseEventEnvelope(payload);
if (!envelope) {
continue;
}
if (envelope.event.type === 'commandResult' && envelope.requestId === requestId) {
return envelope.event.result;
}
}
}
}
return null;
}
async requestStatus(timeoutMs?: number): Promise<TurnDaemonStatus | null> {
const requestId = randomUUID();
await this.sendCommand({ type: 'getStatus', requestId });
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs);
let lastId = '0-0';
while (Date.now() < deadline) {
const remaining = Math.max(1, deadline - Date.now());
const response = (await this.client.xRead(
{ key: this.keys.eventStream, id: lastId },
{ BLOCK: remaining, COUNT: 10 }
)) as RedisStreamReadResponse | null;
if (!response) {
return null;
}
for (const stream of response) {
for (const message of stream.messages) {
lastId = message.id;
const payload = message.message.payload;
if (!payload) {
continue;
}
const envelope = parseEventEnvelope(payload);
if (!envelope) {
continue;
}
if (envelope.event.type === 'status' && envelope.requestId === requestId) {
return envelope.event.status;
}
}
}
}
return null;
}
}
-2
View File
@@ -14,9 +14,7 @@ export * from './daemon/transport.js';
export * from './daemon/databaseTransport.js';
export * from './daemon/idempotentTransport.js';
export * from './daemon/inMemoryTransport.js';
export * from './daemon/redisTransport.js';
export * from './auth/flushStore.js';
export * from './auth/tokenVerifier.js';
export * from './battleSim/types.js';
export * from './battleSim/transport.js';
export * from './battleSim/redisTransport.js';
+5 -1
View File
@@ -16,7 +16,11 @@ import {
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
import { loadAuthoritativeAccountIcon } from '../../services/accountIconSync.js';
import { loadCurrentGameTime } from '../../services/gameClock.js';
import { getSelectionPoolStatus, reserveSelectionPool, resolveSelectionMaxGeneral } from '../../services/selectPool.js';
import {
getSelectionPoolStatus,
reserveSelectionPool,
resolveSelectionMaxGeneral,
} from '@sammo-ts/game-engine/turn/selectPoolService.js';
import {
ConflictingTurnDaemonCommandError,
RejectedNpcPossessionCommandError,
+1 -1
View File
@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '../../services/selectPool.js';
import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
import { procedure, router } from '../../trpc.js';
export const lobbyRouter = router({
+2 -2
View File
@@ -210,9 +210,9 @@ export const resolveOfficerCity = (meta: Record<string, unknown>): number => {
return readMetaNumber(meta, 'officer_city', 0);
};
export const resolveBelong = (meta: Record<string, unknown>): number => readMetaNumber(meta, 'belong', 0);
const resolveBelong = (meta: Record<string, unknown>): number => readMetaNumber(meta, 'belong', 0);
export const resolvePermission = (meta: Record<string, unknown>): PermissionKind => {
const resolvePermission = (meta: Record<string, unknown>): PermissionKind => {
const value = meta.permission;
if (value === 'ambassador' || value === 'auditor') {
return value;
+8 -23
View File
@@ -70,14 +70,8 @@ export const resolveInheritConstants = (worldState: WorldStateRow): InheritConst
configConst.inheritBornTurntimePoint,
DEFAULT_INHERIT_CONST.inheritBornTurntimePoint
),
inheritBornCityPoint: asNumber(
configConst.inheritBornCityPoint,
DEFAULT_INHERIT_CONST.inheritBornCityPoint
),
inheritBornStatPoint: asNumber(
configConst.inheritBornStatPoint,
DEFAULT_INHERIT_CONST.inheritBornStatPoint
),
inheritBornCityPoint: asNumber(configConst.inheritBornCityPoint, DEFAULT_INHERIT_CONST.inheritBornCityPoint),
inheritBornStatPoint: asNumber(configConst.inheritBornStatPoint, DEFAULT_INHERIT_CONST.inheritBornStatPoint),
inheritItemUniqueMinPoint: asNumber(
configConst.inheritItemUniqueMinPoint,
DEFAULT_INHERIT_CONST.inheritItemUniqueMinPoint
@@ -152,18 +146,6 @@ export const setInheritancePoint = async (
});
};
export const addInheritancePoint = async (
db: DatabaseClient,
userId: string,
key: InheritPointKey,
delta: number
): Promise<number> => {
const current = await readInheritancePoint(db, userId, key);
const next = current + delta;
await setInheritancePoint(db, userId, key, next);
return next;
};
export const appendInheritanceLog = async (
db: DatabaseClient,
userId: string,
@@ -181,7 +163,7 @@ export const appendInheritanceLog = async (
});
};
export const readUserMetaValue = (meta: Record<string, unknown>, key: string): number => {
const readUserMetaValue = (meta: Record<string, unknown>, key: string): number => {
const value = meta[key];
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 0;
@@ -189,7 +171,7 @@ export const readUserMetaValue = (meta: Record<string, unknown>, key: string): n
return value;
};
export const computeDexPoint = (meta: Record<string, unknown>): number => {
const computeDexPoint = (meta: Record<string, unknown>): number => {
let total = 0;
for (const [key, value] of Object.entries(meta)) {
if (!key.startsWith('dex')) {
@@ -251,7 +233,10 @@ export const computeInheritanceItems = async (options: {
};
export const sumInheritanceItems = (items: Record<InheritPointKey, number>): number => {
return Object.entries(items).reduce((acc, [key, value]) => (key === 'previous' ? acc : acc + value), items.previous);
return Object.entries(items).reduce(
(acc, [key, value]) => (key === 'previous' ? acc : acc + value),
items.previous
);
};
export const readUserStateMeta = async (db: DatabaseClient, userId: string): Promise<Record<string, unknown>> => {
-12
View File
@@ -1,12 +0,0 @@
export {
buildSelectPoolSeed,
claimWeightedSelectionCandidates,
getSelectionPoolStatus,
isSelectionPoolWorld,
reserveSelectionPool,
resolveSelectionMaxGeneral,
SelectPoolError,
type SelectPoolCandidateDto,
type SelectPoolCandidateInfo,
type SelectPoolReservationDto,
} from '@sammo-ts/game-engine/turn/selectPoolService.js';
+68 -46
View File
@@ -19,8 +19,6 @@ export type TournamentPrismaClient = {
$transaction: (actions: Promise<unknown>[]) => Promise<unknown[]>;
};
export const sleepMs = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
export const isBattleStage = (stage: number): boolean => stage >= 7 && stage <= 10;
export const isPreBattleStage = (stage: number): boolean => stage >= 1 && stage <= 6;
@@ -50,7 +48,7 @@ export const resolveBettingCloseAt = (state: TournamentState): string => {
return new Date(resolveScheduledBaseMs(state) + Math.max(1000, bettingTermMs)).toISOString();
};
export const resolveStatValue = (
const resolveStatValue = (
type: TournamentType,
entry: { leadership: number; strength: number; intel: number }
): number => {
@@ -70,13 +68,34 @@ export const resolveStatValue = (
export const resolveGroupPair = (stage: number, phase: number): [number, number] | null => {
if (stage === 2) {
const pairMap: Array<[number, number]> = [
[0, 1], [2, 3], [4, 5], [6, 7],
[0, 2], [1, 3], [4, 6], [5, 7],
[0, 3], [1, 6], [2, 5], [4, 7],
[0, 4], [1, 5], [2, 6], [3, 7],
[0, 5], [1, 4], [2, 7], [3, 6],
[0, 6], [1, 7], [2, 4], [3, 5],
[0, 7], [1, 2], [3, 4], [5, 6],
[0, 1],
[2, 3],
[4, 5],
[6, 7],
[0, 2],
[1, 3],
[4, 6],
[5, 7],
[0, 3],
[1, 6],
[2, 5],
[4, 7],
[0, 4],
[1, 5],
[2, 6],
[3, 7],
[0, 5],
[1, 4],
[2, 7],
[3, 6],
[0, 6],
[1, 7],
[2, 4],
[3, 5],
[0, 7],
[1, 2],
[3, 4],
[5, 6],
];
const basePair = pairMap[phase % 28];
if (!basePair) {
@@ -87,9 +106,12 @@ export const resolveGroupPair = (stage: number, phase: number): [number, number]
if (stage === 4) {
const pairMap: Array<[number, number]> = [
[0, 1], [2, 3],
[0, 2], [1, 3],
[0, 3], [1, 2],
[0, 1],
[2, 3],
[0, 2],
[1, 3],
[0, 3],
[1, 2],
];
return pairMap[phase % 6] ?? null;
}
@@ -133,10 +155,8 @@ export const assignGroupSlots = (
});
};
export const selectWeighted = <T>(
rng: ReturnType<typeof createTournamentRng>,
pool: Array<{ item: T; weight: number }>
): T => rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight]));
const selectWeighted = <T>(rng: ReturnType<typeof createTournamentRng>, pool: Array<{ item: T; weight: number }>): T =>
rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight]));
export const fillParticipants = async (options: {
prisma: TournamentPrismaClient;
@@ -203,7 +223,10 @@ export const fillParticipants = async (options: {
while (result.length < limit && applicantPool.length > 0) {
const picked = selectWeighted(applicantRng, applicantPool);
applicantPool.splice(applicantPool.findIndex((entry) => entry.item.id === picked.id), 1);
applicantPool.splice(
applicantPool.findIndex((entry) => entry.item.id === picked.id),
1
);
takenIds.add(picked.id);
result.push(picked);
}
@@ -262,7 +285,10 @@ export const fillParticipants = async (options: {
while (result.length < limit && npcPool.length > 0) {
const picked = selectWeighted(npcRng, npcPool);
npcPool.splice(npcPool.findIndex((entry) => entry.item.id === picked.id), 1);
npcPool.splice(
npcPool.findIndex((entry) => entry.item.id === picked.id),
1
);
takenIds.add(picked.id);
result.push(picked);
}
@@ -344,28 +370,28 @@ export const applyGroupMatch = (
return {
participants: participants.map((entry) => {
if (entry.id !== attacker.id && entry.id !== defender.id) {
return entry;
}
const next = {
...entry,
win: entry.win ?? 0,
draw: entry.draw ?? 0,
lose: entry.lose ?? 0,
gl: entry.gl ?? 0,
};
if (result.draw) {
next.draw += 1;
if (entry.id !== attacker.id && entry.id !== defender.id) {
return entry;
}
const next = {
...entry,
win: entry.win ?? 0,
draw: entry.draw ?? 0,
lose: entry.lose ?? 0,
gl: entry.gl ?? 0,
};
if (result.draw) {
next.draw += 1;
return next;
}
if (result.winnerId === entry.id) {
next.win += 1;
next.gl += glDelta;
return next;
}
next.lose += 1;
next.gl -= glDelta;
return next;
}
if (result.winnerId === entry.id) {
next.win += 1;
next.gl += glDelta;
return next;
}
next.lose += 1;
next.gl -= glDelta;
return next;
}),
outcome,
};
@@ -573,7 +599,7 @@ export const buildTournamentRewardPayload = (
};
};
export const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
for (const key of keys) {
const value = source[key];
if (typeof value === 'number' && Number.isFinite(value)) {
@@ -598,11 +624,7 @@ export const seedNpcBets = async (options: {
const matches = await store.getMatches();
const candidateIds = Array.from(
new Set(
matches
.filter((match) => match.stage === 7)
.flatMap((match) => [match.attackerId, match.defenderId])
)
new Set(matches.filter((match) => match.stage === 7).flatMap((match) => [match.attackerId, match.defenderId]))
);
if (candidateIds.length === 0) {
return;
-7
View File
@@ -95,9 +95,6 @@ export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddle
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
// 앞에 두어 실패하거나 재시도되는 업무 transaction과 접속 기록을 분리한다.
export const accessProcedure: typeof procedure = t.procedure
.use(generalAccessEndpointMiddleware)
.use(inputEventMiddleware);
export const accessAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalAccessEndpointMiddleware)
@@ -119,10 +116,6 @@ export const sessionActivityProcedure = t.procedure;
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
export const accessReadOnlyAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalAccessEndpointMiddleware);
// 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를
// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다.
export const accessInputProcedure: typeof procedure.input = (input) =>
+3 -17
View File
@@ -1,7 +1,7 @@
import type { DatabaseClient, GeneralTurnRow, NationTurnRow, InputJsonValue } from '../context.js';
import { isRecord } from '@sammo-ts/common';
export const DEFAULT_TURN_ACTION = '휴식';
const DEFAULT_TURN_ACTION = '휴식';
export const MAX_GENERAL_TURNS = 30;
export const MAX_NATION_TURNS = 12;
@@ -157,7 +157,7 @@ const persistNationTurns = async (
});
};
export const loadGeneralTurns = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnEntry[]> => {
const loadGeneralTurns = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnEntry[]> => {
const rows = await db.generalTurn.findMany({
where: { generalId },
orderBy: [{ turnIdx: 'asc' }],
@@ -165,11 +165,6 @@ export const loadGeneralTurns = async (db: DatabaseClient, generalId: number): P
return buildTurnListFromRows(rows, MAX_GENERAL_TURNS);
};
export const listGeneralTurns = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnView[]> => {
const turns = await loadGeneralTurns(db, generalId);
return serializeTurnList(turns);
};
export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise<ReservedTurnSnapshot> => {
const [turns, revisionRow, general] = await Promise.all([
loadGeneralTurns(db, generalId),
@@ -187,7 +182,7 @@ export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: numb
};
};
export const loadNationTurns = async (
const loadNationTurns = async (
db: DatabaseClient,
nationId: number,
officerLevel: number
@@ -199,15 +194,6 @@ export const loadNationTurns = async (
return buildTurnListFromRows(rows, MAX_NATION_TURNS);
};
export const listNationTurns = async (
db: DatabaseClient,
nationId: number,
officerLevel: number
): Promise<ReservedTurnView[]> => {
const turns = await loadNationTurns(db, nationId, officerLevel);
return serializeTurnList(turns);
};
export const getNationTurnSnapshot = async (
db: DatabaseClient,
nationId: number,
+5 -27
View File
@@ -5,41 +5,19 @@ import { describe, expect, it } from 'vitest';
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import {
buildSelectPoolSeed,
claimWeightedSelectionCandidates,
} from '../src/services/selectPool.js';
import { buildSelectPoolSeed, claimWeightedSelectionCandidates } from '@sammo-ts/game-engine/turn/selectPoolService.js';
interface PoolResource {
data: Array<
[
string,
number,
number,
number,
string,
[number, number, number, number, number],
0 | 1,
string,
]
>;
data: Array<[string, number, number, number, string, [number, number, number, number, number], 0 | 1, string]>;
}
const loadWeightedRows = async (): Promise<Array<[{ id: number }, number]>> => {
const filePath = path.resolve(
import.meta.dirname,
'../../../resources/general-pool/SPoolUnderU30.json'
);
const filePath = path.resolve(import.meta.dirname, '../../../resources/general-pool/SPoolUnderU30.json');
const resource = JSON.parse(await fs.readFile(filePath, 'utf8')) as PoolResource;
return resource.data.map((row, index) => [
{ id: index + 1 },
row[5].reduce((sum, value) => sum + value, 0),
]);
return resource.data.map((row, index) => [{ id: index + 1 }, row[5].reduce((sum, value) => sum + value, 0)]);
};
const drawVector = async (
hiddenSeed: string
): Promise<{ selected: number[]; draws: number[] }> => {
const drawVector = async (hiddenSeed: string): Promise<{ selected: number[]; draws: number[] }> => {
const weighted = await loadWeightedRows();
const now = new Date('2026-07-30T03:34:56.000Z');
const draws: number[] = [];