import { JosaUtil } from '@sammo-ts/common'; import { LogCategory, LogFormat, LogScope, type MapDefinition } from '@sammo-ts/logic'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { MonthlyEventActionHandler } from './monthlyEventHandler.js'; // REF-COMPAT:BEGIN ref-int-column-write-rounding const roundLegacyIntegerColumn = (value: number): number => Math.round(value); // REF-COMPAT:END ref-int-column-write-rounding const resolveOfficerCity = (meta: Record): number => { const camel = meta.officerCity; if (typeof camel === 'number' && Number.isFinite(camel)) { return Math.floor(camel); } const snake = meta.officer_city; return typeof snake === 'number' && Number.isFinite(snake) ? Math.floor(snake) : 0; }; export const createUpdateCitySupplyHandler = (options: { getWorld: () => InMemoryTurnWorld | null; map: MapDefinition; }): MonthlyEventActionHandler => { const mapCityById = new Map(options.map.cities.map((city) => [city.id, city])); return (_args, environment) => { const world = options.getWorld(); if (!world) { return; } const cities = world.listCities().sort((left, right) => left.id - right.id); const ownedCityById = new Map( cities.filter((city) => city.nationId !== 0).map((city) => [city.id, { ...city, supplied: false }]) ); const queue: Array<{ id: number; nationId: number }> = []; for (const nation of world .listNations() .filter((candidate) => candidate.level > 0) .sort((left, right) => left.id - right.id)) { if (nation.capitalCityId === null) { continue; } const capital = ownedCityById.get(nation.capitalCityId); if (!capital || capital.nationId !== nation.id) { continue; } capital.supplied = true; queue.push({ id: capital.id, nationId: nation.id }); } for (let cursor = 0; cursor < queue.length; cursor += 1) { const current = queue[cursor]!; const mapCity = mapCityById.get(current.id); if (!mapCity) { throw new Error(`UpdateCitySupply map city is missing (cityId=${current.id})`); } for (const connectedCityId of mapCity.connections) { const connected = ownedCityById.get(connectedCityId); if (!connected || connected.nationId !== current.nationId || connected.supplied) { continue; } connected.supplied = true; queue.push({ id: connected.id, nationId: current.nationId }); } } const unsuppliedCities = []; for (const city of cities) { const supplied = city.nationId === 0 || ownedCityById.get(city.id)?.supplied === true; if (supplied) { world.updateCity(city.id, { supplyState: 1 }); continue; } const trust = typeof city.meta.trust === 'number' ? city.meta.trust : 0; const damaged = world.updateCity(city.id, { supplyState: 0, population: roundLegacyIntegerColumn(city.population * 0.9), agriculture: roundLegacyIntegerColumn(city.agriculture * 0.9), commerce: roundLegacyIntegerColumn(city.commerce * 0.9), security: roundLegacyIntegerColumn(city.security * 0.9), defence: roundLegacyIntegerColumn(city.defence * 0.9), wall: roundLegacyIntegerColumn(city.wall * 0.9), meta: { ...city.meta, // REF-COMPAT:BEGIN ref-mariadb-float-boundary // 레거시 FLOAT column에 SQL 곱셈 결과가 먼저 저장된 뒤 // 민심 30 threshold를 판정하므로 float32 양자화를 보존한다. trust: Math.fround(trust * 0.9), // REF-COMPAT:END ref-mariadb-float-boundary }, }); if (damaged) { unsuppliedCities.push(damaged); } } const generals = world.listGenerals().sort((left, right) => left.id - right.id); const unsuppliedNationByCityId = new Map(unsuppliedCities.map((city) => [city.id, city.nationId])); for (const general of generals) { if (unsuppliedNationByCityId.get(general.cityId) !== general.nationId) { continue; } world.updateGeneral(general.id, { crew: roundLegacyIntegerColumn(general.crew * 0.95), atmos: roundLegacyIntegerColumn(general.atmos * 0.95), train: roundLegacyIntegerColumn(general.train * 0.95), }); } const lostCities = unsuppliedCities.filter((city) => { const trust = city.meta.trust; return typeof trust === 'number' && trust < 30; }); if (lostCities.length === 0) { return; } const lostCityIds = new Set(lostCities.map((city) => city.id)); for (const lostCity of lostCities) { const josaYi = JosaUtil.pick(lostCity.name, '이'); world.pushLog({ scope: LogScope.SYSTEM, category: LogCategory.HISTORY, text: `【고립】${lostCity.name}${josaYi} 보급이 끊겨 미지배 도시가 되었습니다.`, format: LogFormat.YEAR_MONTH, year: environment.year, month: environment.month, }); } for (const general of generals) { if (!lostCityIds.has(resolveOfficerCity(general.meta))) { continue; } const current = world.getGeneralById(general.id); if (!current) { continue; } world.updateGeneral(general.id, { officerLevel: 1, meta: { ...current.meta, officerCity: 0, officer_city: 0, }, }); } for (const lostCity of lostCities) { world.updateCity(lostCity.id, { nationId: 0, frontState: 0, conflict: {}, meta: { ...lostCity.meta, officer_set: 0, term: 0, }, }); } }; };