feat: Zod를 사용한 월드 상태 구성 및 메타 타입 추가, 사용자 ID 처리 개선

This commit is contained in:
2026-01-05 14:49:19 +00:00
parent 4391110ab0
commit 1861cbc9c6
17 changed files with 118 additions and 153 deletions
+18 -1
View File
@@ -1,3 +1,4 @@
import { z } from 'zod';
import type { GameSessionTokenPayload } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common';
import type { DatabaseClient as InfraDatabaseClient, RedisConnector } from '@sammo-ts/infra'; import type { DatabaseClient as InfraDatabaseClient, RedisConnector } from '@sammo-ts/infra';
@@ -10,6 +11,21 @@ export interface GameProfile {
name: string; name: string;
} }
export const zWorldStateConfig = z.object({
maxUserCnt: z.number().optional(),
fictionMode: z.string().optional(),
});
export type WorldStateConfig = z.infer<typeof zWorldStateConfig>;
export const zWorldStateMeta = z.object({
starttime: z.string().optional(),
opentime: z.string().optional(),
turntime: z.string().optional(),
otherTextInfo: z.string().optional(),
isUnited: z.number().optional(),
});
export type WorldStateMeta = z.infer<typeof zWorldStateMeta>;
export interface WorldStateRow { export interface WorldStateRow {
scenarioCode: string; scenarioCode: string;
currentYear: number; currentYear: number;
@@ -22,7 +38,7 @@ export interface WorldStateRow {
export interface GeneralRow { export interface GeneralRow {
id: number; id: number;
userId: number | null; userId: string | null;
name: string; name: string;
nationId: number; nationId: number;
cityId: number; cityId: number;
@@ -49,6 +65,7 @@ export interface GeneralRow {
atmos: number; atmos: number;
age: number; age: number;
npcState: number; npcState: number;
picture: string | null;
meta: unknown; meta: unknown;
} }
+21 -14
View File
@@ -1,7 +1,8 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import type { WorldStateRow } from './context.js'; import { zWorldStateConfig, zWorldStateMeta } from './context.js';
import type { GameApiContext, WorldStateRow } from './context.js';
import { authedProcedure, procedure, router } from './trpc.js'; import { authedProcedure, procedure, router } from './trpc.js';
import { buildTurnCommandTable } from './turns/commandTable.js'; import { buildTurnCommandTable } from './turns/commandTable.js';
import { import {
@@ -67,12 +68,12 @@ const toWorldStateSnapshot = (row: WorldStateRow) => ({
updatedAt: row.updatedAt.toISOString(), updatedAt: row.updatedAt.toISOString(),
}); });
const getMyGeneral = async (ctx: { db: any, auth: any }) => { const getMyGeneral = async (ctx: Pick<GameApiContext, 'db' | 'auth'>) => {
if (!ctx.auth?.user.id) { if (!ctx.auth?.user.id) {
throw new TRPCError({ code: 'UNAUTHORIZED' }); throw new TRPCError({ code: 'UNAUTHORIZED' });
} }
const general = await ctx.db.general.findFirst({ const general = await ctx.db.general.findFirst({
where: { userId: parseInt(ctx.auth.user.id) }, where: { userId: ctx.auth.user.id },
}); });
if (!general) { if (!general) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' }); throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' });
@@ -90,14 +91,20 @@ export const appRouter = router({
}), }),
lobby: router({ lobby: router({
info: procedure.query(async ({ ctx }) => { info: procedure.query(async ({ ctx }) => {
const worldState = await ctx.db.worldState.findFirst(); const rawWorldState = await ctx.db.worldState.findFirst();
if (!worldState) { if (!rawWorldState) {
throw new TRPCError({ throw new TRPCError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
message: 'World state not found', message: 'World state not found',
}); });
} }
const worldState = {
...rawWorldState,
config: zWorldStateConfig.parse(rawWorldState.config),
meta: zWorldStateMeta.parse(rawWorldState.meta),
};
const userCnt = await ctx.db.general.count({ where: { npcState: 0 } }); const userCnt = await ctx.db.general.count({ where: { npcState: 0 } });
const npcCnt = await ctx.db.general.count({ where: { npcState: { gt: 0 } } }); const npcCnt = await ctx.db.general.count({ where: { npcState: { gt: 0 } } });
const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } }); const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } });
@@ -111,8 +118,8 @@ export const appRouter = router({
}); });
if (general) { if (general) {
myGeneral = { myGeneral = {
name: (general as any).name, name: general.name,
picture: (general as any).picture, picture: general.picture,
}; };
} }
} }
@@ -121,16 +128,16 @@ export const appRouter = router({
year: worldState.currentYear, year: worldState.currentYear,
month: worldState.currentMonth, month: worldState.currentMonth,
userCnt, userCnt,
maxUserCnt: (worldState.config as any).maxUserCnt ?? 500, maxUserCnt: worldState.config.maxUserCnt ?? 500,
npcCnt, npcCnt,
nationCnt, nationCnt,
turnTerm: worldState.tickSeconds / 60, turnTerm: worldState.tickSeconds / 60,
fictionMode: (worldState.config as any).fictionMode ?? '사실', fictionMode: worldState.config.fictionMode ?? '사실',
starttime: (worldState.meta as any).starttime ?? '', starttime: worldState.meta.starttime ?? '',
opentime: (worldState.meta as any).opentime ?? '', opentime: worldState.meta.opentime ?? '',
turntime: (worldState.meta as any).turntime ?? '', turntime: worldState.meta.turntime ?? '',
otherTextInfo: (worldState.meta as any).otherTextInfo ?? '', otherTextInfo: worldState.meta.otherTextInfo ?? '',
isUnited: (worldState.meta as any).isUnited ?? 0, isUnited: worldState.meta.isUnited ?? 0,
myGeneral, myGeneral,
}; };
}), }),
+1 -1
View File
@@ -84,7 +84,7 @@ export const createGameApiServer = async () => {
const token = extractBearerToken(req.headers.authorization); const token = extractBearerToken(req.headers.authorization);
const auth = token ? tokenVerifier.verify(token) : null; const auth = token ? tokenVerifier.verify(token) : null;
return createGameApiContext({ return createGameApiContext({
db: postgres.prisma as unknown as DatabaseClient, db: postgres.prisma,
redis: redis.client, redis: redis.client,
turnDaemon, turnDaemon,
battleSim, battleSim,
@@ -1,7 +1,6 @@
import { import {
createGamePostgresConnector, createGamePostgresConnector,
type InputJsonValue, type InputJsonValue,
type TurnEngineDatabaseClient,
type TurnEngineEventCreateManyInput, type TurnEngineEventCreateManyInput,
} from '@sammo-ts/infra'; } from '@sammo-ts/infra';
import { import {
@@ -125,7 +124,7 @@ export const seedScenarioToDatabase = async (
await connector.connect(); await connector.connect();
try { try {
const prisma = connector.prisma as unknown as TurnEngineDatabaseClient; const prisma = connector.prisma;
if (options.resetTables ?? true) { if (options.resetTables ?? true) {
await prisma.event.deleteMany(); await prisma.event.deleteMany();
+5 -4
View File
@@ -2,7 +2,6 @@ import {
createGamePostgresConnector, createGamePostgresConnector,
type InputJsonValue, type InputJsonValue,
type TurnEngineCityUpdateInput, type TurnEngineCityUpdateInput,
type TurnEngineDatabaseClient,
type TurnEngineDiplomacyCreateManyInput, type TurnEngineDiplomacyCreateManyInput,
type TurnEngineDiplomacyUpdateInput, type TurnEngineDiplomacyUpdateInput,
type TurnEngineGeneralCreateManyInput, type TurnEngineGeneralCreateManyInput,
@@ -238,7 +237,7 @@ export const createDatabaseTurnHooks = async (
// 턴 처리 결과를 DB에 반영하는 훅을 만든다. // 턴 처리 결과를 DB에 반영하는 훅을 만든다.
const connector = createGamePostgresConnector({ url: databaseUrl }); const connector = createGamePostgresConnector({ url: databaseUrl });
await connector.connect(); await connector.connect();
const prisma = connector.prisma as unknown as TurnEngineDatabaseClient; const prisma = connector.prisma;
const hooks: TurnDaemonHooks = { const hooks: TurnDaemonHooks = {
flushChanges: async () => { flushChanges: async () => {
@@ -346,8 +345,10 @@ export const createDatabaseTurnHooks = async (
.map((entry) => .map((entry) =>
prisma.diplomacy.update({ prisma.diplomacy.update({
where: { where: {
srcNationId: entry.fromNationId, srcNationId_destNationId: {
destNationId: entry.toNationId, srcNationId: entry.fromNationId,
destNationId: entry.toNationId,
},
}, },
data: buildDiplomacyUpdate(entry), data: buildDiplomacyUpdate(entry),
}) })
@@ -36,15 +36,6 @@ export interface GatewayAdminActionConsumer {
stop(): Promise<void>; stop(): Promise<void>;
} }
type GatewayProfileRow = {
meta: unknown;
};
type GatewayProfileClient = {
findUnique(args: unknown): Promise<GatewayProfileRow | null>;
update(args: unknown): Promise<void>;
};
const DEFAULT_POLL_MS = 5000; const DEFAULT_POLL_MS = 5000;
const isRecord = (value: unknown): value is Record<string, unknown> => const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -75,9 +66,7 @@ export const createGatewayAdminActionConsumer = async (
url: options.gatewayDatabaseUrl ?? options.databaseUrl, url: options.gatewayDatabaseUrl ?? options.databaseUrl,
}); });
await connector.connect(); await connector.connect();
const prisma = connector.prisma as unknown as { const prisma = connector.prisma;
gatewayProfile: GatewayProfileClient;
};
let timer: NodeJS.Timeout | null = null; let timer: NodeJS.Timeout | null = null;
let inFlight = false; let inFlight = false;
+1 -12
View File
@@ -18,15 +18,6 @@ const DEFAULT_CACHE_MS = 2000;
const isRunningStatus = (status: string | null | undefined): boolean => const isRunningStatus = (status: string | null | undefined): boolean =>
status === 'RUNNING'; status === 'RUNNING';
type GatewayProfileRow = {
status: string | null;
};
type GatewayProfileClient = {
findUnique(args: unknown): Promise<GatewayProfileRow | null>;
update(args: unknown): Promise<void>;
};
export const createGatewayProfileGate = async ( export const createGatewayProfileGate = async (
options: GatewayProfileGateOptions options: GatewayProfileGateOptions
): Promise<GatewayProfileGate> => { ): Promise<GatewayProfileGate> => {
@@ -34,9 +25,7 @@ export const createGatewayProfileGate = async (
url: options.gatewayDatabaseUrl ?? options.databaseUrl, url: options.gatewayDatabaseUrl ?? options.databaseUrl,
}); });
await connector.connect(); await connector.connect();
const prisma = connector.prisma as unknown as { const prisma = connector.prisma;
gatewayProfile: GatewayProfileClient;
};
let lastCheckedAt = 0; let lastCheckedAt = 0;
let cachedPause = false; let cachedPause = false;
@@ -272,7 +272,7 @@ export const createReservedTurnStore = async (
const connector = createGamePostgresConnector({ url: options.databaseUrl }); const connector = createGamePostgresConnector({ url: options.databaseUrl });
await connector.connect(); await connector.connect();
const store = new InMemoryReservedTurnStore( const store = new InMemoryReservedTurnStore(
connector.prisma as unknown as ReservedTurnDatabaseClient, connector.prisma,
{ {
maxGeneralTurns: options.maxGeneralTurns ?? DEFAULT_GENERAL_TURNS, maxGeneralTurns: options.maxGeneralTurns ?? DEFAULT_GENERAL_TURNS,
maxNationTurns: options.maxNationTurns ?? DEFAULT_NATION_TURNS, maxNationTurns: options.maxNationTurns ?? DEFAULT_NATION_TURNS,
+1 -1
View File
@@ -255,7 +255,7 @@ export const loadTurnWorldFromDatabase = async (
const connector = createGamePostgresConnector({ url: options.databaseUrl }); const connector = createGamePostgresConnector({ url: options.databaseUrl });
await connector.connect(); await connector.connect();
try { try {
const prisma = connector.prisma as unknown as TurnEngineDatabaseClient; const prisma: TurnEngineDatabaseClient = connector.prisma;
const worldState = await prisma.worldState.findFirst(); const worldState = await prisma.worldState.findFirst();
if (!worldState) { if (!worldState) {
throw new Error('world_state row is required to start turn daemon.'); throw new Error('world_state row is required to start turn daemon.');
@@ -74,7 +74,7 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
row: GatewayProfileRecord, row: GatewayProfileRecord,
runtimeMap: Map<string, { apiRunning: boolean; daemonRunning: boolean }> runtimeMap: Map<string, { apiRunning: boolean; daemonRunning: boolean }>
): LobbyProfileStatus { ): LobbyProfileStatus {
const meta = row.meta as Record<string, any>; const meta = row.meta;
return { return {
profileName: row.profileName, profileName: row.profileName,
profile: row.profile, profile: row.profile,
@@ -85,8 +85,8 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
apiRunning: false, apiRunning: false,
daemonRunning: false, daemonRunning: false,
}, },
korName: (meta.korName as string) ?? row.profile, korName: (meta.korName as string | undefined) ?? row.profile,
color: (meta.color as string) ?? '#ffffff', color: (meta.color as string | undefined) ?? '#ffffff',
}; };
} }
} }
@@ -587,15 +587,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const connector = createGamePostgresConnector({ url: databaseUrl }); const connector = createGamePostgresConnector({ url: databaseUrl });
await connector.connect(); await connector.connect();
try { try {
const prisma = connector.prisma as unknown as { const row = await connector.prisma.worldState.findFirst({
worldState: {
findFirst: (args: unknown) => Promise<{
scenarioCode: string | null;
tickSeconds: number | null;
} | null>;
};
};
const row = await prisma.worldState.findFirst({
select: { scenarioCode: true, tickSeconds: true }, select: { scenarioCode: true, tickSeconds: true },
}); });
if (row) { if (row) {
@@ -122,15 +122,6 @@ type GatewayProfileRow = {
updatedAt: Date; updatedAt: Date;
}; };
type GatewayProfileClient = {
findMany(args: unknown): Promise<GatewayProfileRow[]>;
findUnique(args: unknown): Promise<GatewayProfileRow | null>;
findFirst(args: unknown): Promise<GatewayProfileRow | null>;
upsert(args: unknown): Promise<GatewayProfileRow>;
update(args: unknown): Promise<GatewayProfileRow>;
updateMany(args: unknown): Promise<unknown>;
};
const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({ const mapProfile = (row: GatewayProfileRow): GatewayProfileRecord => ({
profileName: row.profileName, profileName: row.profileName,
profile: row.profile, profile: row.profile,
@@ -161,23 +152,20 @@ export const createGatewayProfileRepository = (
prisma: GatewayPrismaClient prisma: GatewayPrismaClient
): GatewayProfileRepository => ({ ): GatewayProfileRepository => ({
async listProfiles(): Promise<GatewayProfileRecord[]> { async listProfiles(): Promise<GatewayProfileRecord[]> {
const gatewayProfile = prisma.gatewayProfile as unknown as GatewayProfileClient; const rows = await prisma.gatewayProfile.findMany({
const rows = await gatewayProfile.findMany({
orderBy: [{ profile: 'asc' }, { scenario: 'asc' }], orderBy: [{ profile: 'asc' }, { scenario: 'asc' }],
}); });
return rows.map(mapProfile); return rows.map(mapProfile);
}, },
async getProfile(profileName: string): Promise<GatewayProfileRecord | null> { async getProfile(profileName: string): Promise<GatewayProfileRecord | null> {
const gatewayProfile = prisma.gatewayProfile as unknown as GatewayProfileClient; const row = await prisma.gatewayProfile.findUnique({
const row = await gatewayProfile.findUnique({
where: { profileName }, where: { profileName },
}); });
return row ? mapProfile(row) : null; return row ? mapProfile(row) : null;
}, },
async upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord> { async upsertProfile(input: GatewayProfileUpsertInput): Promise<GatewayProfileRecord> {
const gatewayProfile = prisma.gatewayProfile as unknown as GatewayProfileClient;
const profileName = buildProfileName(input.profile, input.scenario); const profileName = buildProfileName(input.profile, input.scenario);
const row = await gatewayProfile.upsert({ const row = await prisma.gatewayProfile.upsert({
where: { profileName }, where: { profileName },
create: { create: {
profileName, profileName,
@@ -229,7 +217,7 @@ export const createGatewayProfileRepository = (
scheduledStartAt?: string | null; scheduledStartAt?: string | null;
} }
): Promise<GatewayProfileRecord | null> { ): Promise<GatewayProfileRecord | null> {
const gatewayProfile = prisma.gatewayProfile as unknown as GatewayProfileClient; const gatewayProfile = prisma.gatewayProfile;
const row = await gatewayProfile.update({ const row = await gatewayProfile.update({
where: { profileName }, where: { profileName },
data: { data: {
@@ -269,7 +257,7 @@ export const createGatewayProfileRepository = (
lastUsedAt?: string | null; lastUsedAt?: string | null;
} }
): Promise<GatewayProfileRecord | null> { ): Promise<GatewayProfileRecord | null> {
const gatewayProfile = prisma.gatewayProfile as unknown as GatewayProfileClient; const gatewayProfile = prisma.gatewayProfile;
const row = await gatewayProfile.update({ const row = await gatewayProfile.update({
where: { profileName }, where: { profileName },
data: { data: {
@@ -311,7 +299,7 @@ export const createGatewayProfileRepository = (
profileName: string, profileName: string,
meta: Record<string, unknown> meta: Record<string, unknown>
): Promise<GatewayProfileRecord | null> { ): Promise<GatewayProfileRecord | null> {
const gatewayProfile = prisma.gatewayProfile as unknown as GatewayProfileClient; const gatewayProfile = prisma.gatewayProfile;
const row = await gatewayProfile.update({ const row = await gatewayProfile.update({
where: { profileName }, where: { profileName },
data: { data: {
@@ -321,7 +309,7 @@ export const createGatewayProfileRepository = (
return row ? mapProfile(row) : null; return row ? mapProfile(row) : null;
}, },
async listReservedToStart(now: Date): Promise<GatewayProfileRecord[]> { async listReservedToStart(now: Date): Promise<GatewayProfileRecord[]> {
const gatewayProfile = prisma.gatewayProfile as unknown as GatewayProfileClient; const gatewayProfile = prisma.gatewayProfile;
const rows = await gatewayProfile.findMany({ const rows = await gatewayProfile.findMany({
where: { where: {
status: 'RESERVED', status: 'RESERVED',
@@ -333,7 +321,7 @@ export const createGatewayProfileRepository = (
return rows.map(mapProfile); return rows.map(mapProfile);
}, },
async findQueuedBuild(): Promise<GatewayProfileRecord | null> { async findQueuedBuild(): Promise<GatewayProfileRecord | null> {
const gatewayProfile = prisma.gatewayProfile as unknown as GatewayProfileClient; const gatewayProfile = prisma.gatewayProfile;
const row = await gatewayProfile.findFirst({ const row = await gatewayProfile.findFirst({
where: { buildStatus: 'QUEUED' }, where: { buildStatus: 'QUEUED' },
orderBy: { buildRequestedAt: 'asc' }, orderBy: { buildRequestedAt: 'asc' },
@@ -341,7 +329,7 @@ export const createGatewayProfileRepository = (
return row ? mapProfile(row) : null; return row ? mapProfile(row) : null;
}, },
async updateLastError(profileName: string, lastError: string | null): Promise<void> { async updateLastError(profileName: string, lastError: string | null): Promise<void> {
const gatewayProfile = prisma.gatewayProfile as unknown as GatewayProfileClient; const gatewayProfile = prisma.gatewayProfile;
await gatewayProfile.update({ await gatewayProfile.update({
where: { profileName }, where: { profileName },
data: { lastError }, data: { lastError },
@@ -352,7 +340,7 @@ export const createGatewayProfileRepository = (
workspace: string, workspace: string,
lastUsedAt: string lastUsedAt: string
): Promise<void> { ): Promise<void> {
const gatewayProfile = prisma.gatewayProfile as unknown as GatewayProfileClient; const gatewayProfile = prisma.gatewayProfile;
await gatewayProfile.update({ await gatewayProfile.update({
where: { profileName }, where: { profileName },
data: { data: {
@@ -365,7 +353,7 @@ export const createGatewayProfileRepository = (
if (!profileNames.length) { if (!profileNames.length) {
return; return;
} }
const gatewayProfile = prisma.gatewayProfile as unknown as GatewayProfileClient; const gatewayProfile = prisma.gatewayProfile;
await gatewayProfile.updateMany({ await gatewayProfile.updateMany({
where: { where: {
profileName: { in: profileNames }, profileName: { in: profileNames },
+9 -2
View File
@@ -1,11 +1,18 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref } from 'vue'; import { ref } from 'vue';
export interface UserInfo {
id: string;
username: string;
displayName: string;
roles: string[];
}
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const user = ref(null); const user = ref<UserInfo | null>(null);
const isLoggedIn = ref(false); const isLoggedIn = ref(false);
function setUser(userData: any) { function setUser(userData: UserInfo | null) {
user.value = userData; user.value = userData;
isLoggedIn.value = !!userData; isLoggedIn.value = !!userData;
} }
+19 -42
View File
@@ -1,12 +1,7 @@
export type JsonValue = import type { GamePrisma, LogCategory, LogScope } from './gamePrisma.js';
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };
export type InputJsonValue = JsonValue; export type JsonValue = GamePrisma.JsonValue;
export type InputJsonValue = GamePrisma.InputJsonValue;
export interface TurnEngineWorldStateRow { export interface TurnEngineWorldStateRow {
id: number; id: number;
@@ -148,7 +143,7 @@ export interface TurnEngineGeneralUpdateInput {
name: string; name: string;
nationId: number; nationId: number;
cityId: number; cityId: number;
troopId: number | null; troopId: number;
leadership: number; leadership: number;
strength: number; strength: number;
intel: number; intel: number;
@@ -181,7 +176,7 @@ export interface TurnEngineGeneralCreateManyInput {
name: string; name: string;
nationId: number; nationId: number;
cityId: number; cityId: number;
troopId?: number | null; troopId?: number;
npcState: number; npcState: number;
leadership: number; leadership: number;
strength: number; strength: number;
@@ -323,8 +318,8 @@ export interface TurnEngineEventCreateManyInput {
} }
export interface TurnEngineLogEntryCreateManyInput { export interface TurnEngineLogEntryCreateManyInput {
scope: string; scope: LogScope;
category: string; category: LogCategory;
subType: string | null; subType: string | null;
year: number; year: number;
month: number; month: number;
@@ -387,7 +382,12 @@ export interface TurnEngineDatabaseClient {
data: TurnEngineDiplomacyCreateManyInput[]; data: TurnEngineDiplomacyCreateManyInput[];
}): Promise<unknown>; }): Promise<unknown>;
update(args: { update(args: {
where: { srcNationId: number; destNationId: number }; where: {
srcNationId_destNationId: {
srcNationId: number;
destNationId: number;
};
};
data: TurnEngineDiplomacyUpdateInput; data: TurnEngineDiplomacyUpdateInput;
}): Promise<unknown>; }): Promise<unknown>;
deleteMany(args?: unknown): Promise<unknown>; deleteMany(args?: unknown): Promise<unknown>;
@@ -416,36 +416,13 @@ export interface TurnEngineDatabaseClient {
}): Promise<unknown>; }): Promise<unknown>;
}; };
generalTurn: { generalTurn: {
findMany(args?: { findMany(args?: unknown): Promise<TurnEngineGeneralTurnRow[]>;
where?: { generalId?: number }; deleteMany(args?: unknown): Promise<unknown>;
orderBy?: { turnIdx: 'asc' | 'desc' }[]; createMany(args?: unknown): Promise<unknown>;
}): Promise<TurnEngineGeneralTurnRow[]>;
deleteMany(args: { where: { generalId: number } }): Promise<unknown>;
createMany(args: {
data: Array<{
generalId: number;
turnIdx: number;
actionCode: string;
arg: InputJsonValue;
}>;
}): Promise<unknown>;
}; };
nationTurn: { nationTurn: {
findMany(args?: { findMany(args?: unknown): Promise<TurnEngineNationTurnRow[]>;
where?: { nationId?: number; officerLevel?: number }; deleteMany(args?: unknown): Promise<unknown>;
orderBy?: { turnIdx: 'asc' | 'desc' }[]; createMany(args?: unknown): Promise<unknown>;
}): Promise<TurnEngineNationTurnRow[]>;
deleteMany(args: {
where: { nationId: number; officerLevel: number };
}): Promise<unknown>;
createMany(args: {
data: Array<{
nationId: number;
officerLevel: number;
turnIdx: number;
actionCode: string;
arg: InputJsonValue;
}>;
}): Promise<unknown>;
}; };
} }
@@ -15,6 +15,6 @@ export interface TurnCommandModule<TSpec extends TurnCommandSpecBase = TurnComma
commandSpec: TSpec; commandSpec: TSpec;
ActionDefinition: new (...args: any[]) => GeneralActionDefinition; ActionDefinition: new (...args: any[]) => GeneralActionDefinition;
ActionResolver?: new (...args: any[]) => GeneralActionResolver; ActionResolver?: new (...args: any[]) => GeneralActionResolver;
CommandResolver?: new (...args: any[]) => unknown; CommandResolver?: new (...args: any[]) => any;
actionContextBuilder?: ActionContextBuilder; actionContextBuilder?: ActionContextBuilder;
} }
@@ -29,11 +29,13 @@ export interface CityDevelopmentEnvironment {
amount?: number; amount?: number;
} }
type NumberKeys<T> = { [K in keyof T]: T[K] extends number ? K : never }[keyof T];
export interface CityDevelopmentConfig { export interface CityDevelopmentConfig {
key: string; key: string;
name: string; name: string;
statKey: keyof City; statKey: NumberKeys<City>;
maxKey: keyof City; maxKey: NumberKeys<City>;
label: string; label: string;
baseAmount: number; baseAmount: number;
} }
@@ -74,8 +76,8 @@ export class CityDevelopmentActionDefinition<
occupiedCity(), occupiedCity(),
suppliedCity(), suppliedCity(),
remainCityCapacityByMax( remainCityCapacityByMax(
String(this.config.statKey), this.config.statKey,
String(this.config.maxKey), this.config.maxKey,
this.config.label this.config.label
), ),
reqGeneralGold(getRequiredGold), reqGeneralGold(getRequiredGold),
@@ -105,7 +107,7 @@ export class CityDevelopmentActionDefinition<
const costGold = this.env.develCost ?? 0; const costGold = this.env.develCost ?? 0;
// 직접 수정 (Immer Draft) // 직접 수정 (Immer Draft)
(city as any)[this.config.statKey] = nextValue; city[this.config.statKey] = nextValue;
general.gold = Math.max(0, general.gold - costGold); general.gold = Math.max(0, general.gold - costGold);
const logMessage = `${this.config.label}${nextValue - current} 증가했습니다.`; const logMessage = `${this.config.label}${nextValue - current} 증가했습니다.`;
+17 -20
View File
@@ -143,7 +143,7 @@ export const suppliedDestCity = (): Constraint => ({
}); });
export const remainCityCapacity = ( export const remainCityCapacity = (
key: string, key: keyof City,
label: string label: string
): Constraint => ({ ): Constraint => ({
name: 'RemainCityCapacity', name: 'RemainCityCapacity',
@@ -158,11 +158,10 @@ export const remainCityCapacity = (
const req: RequirementKey = { kind: 'city', id: ctx.cityId }; const req: RequirementKey = { kind: 'city', id: ctx.cityId };
return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.');
} }
const record = city as unknown as Record<string, number | undefined>; const maxKey = `${String(key)}Max` as keyof City;
const maxKey = `${key}_max`; const current = city[key];
const current = record[key]; const max = city[maxKey];
const max = record[maxKey]; if (typeof current !== 'number' || typeof max !== 'number') {
if (current === undefined || max === undefined) {
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
} }
if (current < max) { if (current < max) {
@@ -173,8 +172,8 @@ export const remainCityCapacity = (
}); });
export const remainCityCapacityByMax = ( export const remainCityCapacityByMax = (
key: string, key: keyof City,
maxKey: string, maxKey: keyof City,
label: string label: string
): Constraint => ({ ): Constraint => ({
name: 'RemainCityCapacityByMax', name: 'RemainCityCapacityByMax',
@@ -189,10 +188,9 @@ export const remainCityCapacityByMax = (
const req: RequirementKey = { kind: 'city', id: ctx.cityId }; const req: RequirementKey = { kind: 'city', id: ctx.cityId };
return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.');
} }
const record = city as unknown as Record<string, number | undefined>; const current = city[key];
const current = record[key]; const max = city[maxKey];
const max = record[maxKey]; if (typeof current !== 'number' || typeof max !== 'number') {
if (current === undefined || max === undefined) {
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
} }
if (current < max) { if (current < max) {
@@ -203,7 +201,7 @@ export const remainCityCapacityByMax = (
}); });
export const reqCityCapacity = ( export const reqCityCapacity = (
key: string, key: keyof City,
label: string, label: string,
required: number | string required: number | string
): Constraint => ({ ): Constraint => ({
@@ -219,16 +217,15 @@ export const reqCityCapacity = (
const req: RequirementKey = { kind: 'city', id: ctx.cityId }; const req: RequirementKey = { kind: 'city', id: ctx.cityId };
return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.'); return unknownOrDeny(ctx, [req], '도시 정보가 없습니다.');
} }
const record = city as unknown as Record<string, number | undefined>; const current = city[key];
const current = record[key]; if (typeof current !== 'number') {
if (current === undefined) {
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
} }
if (typeof required === 'string') { if (typeof required === 'string') {
const ratio = parsePercent(required); const ratio = parsePercent(required);
const maxKey = `${key}Max`; const maxKey = `${String(key)}Max` as keyof City;
const max = record[maxKey]; const max = city[maxKey];
if (ratio === null || max === undefined) { if (ratio === null || typeof max !== 'number') {
return unknownOrDeny(ctx, [], '도시 정보가 없습니다.'); return unknownOrDeny(ctx, [], '도시 정보가 없습니다.');
} }
if (current >= max * ratio) { if (current >= max * ratio) {
@@ -256,7 +253,7 @@ export const reqCityTrust = (minTrust: number): Constraint => ({
} }
const trust = const trust =
readMetaNumberFromUnknown( readMetaNumberFromUnknown(
city.meta as Record<string, unknown>, city.meta,
'trust' 'trust'
) ?? null; ) ?? null;
if (trust === null) { if (trust === null) {