diff --git a/app/game-api/src/battleSim/processor.ts b/app/game-api/src/battleSim/processor.ts index 8b66f4b..632540f 100644 --- a/app/game-api/src/battleSim/processor.ts +++ b/app/game-api/src/battleSim/processor.ts @@ -49,6 +49,7 @@ const mapCityPayload = (payload: BattleSimJobPayload['attackerCity']): City => ( name: payload.name, nationId: payload.nation, level: payload.level, + state: payload.state, population: payload.pop, populationMax: payload.pop_max, agriculture: payload.agri, diff --git a/app/game-api/src/context.ts b/app/game-api/src/context.ts index ed788ce..e31f6ad 100644 --- a/app/game-api/src/context.ts +++ b/app/game-api/src/context.ts @@ -1,5 +1,5 @@ import type { GameSessionTokenPayload } from '@sammo-ts/common'; -import type { DatabaseClient as InfraDatabaseClient } from '@sammo-ts/infra'; +import type { DatabaseClient as InfraDatabaseClient, RedisConnector } from '@sammo-ts/infra'; import type { TurnDaemonTransport } from './daemon/transport.js'; import type { BattleSimTransport } from './battleSim/transport.js'; @@ -117,6 +117,7 @@ export type DatabaseClient = InfraDatabaseClient< export interface GameApiContext { db: DatabaseClient; + redis: RedisConnector['client']; turnDaemon: TurnDaemonTransport; battleSim: BattleSimTransport; profile: GameProfile; @@ -125,6 +126,7 @@ export interface GameApiContext { export const createGameApiContext = (options: { db: DatabaseClient; + redis: RedisConnector['client']; turnDaemon: TurnDaemonTransport; battleSim: BattleSimTransport; profile: GameProfile; @@ -132,6 +134,7 @@ export const createGameApiContext = (options: { }): GameApiContext => { return { db: options.db, + redis: options.redis, turnDaemon: options.turnDaemon, battleSim: options.battleSim, profile: options.profile, diff --git a/app/game-api/src/maps/worldMap.ts b/app/game-api/src/maps/worldMap.ts new file mode 100644 index 0000000..c9786ff --- /dev/null +++ b/app/game-api/src/maps/worldMap.ts @@ -0,0 +1,240 @@ +import type { GameApiContext, WorldStateRow } from '../context.js'; + +export type MapCityCompact = [number, number, number, number, number, number]; +export type MapNationCompact = [number, string, string, number]; + +export type BaseMapResult = { + result: true; + version: 0; + startYear: number; + year: number; + month: number; + cityList: MapCityCompact[]; + nationList: MapNationCompact[]; +}; + +export type WorldMapResult = BaseMapResult & { + spyList: Record; + shownByGeneralList: number[]; + myCity: number | null; + myNation: number | null; +}; + +type MapCityRow = { + id: number; + level: number; + nationId: number; + region: number; + supplyState: number; + meta: unknown; +}; + +type MapNationRow = { + id: number; + name: string; + color: string; + capitalCityId: number | null; + meta: unknown; +}; + +type GeneralCityRow = { + cityId: number; +}; + +const MAP_VERSION = 0 as const; +const BASE_MAP_TTL_SECONDS = 30; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const asRecord = (value: unknown): Record => + isRecord(value) ? value : {}; + +const resolveStartYear = (worldState: WorldStateRow): number => { + const meta = asRecord(worldState.meta); + const scenarioMeta = asRecord(meta.scenarioMeta); + const startYear = scenarioMeta.startYear; + if (typeof startYear === 'number' && Number.isFinite(startYear)) { + return startYear; + } + return 0; +}; + +const readState = (meta: Record): number => { + const raw = meta.state; + if (typeof raw === 'number' && Number.isFinite(raw)) { + return Math.floor(raw); + } + return 0; +}; + +const normalizeNumberRecord = (value: unknown): Record => { + if (!isRecord(value)) { + return {}; + } + const output: Record = {}; + for (const [key, rawValue] of Object.entries(value)) { + const keyNumber = Number(key); + if (!Number.isFinite(keyNumber)) { + continue; + } + if (typeof rawValue === 'number' && Number.isFinite(rawValue)) { + output[keyNumber] = Math.floor(rawValue); + } + } + return output; +}; + +const resolveSpyList = (meta: Record): Record => { + if (meta.spyList !== undefined) { + return normalizeNumberRecord(meta.spyList); + } + if (meta.spy !== undefined) { + return normalizeNumberRecord(meta.spy); + } + return {}; +}; + +const buildBaseMapCacheKey = (ctx: GameApiContext): string => + `sammo:map:base:${ctx.profile.id}:${ctx.profile.scenario}`; + +const loadBaseMap = async ( + ctx: GameApiContext, + useCache: boolean +): Promise => { + const cacheKey = buildBaseMapCacheKey(ctx); + if (useCache) { + const cached = await ctx.redis.get(cacheKey); + if (cached) { + try { + return JSON.parse(cached) as BaseMapResult; + } catch { + // Ignore cache parse errors. + } + } + } + + const worldState = await ctx.db.worldState.findFirst(); + if (!worldState) { + return null; + } + + const [cityRows, nationRows] = await Promise.all([ + ctx.db.$queryRaw` + SELECT id, + level, + nation_id as "nationId", + region, + supply_state as "supplyState", + meta + FROM city + `, + ctx.db.$queryRaw` + SELECT id, + name, + color, + capital_city_id as "capitalCityId", + meta + FROM nation + `, + ]); + + const cityList: MapCityCompact[] = cityRows.map((row) => { + const meta = asRecord(row.meta); + const state = readState(meta); + const supplyFlag = row.supplyState > 0 ? 1 : 0; + return [ + row.id, + row.level, + state, + row.nationId, + row.region, + supplyFlag, + ]; + }); + + const nationList: MapNationCompact[] = nationRows.map((row) => [ + row.id, + row.name, + row.color, + row.capitalCityId ?? 0, + ]); + + const baseMap: BaseMapResult = { + result: true, + version: MAP_VERSION, + startYear: resolveStartYear(worldState), + year: worldState.currentYear, + month: worldState.currentMonth, + cityList, + nationList, + }; + + if (useCache) { + await ctx.redis.set(cacheKey, JSON.stringify(baseMap), { + EX: BASE_MAP_TTL_SECONDS, + }); + } + + return baseMap; +}; + +export const loadWorldMap = async ( + ctx: GameApiContext, + options: { + generalId?: number; + neutralView?: boolean; + showMe?: boolean; + useCache?: boolean; + } +): Promise => { + const baseMap = await loadBaseMap(ctx, options.useCache ?? true); + if (!baseMap) { + return null; + } + + let myCity: number | null = null; + let myNation: number | null = null; + let spyList: Record = {}; + let shownByGeneralList: number[] = []; + + if (options.generalId) { + const general = await ctx.db.general.findUnique({ + where: { id: options.generalId }, + }); + if (general) { + if (options.showMe !== false && general.cityId > 0) { + myCity = general.cityId; + } + if (options.neutralView !== true && general.nationId > 0) { + myNation = general.nationId; + } + } + } + + if (myNation !== null) { + const nation = await ctx.db.nation.findUnique({ + where: { id: myNation }, + }); + if (nation) { + spyList = resolveSpyList(asRecord(nation.meta)); + } + + const generalCities = await ctx.db.$queryRaw` + SELECT DISTINCT city_id as "cityId" + FROM general + WHERE nation_id = ${myNation} + `; + shownByGeneralList = generalCities + .map((row) => row.cityId) + .filter((id) => Number.isFinite(id)); + } + + return { + ...baseMap, + spyList, + shownByGeneralList, + myCity, + myNation, + }; +}; diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index 274a1e9..ae60ac4 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -29,6 +29,7 @@ import { } from './messages/store.js'; import { buildBattleSimJobPayload } from './battleSim/environment.js'; import { zBattleSimJobId, zBattleSimRequest } from './battleSim/schema.js'; +import { loadWorldMap } from './maps/worldMap.js'; const zRunReason = z.enum(['schedule', 'manual', 'poke']); const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']); @@ -101,6 +102,25 @@ export const appRouter = router({ const state = await ctx.db.worldState.findFirst(); return state ? toWorldStateSnapshot(state) : null; }), + getMap: procedure + .input( + z.object({ + generalId: z.number().int().positive().optional(), + neutralView: z.boolean().optional(), + showMe: z.boolean().optional(), + useCache: z.boolean().optional(), + }) + ) + .query(async ({ ctx, input }) => { + const map = await loadWorldMap(ctx, input); + if (!map) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'World state is not initialized.', + }); + } + return map; + }), }), turns: router({ getCommandTable: authedProcedure diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 552ac63..999620a 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -83,6 +83,7 @@ export const createGameApiServer = async () => { const auth = token ? tokenVerifier.verify(token) : null; return createGameApiContext({ db: postgres.prisma as unknown as DatabaseClient, + redis: redis.client, turnDaemon, battleSim, profile: { diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 8b2221b..51de120 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -6,7 +6,7 @@ const t = initTRPC.context().create(); export const router = t.router; export const procedure = t.procedure; -export const authedProcedure = t.procedure.use(({ ctx, next }) => { +export const authedProcedure: typeof t.procedure = t.procedure.use(({ ctx, next }) => { if (!ctx.auth) { throw new TRPCError({ code: 'UNAUTHORIZED', diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 12049a4..dff98d4 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -325,32 +325,40 @@ const mapGeneralRow = (row: GeneralRow): General => ({ meta: asTriggerRecord(row.meta), }); -const mapCityRow = (row: CityRow): City => ({ - id: row.id, - name: row.name, - nationId: row.nationId, - level: row.level, - population: row.population, - populationMax: row.populationMax, - agriculture: row.agriculture, - agricultureMax: row.agricultureMax, - commerce: row.commerce, - commerceMax: row.commerceMax, - security: row.security, - securityMax: row.securityMax, - supplyState: row.supplyState, - frontState: row.frontState, - defence: row.defence, - defenceMax: row.defenceMax, - wall: row.wall, - wallMax: row.wallMax, - meta: { - ...asTriggerRecord(row.meta), - trust: row.trust, - trade: row.trade, - region: row.region, - }, -}); +const mapCityRow = (row: CityRow): City => { + const meta = asTriggerRecord(row.meta); + const state = + typeof meta.state === 'number' && Number.isFinite(meta.state) + ? Math.floor(meta.state) + : 0; + return { + id: row.id, + name: row.name, + nationId: row.nationId, + level: row.level, + state, + population: row.population, + populationMax: row.populationMax, + agriculture: row.agriculture, + agricultureMax: row.agricultureMax, + commerce: row.commerce, + commerceMax: row.commerceMax, + security: row.security, + securityMax: row.securityMax, + supplyState: row.supplyState, + frontState: row.frontState, + defence: row.defence, + defenceMax: row.defenceMax, + wall: row.wall, + wallMax: row.wallMax, + meta: { + ...meta, + trust: row.trust, + trade: row.trade, + region: row.region, + }, + }; +}; const mapNationRow = (row: NationRow): Nation => ({ id: row.id, diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index f42ac61..2425a01 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -199,6 +199,7 @@ export const seedScenarioToDatabase = async ( meta: asJson({ position: city.position, connections: city.connections, + state: city.state, ...city.meta, }), })), diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 620300e..7cca985 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -110,7 +110,10 @@ const buildGeneralCreate = ( const buildCityUpdate = ( city: ReturnType['cities'][number] ): TurnEngineCityUpdateInput => { - const meta = city.meta as Record; + const meta = { + ...(city.meta as Record), + state: city.state, + }; const trust = readMetaNumber(meta, 'trust'); const trade = readMetaNumber(meta, 'trade'); const region = readMetaNumber(meta, 'region'); @@ -133,7 +136,7 @@ const buildCityUpdate = ( defenceMax: city.defenceMax, wall: city.wall, wallMax: city.wallMax, - meta: asJson(city.meta), + meta: asJson(meta), }; if (trust !== null) { diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index dd415b4..b99032b 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -179,32 +179,40 @@ const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => ({ recentWarTime: row.recentWarTime ?? null, }); -const mapCityRow = (row: TurnEngineCityRow): City => ({ - id: row.id, - name: row.name, - nationId: row.nationId, - level: row.level, - population: row.population, - populationMax: row.populationMax, - agriculture: row.agriculture, - agricultureMax: row.agricultureMax, - commerce: row.commerce, - commerceMax: row.commerceMax, - security: row.security, - securityMax: row.securityMax, - supplyState: row.supplyState, - frontState: row.frontState, - defence: row.defence, - defenceMax: row.defenceMax, - wall: row.wall, - wallMax: row.wallMax, - meta: { - ...asTriggerRecord(row.meta), - trust: row.trust, - trade: row.trade, - region: row.region, - }, -}); +const mapCityRow = (row: TurnEngineCityRow): City => { + const meta = asTriggerRecord(row.meta); + const state = + typeof meta.state === 'number' && Number.isFinite(meta.state) + ? Math.floor(meta.state) + : 0; + return { + id: row.id, + name: row.name, + nationId: row.nationId, + level: row.level, + state, + population: row.population, + populationMax: row.populationMax, + agriculture: row.agriculture, + agricultureMax: row.agricultureMax, + commerce: row.commerce, + commerceMax: row.commerceMax, + security: row.security, + securityMax: row.securityMax, + supplyState: row.supplyState, + frontState: row.frontState, + defence: row.defence, + defenceMax: row.defenceMax, + wall: row.wall, + wallMax: row.wallMax, + meta: { + ...meta, + trust: row.trust, + trade: row.trade, + region: row.region, + }, + }; +}; const mapNationRow = (row: TurnEngineNationRow): Nation => ({ id: row.id, diff --git a/packages/logic/src/domain/entities.ts b/packages/logic/src/domain/entities.ts index d3305f2..9317fcf 100644 --- a/packages/logic/src/domain/entities.ts +++ b/packages/logic/src/domain/entities.ts @@ -67,6 +67,7 @@ export interface City { name: string; nationId: NationId; level: number; + state: number; population: number; populationMax: number; agriculture: number; diff --git a/packages/logic/src/world/bootstrap.ts b/packages/logic/src/world/bootstrap.ts index e9d5a72..7fb02f5 100644 --- a/packages/logic/src/world/bootstrap.ts +++ b/packages/logic/src/world/bootstrap.ts @@ -559,11 +559,17 @@ export const buildScenarioBootstrap = ( for (const city of map.cities) { const nationId = cityOwnership.get(city.id) ?? 0; + const rawCityMeta = city.meta ?? {}; + const state = + typeof rawCityMeta.state === 'number' && Number.isFinite(rawCityMeta.state) + ? Math.floor(rawCityMeta.state) + : 0; const seed: CitySeed = { id: city.id, name: city.name, nationId, level: city.level, + state, population: city.initial.population, populationMax: city.max.population, agriculture: city.initial.agriculture, @@ -583,11 +589,14 @@ export const buildScenarioBootstrap = ( region: city.region, position: city.position, connections: city.connections, - meta: city.meta ?? {}, + meta: { + ...rawCityMeta, + state, + }, }; seedCities.push(seed); - const cityMeta: Record = { + const cityTriggerMeta: Record = { region: city.region, trust: seed.trust, trade: seed.trade, @@ -600,6 +609,7 @@ export const buildScenarioBootstrap = ( name: seed.name, nationId: seed.nationId, level: seed.level, + state, population: seed.population, populationMax: seed.populationMax, agriculture: seed.agriculture, @@ -614,7 +624,7 @@ export const buildScenarioBootstrap = ( defenceMax: seed.defenceMax, wall: seed.wall, wallMax: seed.wallMax, - meta: cityMeta, + meta: cityTriggerMeta, }); } diff --git a/packages/logic/src/world/types.ts b/packages/logic/src/world/types.ts index ab611ec..894bb49 100644 --- a/packages/logic/src/world/types.ts +++ b/packages/logic/src/world/types.ts @@ -120,6 +120,7 @@ export interface CitySeed { name: string; nationId: number; level: number; + state: number; population: number; populationMax: number; agriculture: number;