merge: implement monthly disaster event

This commit is contained in:
2026-07-25 16:39:38 +00:00
7 changed files with 628 additions and 11 deletions
@@ -0,0 +1,199 @@
import { LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import {
GeneralActionPipeline,
LogCategory,
LogFormat,
LogScope,
createGeneralTriggerContext,
type GeneralActionModule,
} from '@sammo-ts/logic';
import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { MonthlyEventActionHandler } from './monthlyEventHandler.js';
type DisasterText = {
title: string;
stateCode: number;
body: string;
};
const DISASTER_TEXT_BY_MONTH: Readonly<Record<number, DisasterText[]>> = {
1: [
{ title: '<M><b>【재난】</b></>', stateCode: 4, body: '역병이 발생하여 도시가 황폐해지고 있습니다.' },
{ title: '<M><b>【재난】</b></>', stateCode: 5, body: '지진으로 피해가 속출하고 있습니다.' },
{ title: '<M><b>【재난】</b></>', stateCode: 3, body: '추위가 풀리지 않아 얼어죽는 백성들이 늘어나고 있습니다.' },
{ title: '<M><b>【재난】</b></>', stateCode: 9, body: '황건적이 출현해 도시를 습격하고 있습니다.' },
],
4: [
{ title: '<M><b>【재난】</b></>', stateCode: 7, body: '홍수로 인해 피해가 급증하고 있습니다.' },
{ title: '<M><b>【재난】</b></>', stateCode: 5, body: '지진으로 피해가 속출하고 있습니다.' },
{ title: '<M><b>【재난】</b></>', stateCode: 6, body: '태풍으로 인해 피해가 속출하고 있습니다.' },
],
7: [
{ title: '<M><b>【재난】</b></>', stateCode: 8, body: '메뚜기 떼가 발생하여 도시가 황폐해지고 있습니다.' },
{ title: '<M><b>【재난】</b></>', stateCode: 5, body: '지진으로 피해가 속출하고 있습니다.' },
{ title: '<M><b>【재난】</b></>', stateCode: 8, body: '흉년이 들어 굶어죽는 백성들이 늘어나고 있습니다.' },
],
10: [
{ title: '<M><b>【재난】</b></>', stateCode: 3, body: '혹한으로 도시가 황폐해지고 있습니다.' },
{ title: '<M><b>【재난】</b></>', stateCode: 5, body: '지진으로 피해가 속출하고 있습니다.' },
{ title: '<M><b>【재난】</b></>', stateCode: 3, body: '눈이 많이 쌓여 도시가 황폐해지고 있습니다.' },
{ title: '<M><b>【재난】</b></>', stateCode: 9, body: '황건적이 출현해 도시를 습격하고 있습니다.' },
],
};
const BOOMING_TEXT_BY_MONTH: Readonly<Partial<Record<number, DisasterText[]>>> = {
4: [{ title: '<C><b>【호황】</b></>', stateCode: 2, body: '호황으로 도시가 번창하고 있습니다.' }],
7: [{ title: '<C><b>【풍작】</b></>', stateCode: 1, body: '풍작으로 도시가 번창하고 있습니다.' }],
};
const BOOMING_RATE_BY_MONTH: Readonly<Record<number, number>> = {
1: 0,
4: 0.25,
7: 0.25,
10: 0,
};
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
const resolveHiddenSeed = (world: InMemoryTurnWorld): string | number => {
const state = world.getState();
const rawSeed = state.meta.hiddenSeed ?? state.meta.seed ?? state.id;
return typeof rawSeed === 'string' || typeof rawSeed === 'number' ? rawSeed : String(rawSeed);
};
const roundLegacyIntegerColumn = (value: number): number => Math.round(value);
export const createRaiseDisasterHandler = (options: {
getWorld: () => InMemoryTurnWorld | null;
generalActionModules?: GeneralActionModule[];
}): MonthlyEventActionHandler => {
const generalPipeline = new GeneralActionPipeline(options.generalActionModules ?? []);
return (_args, environment) => {
const world = options.getWorld();
if (!world) {
return;
}
// 레거시 InnoDB의 PK scan 순서를 명시적으로 고정해 RNG 소비 순서를
// PostgreSQL의 비결정적 findMany 반환 순서에 맡기지 않는다.
const cities = world.listCities().sort((left, right) => left.id - right.id);
// 레거시는 3년 유예 판정보다 먼저 이전 재난 표시를 초기화한다.
for (const city of cities) {
if (city.state <= 10) {
world.updateCity(city.id, { state: 0 });
}
}
if (environment.startyear + 3 > environment.year) {
return;
}
const boomingRate = BOOMING_RATE_BY_MONTH[environment.month];
if (boomingRate === undefined) {
throw new Error(`Unsupported month for RaiseDisaster: ${environment.month}`);
}
const rng = new RandUtil(
new LiteHashDRBG(
simpleSerialize(resolveHiddenSeed(world), 'disater', environment.year, environment.month)
)
);
const isGood = rng.nextBool(boomingRate);
const targetCities = cities.filter((city) => {
if (city.securityMax <= 0) {
throw new Error(`RaiseDisaster requires positive securityMax (cityId=${city.id})`);
}
const securityRatio = city.security / city.securityMax;
const probability = isGood ? 0.02 + securityRatio * 0.05 : 0.06 - securityRatio * 0.05;
return rng.nextBool(probability);
});
if (targetCities.length === 0) {
return;
}
const textCandidates = isGood
? BOOMING_TEXT_BY_MONTH[environment.month]
: DISASTER_TEXT_BY_MONTH[environment.month];
if (!textCandidates || textCandidates.length === 0) {
throw new Error(`RaiseDisaster has no text candidates for month ${environment.month}`);
}
const picked = rng.choice(textCandidates);
const cityNames = targetCities.map((city) => city.name).join(' ');
world.pushLog({
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
text: `${picked.title}<G><b>${cityNames}</b></>에 ${picked.body}`,
format: LogFormat.YEAR_MONTH,
});
const allGenerals = world.listGenerals().sort((left, right) => left.id - right.id);
for (const city of targetCities) {
const securityRatio = clamp(city.security / city.securityMax / 0.8, 0, 1);
const affectRatio = isGood ? 1.01 + securityRatio * 0.04 : 0.8 + securityRatio * 0.15;
const trust = typeof city.meta.trust === 'number' ? city.meta.trust : 0;
world.updateCity(city.id, {
state: picked.stateCode,
population: roundLegacyIntegerColumn(
isGood
? Math.min(city.population * affectRatio, city.populationMax)
: city.population * affectRatio
),
agriculture: roundLegacyIntegerColumn(
isGood
? Math.min(city.agriculture * affectRatio, city.agricultureMax)
: city.agriculture * affectRatio
),
commerce: roundLegacyIntegerColumn(
isGood ? Math.min(city.commerce * affectRatio, city.commerceMax) : city.commerce * affectRatio
),
security: roundLegacyIntegerColumn(
isGood ? Math.min(city.security * affectRatio, city.securityMax) : city.security * affectRatio
),
defence: roundLegacyIntegerColumn(
isGood ? Math.min(city.defence * affectRatio, city.defenceMax) : city.defence * affectRatio
),
wall: roundLegacyIntegerColumn(
isGood ? Math.min(city.wall * affectRatio, city.wallMax) : city.wall * affectRatio
),
meta: {
...city.meta,
trust: isGood ? Math.min(trust * affectRatio, 100) : trust * affectRatio,
},
});
if (isGood) {
continue;
}
for (const general of allGenerals.filter((candidate) => candidate.cityId === city.id)) {
const context = createGeneralTriggerContext({
general,
nation: world.getNationById(general.nationId),
worldView: {
listGenerals: () => allGenerals,
listGeneralsByCity: (cityId) =>
allGenerals.filter((candidate) => candidate.cityId === cityId),
},
rng,
});
const injuryProbability = generalPipeline.onCalcStat(context, 'injuryProb', 0.3);
if (!rng.nextBool(injuryProbability)) {
continue;
}
world.pushLog({
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: general.id,
text: '<M>재난</>으로 인해 <R>부상</>을 당했습니다.',
format: LogFormat.MONTH,
});
world.updateGeneral(general.id, {
injury: clamp(general.injury + rng.nextRangeInt(1, 16), 0, 80),
crew: roundLegacyIntegerColumn(general.crew * 0.98),
atmos: roundLegacyIntegerColumn(general.atmos * 0.98),
train: roundLegacyIntegerColumn(general.train * 0.98),
});
}
}
};
};
+16 -1
View File
@@ -1,4 +1,10 @@
import { LogCategory, LogScope, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic';
import {
LogCategory,
LogScope,
loadActionModuleBundle,
type TurnCommandProfile,
type TurnSchedule,
} from '@sammo-ts/logic';
import { buildGameEventChannel, type RealtimeEvent } from '@sammo-ts/common';
import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic';
@@ -39,6 +45,7 @@ import {
createRandomizeCityTradeRateHandler,
type MonthlyEventActionHandler,
} from './monthlyEventHandler.js';
import { createRaiseDisasterHandler } from './monthlyDisasterAction.js';
import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
export interface TurnDaemonRuntimeOptions {
@@ -127,6 +134,7 @@ const createTurnDaemonRuntimeWithLease = async (
let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
const monthlyActionModules = await loadActionModuleBundle(snapshot.unitSet);
const unification = options.calendarHandler
? null
: createUnificationHandler({
@@ -152,6 +160,13 @@ const createTurnDaemonRuntimeWithLease = async (
getWorld: () => worldRef,
})
);
eventActions.set(
'RaiseDisaster',
createRaiseDisasterHandler({
getWorld: () => worldRef,
generalActionModules: monthlyActionModules.general,
})
);
eventActions.set('ProcessIncome', (_args, environment) => {
void incomeHandler.onMonthChanged?.({
previousYear: environment.month === 1 ? environment.year - 1 : environment.year,
@@ -0,0 +1,167 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { City, MapDefinition } from '@sammo-ts/logic';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { createRaiseDisasterHandler } from '../src/turn/monthlyDisasterAction.js';
import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const cityId = 990_042;
const map: MapDefinition = {
id: 'monthly-disaster-persistence',
name: 'monthly-disaster-persistence',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
};
const city: City = {
id: cityId,
name: '재난저장검증도시',
nationId: 0,
level: 1,
state: 0,
population: 1_000,
populationMax: 2_000,
agriculture: 500,
agricultureMax: 1_000,
commerce: 500,
commerceMax: 1_000,
security: 0,
securityMax: 100,
supplyState: 1,
frontState: 0,
defence: 100,
defenceMax: 1_000,
wall: 100,
wallMax: 1_000,
conflict: {},
meta: { trust: 99.5, trade: 100, region: 1 },
};
integration('monthly disaster database persistence', () => {
let db: GamePrismaClient;
let closeDb: (() => Promise<void>) | undefined;
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.city.deleteMany({ where: { id: cityId } });
});
afterAll(async () => {
await db.city.deleteMany({ where: { id: cityId } });
await closeDb?.();
});
it('preserves fractional trust while writing integer damage and the disaster state', async () => {
await db.city.create({
data: {
id: city.id,
name: city.name,
level: city.level,
nationId: city.nationId,
supplyState: city.supplyState,
frontState: city.frontState,
population: city.population,
populationMax: city.populationMax,
agriculture: city.agriculture,
agricultureMax: city.agricultureMax,
commerce: city.commerce,
commerceMax: city.commerceMax,
security: city.security,
securityMax: city.securityMax,
trust: 99.5,
trade: 100,
defence: city.defence,
defenceMax: city.defenceMax,
wall: city.wall,
wallMax: city.wallMax,
region: 1,
conflict: {},
meta: {},
},
});
const row = await db.worldState.create({
data: {
scenarioCode: 'monthly-disaster-persistence',
currentYear: 193,
currentMonth: 1,
tickSeconds: 600,
config: {},
meta: {},
},
});
const state: TurnWorldState = {
id: row.id,
currentYear: 193,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('2026-07-25T00:10:00.000Z'),
meta: { hiddenSeed: 'disaster-test-6' },
};
const snapshot: TurnWorldSnapshot = {
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: map.id, unitSet: 'default' },
},
map,
generals: [],
cities: [city],
nations: [],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
};
const world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
});
const dbHooks = await createDatabaseTurnHooks(databaseUrl!, world);
const checkpoint = {
lastTurnTime: state.lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 1,
durationMs: 0,
partial: false,
};
try {
const raiseDisaster = createRaiseDisasterHandler({ getWorld: () => world });
raiseDisaster(
[],
{
year: 193,
month: 1,
startyear: 190,
currentEventID: 1,
turnTime: state.lastTurnTime,
},
{ id: 1, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} }
);
await dbHooks.hooks.flushChanges?.(checkpoint);
const persisted = await db.city.findUniqueOrThrow({ where: { id: cityId } });
expect(persisted).toMatchObject({
population: 800,
agriculture: 400,
commerce: 400,
defence: 80,
wall: 80,
meta: { state: 3 },
});
expect(persisted.trust).toBeCloseTo(79.6);
} finally {
await dbHooks.close();
await db.worldState.delete({ where: { id: row.id } });
}
});
});
@@ -1,13 +1,14 @@
import { describe, expect, it } from 'vitest';
import type { City, MapDefinition } from '@sammo-ts/logic';
import { loadActionModuleBundle, LogCategory, LogFormat, LogScope, type City, type MapDefinition } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import { createRaiseDisasterHandler } from '../src/turn/monthlyDisasterAction.js';
import {
createMonthlyEventHandler,
createRandomizeCityTradeRateHandler,
type MonthlyEventActionHandler,
} from '../src/turn/monthlyEventHandler.js';
import type { TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
const map: MapDefinition = {
id: 'event-test',
@@ -19,15 +20,25 @@ const map: MapDefinition = {
const buildWorld = (
events: TurnWorldSnapshot['events'],
actions: Map<string, MonthlyEventActionHandler>,
cities: City[] = []
cities: City[] = [],
options: {
generals?: TurnGeneral[];
hiddenSeed?: string;
startYear?: number;
currentYear?: number;
currentMonth?: number;
} = {}
): InMemoryTurnWorld => {
const currentYear = options.currentYear ?? 189;
const currentMonth = options.currentMonth ?? 12;
const startYear = options.startYear ?? 189;
const state: TurnWorldState = {
id: 1,
currentYear: 189,
currentMonth: 12,
currentYear,
currentMonth,
tickSeconds: 600,
lastTurnTime: new Date('0189-12-01T00:00:00.000Z'),
meta: { hiddenSeed: 'monthly-event-test-seed' },
meta: { hiddenSeed: options.hiddenSeed ?? 'monthly-event-test-seed' },
};
const snapshot: TurnWorldSnapshot = {
scenarioConfig: {
@@ -39,7 +50,7 @@ const buildWorld = (
},
scenarioMeta: {
title: 'event test',
startYear: 189,
startYear,
life: null,
fiction: null,
history: [],
@@ -49,7 +60,7 @@ const buildWorld = (
diplomacy: [],
events,
initialEvents: [],
generals: [],
generals: options.generals ?? [],
cities,
nations: [],
troops: [],
@@ -57,7 +68,7 @@ const buildWorld = (
let world: InMemoryTurnWorld | null = null;
const handler = createMonthlyEventHandler({
getWorld: () => world,
startYear: 189,
startYear,
actions,
});
world = new InMemoryTurnWorld(state, snapshot, {
@@ -91,6 +102,37 @@ const buildCity = (id: number, level: number): City => ({
meta: { trust: 50, trade: 100, marker: id },
});
const buildGeneral = (id: number, patch: Partial<TurnGeneral> = {}): TurnGeneral => ({
id,
name: `장수${id}`,
nationId: 0,
cityId: 1,
troopId: 0,
stats: { leadership: 50, strength: 50, intelligence: 50 },
experience: 0,
dedication: 0,
officerLevel: 0,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 99,
crewTypeId: 1100,
train: 51,
atmos: 50,
age: 30,
npcState: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24 },
turnTime: new Date('0193-01-01T00:00:00.000Z'),
...patch,
});
describe('monthly event pipeline', () => {
it('runs PRE_MONTH before the date change and MONTH after it in priority/id order', async () => {
const trace: string[] = [];
@@ -244,4 +286,194 @@ describe('monthly event pipeline', () => {
'Unsupported city level for RandomizeCityTradeRate: 9 (cityId=99)'
);
});
it('resets an old disaster marker before the opening three-year skip', async () => {
const actions = new Map<string, MonthlyEventActionHandler>();
const world = buildWorld(
[
{
id: 12,
targetCode: 'month',
priority: 0,
condition: true,
action: [['RaiseDisaster']],
meta: {},
},
],
actions,
[{ ...buildCity(1, 1), state: 5 }, { ...buildCity(2, 1), state: 31 }],
{ startYear: 190, currentYear: 190, currentMonth: 12 }
);
actions.set('RaiseDisaster', createRaiseDisasterHandler({ getWorld: () => world }));
await world.advanceMonth(new Date('0191-01-01T00:00:00.000Z'));
expect(world.getCityById(1)?.state).toBe(0);
expect(world.getCityById(2)?.state).toBe(31);
expect(world.peekDirtyState().logs).toEqual([]);
});
it('matches the legacy January disaster RNG, city damage, injury order, and talisman protection', async () => {
const actions = new Map<string, MonthlyEventActionHandler>();
const moduleBundle = await loadActionModuleBundle();
const protectedGeneral = buildGeneral(1, {
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: 'che_부적_태현청생부' },
},
});
const injuredGeneral = buildGeneral(2);
const cappedGeneral = buildGeneral(3, { injury: 70 });
const disasterCity: City = {
...buildCity(1, 1),
name: '재난도시',
state: 5,
population: 1_001,
populationMax: 2_000,
agriculture: 501,
agricultureMax: 1_000,
commerce: 499,
commerceMax: 1_000,
security: 0,
securityMax: 100,
defence: 99,
defenceMax: 1_000,
wall: 101,
wallMax: 1_000,
meta: { trust: 99.5, trade: 100, marker: 1 },
};
const world = buildWorld(
[
{
id: 13,
targetCode: 'month',
priority: 0,
condition: true,
action: [['RaiseDisaster']],
meta: {},
},
],
actions,
[disasterCity],
{
generals: [cappedGeneral, protectedGeneral, injuredGeneral],
hiddenSeed: 'disaster-test-6',
startYear: 190,
currentYear: 192,
currentMonth: 12,
}
);
actions.set(
'RaiseDisaster',
createRaiseDisasterHandler({
getWorld: () => world,
generalActionModules: moduleBundle.general,
})
);
await world.advanceMonth(new Date('0193-01-01T00:00:00.000Z'));
const damagedCity = world.getCityById(1);
expect(damagedCity).toMatchObject({
state: 3,
population: 801,
agriculture: 401,
commerce: 399,
security: 0,
defence: 79,
wall: 81,
meta: { trade: 100, marker: 1 },
});
expect(damagedCity?.meta.trust).toBeCloseTo(79.6);
expect(world.getGeneralById(1)).toMatchObject({ injury: 0, crew: 99, atmos: 50, train: 51 });
expect(world.getGeneralById(2)).toMatchObject({ injury: 7, crew: 97, atmos: 49, train: 50 });
expect(world.getGeneralById(3)).toMatchObject({ injury: 80, crew: 97, atmos: 49, train: 50 });
expect(world.peekDirtyState().logs).toEqual([
{
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
text: '<M><b>【재난】</b></><G><b>재난도시</b></>에 추위가 풀리지 않아 얼어죽는 백성들이 늘어나고 있습니다.',
format: LogFormat.YEAR_MONTH,
},
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: 2,
text: '<M>재난</>으로 인해 <R>부상</>을 당했습니다.',
format: LogFormat.MONTH,
},
{
scope: LogScope.GENERAL,
category: LogCategory.ACTION,
generalId: 3,
text: '<M>재난</>으로 인해 <R>부상</>을 당했습니다.',
format: LogFormat.MONTH,
},
]);
});
it('matches the legacy April booming RNG and maximum clamps', async () => {
const actions = new Map<string, MonthlyEventActionHandler>();
const boomingCity: City = {
...buildCity(1, 8),
name: '호황도시',
population: 1_001,
populationMax: 1_100,
agriculture: 999,
agricultureMax: 1_000,
commerce: 500,
commerceMax: 1_000,
security: 1_000,
securityMax: 1_000,
defence: 101,
defenceMax: 1_000,
wall: 99,
wallMax: 1_000,
meta: { trust: 99.5, trade: 100 },
};
const world = buildWorld(
[
{
id: 14,
targetCode: 'month',
priority: 0,
condition: true,
action: [['RaiseDisaster']],
meta: {},
},
],
actions,
[boomingCity],
{
hiddenSeed: 'booming-test-59',
startYear: 190,
currentYear: 193,
currentMonth: 3,
}
);
actions.set('RaiseDisaster', createRaiseDisasterHandler({ getWorld: () => world }));
await world.advanceMonth(new Date('0193-04-01T00:00:00.000Z'));
expect(world.getCityById(1)).toMatchObject({
state: 2,
population: 1_051,
agriculture: 1_000,
commerce: 525,
security: 1_000,
defence: 106,
wall: 104,
meta: { trust: 100, trade: 100 },
});
expect(world.peekDirtyState().logs).toEqual([
{
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
text: '<C><b>【호황】</b></><G><b>호황도시</b></>에 호황으로 도시가 번창하고 있습니다.',
format: LogFormat.YEAR_MONTH,
},
]);
});
});
+1 -1
View File
@@ -131,7 +131,7 @@ model City {
commerceMax Int @map("comm_max")
security Int @map("secu")
securityMax Int @map("secu_max")
trust Int @default(0)
trust Float @default(0) @db.Real
trade Int? @default(100)
defence Int @map("def")
defenceMax Int @map("def_max")
@@ -0,0 +1,3 @@
ALTER TABLE "city"
ALTER COLUMN "trust" TYPE REAL
USING "trust"::REAL;
+1
View File
@@ -36,6 +36,7 @@ export type GeneralStatName =
| 'dedication'
| 'sabotageDefence'
| 'sabotageAttack'
| 'injuryProb'
| 'addDex';
export type WarStatName =