feat: add state property to city and update related processing logic in battle simulation and world map

This commit is contained in:
2026-01-02 21:13:37 +00:00
parent edcfad4e40
commit 11ffdfef0e
13 changed files with 356 additions and 59 deletions
+1
View File
@@ -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,
+4 -1
View File
@@ -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,
+240
View File
@@ -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<number, number>;
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<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const asRecord = (value: unknown): Record<string, unknown> =>
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<string, unknown>): number => {
const raw = meta.state;
if (typeof raw === 'number' && Number.isFinite(raw)) {
return Math.floor(raw);
}
return 0;
};
const normalizeNumberRecord = (value: unknown): Record<number, number> => {
if (!isRecord(value)) {
return {};
}
const output: Record<number, number> = {};
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<string, unknown>): Record<number, number> => {
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<BaseMapResult | null> => {
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<MapCityRow[]>`
SELECT id,
level,
nation_id as "nationId",
region,
supply_state as "supplyState",
meta
FROM city
`,
ctx.db.$queryRaw<MapNationRow[]>`
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<WorldMapResult | null> => {
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<number, number> = {};
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<GeneralCityRow[]>`
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,
};
};
+20
View File
@@ -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
+1
View File
@@ -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: {
+1 -1
View File
@@ -6,7 +6,7 @@ const t = initTRPC.context<GameApiContext>().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',
+34 -26
View File
@@ -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,
@@ -199,6 +199,7 @@ export const seedScenarioToDatabase = async (
meta: asJson({
position: city.position,
connections: city.connections,
state: city.state,
...city.meta,
}),
})),
+5 -2
View File
@@ -110,7 +110,10 @@ const buildGeneralCreate = (
const buildCityUpdate = (
city: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['cities'][number]
): TurnEngineCityUpdateInput => {
const meta = city.meta as Record<string, unknown>;
const meta = {
...(city.meta as Record<string, unknown>),
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) {
+34 -26
View File
@@ -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,
+1
View File
@@ -67,6 +67,7 @@ export interface City {
name: string;
nationId: NationId;
level: number;
state: number;
population: number;
populationMax: number;
agriculture: number;
+13 -3
View File
@@ -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<string, TriggerValue> = {
const cityTriggerMeta: Record<string, TriggerValue> = {
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,
});
}
+1
View File
@@ -120,6 +120,7 @@ export interface CitySeed {
name: string;
nationId: number;
level: number;
state: number;
population: number;
populationMax: number;
agriculture: number;