feat: implement monthly city supply update

This commit is contained in:
2026-07-25 16:58:19 +00:00
parent 036e72b929
commit 3142f8d917
7 changed files with 801 additions and 3 deletions
@@ -0,0 +1,159 @@
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';
const roundLegacyIntegerColumn = (value: number): number => Math.round(value);
const resolveOfficerCity = (meta: Record<string, unknown>): 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,
// 레거시 FLOAT column에 SQL 곱셈 결과가 먼저 저장된 뒤
// 민심 30 threshold를 판정하므로 float32 양자화를 보존한다.
trust: Math.fround(trust * 0.9),
},
});
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: `<R><b>【고립】</b></><G><b>${lostCity.name}</b></>${josaYi} 보급이 끊겨 <R>미지배</> 도시가 되었습니다.`,
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,
},
});
}
};
};
+8
View File
@@ -46,6 +46,7 @@ import {
type MonthlyEventActionHandler,
} from './monthlyEventHandler.js';
import { createRaiseDisasterHandler } from './monthlyDisasterAction.js';
import { createUpdateCitySupplyHandler } from './monthlyCitySupplyAction.js';
import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
export interface TurnDaemonRuntimeOptions {
@@ -167,6 +168,13 @@ const createTurnDaemonRuntimeWithLease = async (
generalActionModules: monthlyActionModules.general,
})
);
eventActions.set(
'UpdateCitySupply',
createUpdateCitySupplyHandler({
getWorld: () => worldRef,
map: snapshot.map,
})
);
eventActions.set('ProcessIncome', (_args, environment) => {
void incomeHandler.onMonthChanged?.({
previousYear: environment.month === 1 ? environment.year - 1 : environment.year,