Merge branch 'main' into feature/nation-seizure-npc-message-parity

This commit is contained in:
2026-07-26 06:06:12 +00:00
39 changed files with 706 additions and 329 deletions
@@ -711,7 +711,7 @@ export class GeneralAI {
const leadership = this.general.stats.leadership;
const strength = Math.max(this.general.stats.strength, 1);
const intel = Math.max(this.general.stats.intelligence, 1);
let genType = 0;
let genType: number;
if (strength >= intel) {
genType = t무장;
@@ -38,7 +38,7 @@ export const do부대전방발령 = (ai: GeneralAI) => {
const force = ai.nationPolicy.combatForce[leader.id];
let [fromCityId, toCityId] = force;
let targetCityId: number | null = null;
let targetCityId: number | null;
if (!ai.warRoute || !ai.warRoute[fromCityId] || ai.warRoute[fromCityId][toCityId] === undefined) {
targetCityId = pickRandomCityId(ai, ai.frontCities);
} else {
+4 -79
View File
@@ -22,7 +22,7 @@ import {
type LogEntryDraft,
type MessageRecordDraft,
} from '@sammo-ts/logic';
import { asRecord, type RankDataType } from '@sammo-ts/common';
import { asRecord } from '@sammo-ts/common';
import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
@@ -33,6 +33,7 @@ import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence
import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js';
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
import { buildPersistedRankRows } from './rankData.js';
export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks;
@@ -270,20 +271,6 @@ const toLegacyDatabaseInt = (value: number): number => {
return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5);
};
const readRankMetaNumber = (meta: Record<string, unknown>, key: string): number => {
const value = meta[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return toLegacyDatabaseInt(value);
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return toLegacyDatabaseInt(parsed);
}
}
return 0;
};
const LEGACY_INTEGER_GENERAL_META_KEYS = [
'leadership_exp',
'strength_exp',
@@ -312,72 +299,10 @@ const buildPersistedGeneralMeta = (
return asJson(meta);
};
const buildRankRows = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
): Array<{ generalId: number; nationId: number; type: string; value: number }> => {
const meta = asRecord(general.meta);
const readMeta = (key: string) => readRankMetaNumber(meta, key);
const readRank = (key: string) => readRankMetaNumber(meta, `rank_${key}`);
const entries: Array<[RankDataType, number]> = [
['experience', toLegacyDatabaseInt(general.experience)],
['dedication', toLegacyDatabaseInt(general.dedication)],
['firenum', readMeta('firenum')],
['warnum', readRank('warnum')],
['killnum', readRank('killnum')],
['deathnum', readRank('deathnum')],
['occupied', readRank('occupied')],
['killcrew', readRank('killcrew')],
['deathcrew', readRank('deathcrew')],
['killcrew_person', readRank('killcrew_person')],
['deathcrew_person', readRank('deathcrew_person')],
['dex1', readMeta('dex1')],
['dex2', readMeta('dex2')],
['dex3', readMeta('dex3')],
['dex4', readMeta('dex4')],
['dex5', readMeta('dex5')],
['ttw', readMeta('ttw')],
['ttd', readMeta('ttd')],
['ttl', readMeta('ttl')],
['ttg', readMeta('ttg')],
['ttp', readMeta('ttp')],
['tlw', readMeta('tlw')],
['tld', readMeta('tld')],
['tll', readMeta('tll')],
['tlg', readMeta('tlg')],
['tlp', readMeta('tlp')],
['tsw', readMeta('tsw')],
['tsd', readMeta('tsd')],
['tsl', readMeta('tsl')],
['tsg', readMeta('tsg')],
['tsp', readMeta('tsp')],
['tiw', readMeta('tiw')],
['tid', readMeta('tid')],
['til', readMeta('til')],
['tig', readMeta('tig')],
['tip', readMeta('tip')],
['betgold', readMeta('betgold')],
['betwin', readMeta('betwin')],
['betwingold', readMeta('betwingold')],
['inherit_earned', readMeta('inherit_earned')],
['inherit_spent', readMeta('inherit_spent')],
['inherit_earned_dyn', readMeta('inherit_earned_dyn')],
['inherit_earned_act', readMeta('inherit_earned_act')],
['inherit_spent_dyn', readMeta('inherit_spent_dyn')],
];
return entries.map(([type, value]) => ({
generalId: general.id,
nationId: general.nationId,
type,
value,
}));
};
const buildInitialRankRows = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
): Array<{ generalId: number; nationId: number; type: string; value: number }> =>
buildRankRows(general).map((row) => ({ ...row, nationId: 0, value: 0 }));
buildPersistedRankRows(general).map((row) => ({ ...row, nationId: 0, value: 0 }));
const buildGeneralUpdate = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
@@ -953,7 +878,7 @@ export const createDatabaseTurnHooks = async (
if (createdGenerals.length > 0 || rankTargets.length > 0) {
const rankRows = [
...createdGenerals.flatMap(buildInitialRankRows),
...rankTargets.flatMap(buildRankRows),
...rankTargets.flatMap(buildPersistedRankRows),
];
await Promise.all(
rankRows.map((row) =>
+6 -9
View File
@@ -108,7 +108,7 @@ const applyIncomeOutcome = (
originOutcome: number
): { next: number; ratio: number; realOutcome: number } => {
let next = current + income;
let realOutcome = 0;
let realOutcome: number;
if (next < baseResource) {
realOutcome = 0;
next = baseResource;
@@ -139,14 +139,11 @@ const processIncomeForNation = (
const trait = traitMap.get(nation.typeCode) ?? null;
const incomeContext = buildNationIncomeContext(nation, trait);
let income = 0;
if (type === 'gold') {
income = getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
} else {
income =
getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) +
getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
}
const income =
type === 'gold'
? getGoldIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level)
: getRiceIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level) +
getWallIncome(incomeContext, nationCities, officerCounts, nation.capitalCityId ?? 0, nation.level);
const incomeValue = roundResource(income);
const originOutcome = getOutcome(100, nationGenerals);
+87
View File
@@ -0,0 +1,87 @@
import {
LEGACY_RANK_DATA_TYPES,
RANK_DATA_TYPES,
rankDataMetaKey,
type LegacyRankDataType,
type RankDataType,
} from '@sammo-ts/common';
export interface RankedGeneralState {
id: number;
nationId: number;
experience: number;
dedication: number;
meta: unknown;
}
export interface PersistedRankRow {
generalId: number;
nationId: number;
type: RankDataType;
value: number;
}
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
? { ...(value as Record<string, unknown>) }
: {};
const toLegacyDatabaseInt = (value: number): number => {
if (!Number.isFinite(value)) {
return 0;
}
return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5);
};
const readMetaNumber = (meta: Record<string, unknown>, key: string): number => {
const value = meta[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return toLegacyDatabaseInt(value);
}
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? toLegacyDatabaseInt(parsed) : 0;
}
return 0;
};
export const rankMetaKey = rankDataMetaKey;
export const buildPersistedRankRows = (general: RankedGeneralState): PersistedRankRow[] => {
const meta = asRecord(general.meta);
return RANK_DATA_TYPES.map((type) => {
const value =
type === 'experience'
? toLegacyDatabaseInt(general.experience)
: type === 'dedication'
? toLegacyDatabaseInt(general.dedication)
: readMetaNumber(meta, rankMetaKey(type));
return {
generalId: general.id,
nationId: general.nationId,
type,
value,
};
});
};
export const buildLegacyComparableRankRows = (
general: RankedGeneralState
): Array<PersistedRankRow & { type: LegacyRankDataType }> => {
const legacyTypes = new Set<RankDataType>(LEGACY_RANK_DATA_TYPES);
return buildPersistedRankRows(general).filter(
(row): row is PersistedRankRow & { type: LegacyRankDataType } => legacyTypes.has(row.type)
);
};
export const applyPersistedRankRowsToMeta = (
rawMeta: Record<string, unknown>,
rows: ReadonlyArray<{ type: string; value: number }>
): void => {
const supportedTypes = new Set<string>(RANK_DATA_TYPES);
for (const row of rows) {
if (row.type === 'experience' || row.type === 'dedication' || !supportedTypes.has(row.type)) {
continue;
}
rawMeta[rankMetaKey(row.type as RankDataType)] = row.value;
}
};
@@ -37,7 +37,7 @@ import {
} from '@sammo-ts/logic';
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import { asRecord, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import type { ConstraintContext, StateView } from '@sammo-ts/logic';
@@ -57,6 +57,7 @@ import { buildFrontStatePatches } from './frontStateHandler.js';
import { buildActionContext } from './reservedTurnActionContext.js';
import { GeneralAI, shouldUseAi } from './ai/generalAi.js';
import type { AiReservedTurnProvider } from './ai/types.js';
import { rankMetaKey } from './rankData.js';
const DEFAULT_ACTION = '휴식';
@@ -227,44 +228,11 @@ const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({
const resetRetiredGeneral = (general: TurnGeneral): TurnGeneral => {
const meta = { ...general.meta };
for (const key of [
'firenum',
'rank_warnum',
'rank_killnum',
'rank_deathnum',
'rank_occupied',
'rank_killcrew',
'rank_deathcrew',
'rank_killcrew_person',
'rank_deathcrew_person',
'rank_ttw',
'rank_ttd',
'rank_ttl',
'rank_ttg',
'rank_ttp',
'rank_tlw',
'rank_tld',
'rank_tll',
'rank_tlg',
'rank_tlp',
'rank_tsw',
'rank_tsd',
'rank_tsl',
'rank_tsg',
'rank_tsp',
'rank_tiw',
'rank_tid',
'rank_til',
'rank_tig',
'rank_tip',
'rank_betgold',
'rank_betwin',
'rank_betwingold',
'specage',
'specage2',
]) {
meta[key] = 0;
for (const type of LEGACY_RANK_DATA_TYPES) {
meta[rankMetaKey(type)] = 0;
}
meta.specage = 0;
meta.specage2 = 0;
for (let dex = 1; dex <= 5; dex += 1) {
const key = `dex${dex}`;
meta[key] = Math.round(readMetaNumber(meta, key, 0) * 0.5);
+36 -39
View File
@@ -167,7 +167,8 @@ export const createUnificationHandler = (options: {
sabotage,
dex,
unifier,
unifierAward: general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0,
unifierAward:
general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0,
},
},
});
@@ -194,15 +195,11 @@ export const createUnificationHandler = (options: {
const meta = asRecord(state.meta);
const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim()
? meta.serverId.trim()
: options.profileName;
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName;
const season = readMetaNumberOrNull(meta, 'season') ?? 1;
const scenario = readMetaNumberOrNull(meta, 'scenarioId') ?? 0;
const scenarioName =
typeof asRecord(meta.scenarioMeta).title === 'string'
? String(asRecord(meta.scenarioMeta).title)
: '';
typeof asRecord(meta.scenarioMeta).title === 'string' ? String(asRecord(meta.scenarioMeta).title) : '';
const startTime = typeof meta.starttime === 'string' ? meta.starttime : null;
const unitedTime = new Date().toISOString();
@@ -307,14 +304,16 @@ export const createUnificationHandler = (options: {
};
for (const [typeName, valueType] of hallTypes) {
let value = 0;
if (valueType === 'natural') {
value = typeName === 'experience' ? general.experience : typeName === 'dedication' ? general.dedication : ranks[typeName] ?? 0;
} else if (valueType === 'rank') {
value = ranks[typeName] ?? 0;
} else {
value = calcValues[typeName] ?? 0;
}
const value =
valueType === 'natural'
? typeName === 'experience'
? general.experience
: typeName === 'dedication'
? general.dedication
: (ranks[typeName] ?? 0)
: valueType === 'rank'
? (ranks[typeName] ?? 0)
: (calcValues[typeName] ?? 0);
if ((typeName === 'winrate' || typeName === 'killrate') && warnum < 10) {
continue;
@@ -391,9 +390,7 @@ export const createUnificationHandler = (options: {
const meta = asRecord(state.meta);
const serverId =
typeof meta.serverId === 'string' && meta.serverId.trim()
? meta.serverId.trim()
: options.profileName;
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName;
const serverName =
typeof meta.serverName === 'string' && meta.serverName.trim()
? meta.serverName.trim()
@@ -653,30 +650,30 @@ export const createUnificationHandler = (options: {
await Promise.all(
oldGeneralTargets.map((general) =>
((snapshot) =>
prisma.oldGeneral.upsert({
where: {
by_no: {
prisma.oldGeneral.upsert({
where: {
by_no: {
serverId,
generalNo: general.id,
},
},
update: {
owner: general.userId ?? null,
name: general.name,
lastYearMonth: state.currentYear * 100 + state.currentMonth,
turnTime: general.turnTime,
data: snapshot,
},
create: {
serverId,
generalNo: general.id,
owner: general.userId ?? null,
name: general.name,
lastYearMonth: state.currentYear * 100 + state.currentMonth,
turnTime: general.turnTime,
data: snapshot,
},
},
update: {
owner: general.userId ?? null,
name: general.name,
lastYearMonth: state.currentYear * 100 + state.currentMonth,
turnTime: general.turnTime,
data: snapshot,
},
create: {
serverId,
generalNo: general.id,
owner: general.userId ?? null,
name: general.name,
lastYearMonth: state.currentYear * 100 + state.currentMonth,
turnTime: general.turnTime,
data: snapshot,
},
}))( {
}))({
...general,
turnTime: general.turnTime.toISOString(),
})
@@ -315,8 +315,8 @@ async function handleTournamentMatchResult(
const attackerG = getRankNumber(attacker, rankKey('g'));
const defenderG = getRankNumber(defender, rankKey('g'));
let attackerGDelta = 0;
let defenderGDelta = 0;
let attackerGDelta: number;
let defenderGDelta: number;
let attackerW = 0;
let attackerD = 0;
let attackerL = 0;
+2 -17
View File
@@ -32,6 +32,7 @@ import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js';
import { loadUnitSetDefinitionByName } from '../scenario/unitSetLoader.js';
import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnWorldLoadResult } from './types.js';
import { readDiplomacyMeta } from '@sammo-ts/logic';
import { applyPersistedRankRowsToMeta } from './rankData.js';
interface TurnWorldLoaderOptions {
databaseUrl: string;
@@ -157,17 +158,6 @@ const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => {
return parsed.data;
};
const GENERAL_RANK_META_PREFIX_TYPES = new Set([
'warnum',
'killnum',
'deathnum',
'occupied',
'killcrew',
'deathcrew',
'killcrew_person',
'deathcrew_person',
]);
const mapGeneralRow = (
row: TurnEngineGeneralRow,
rankRows: readonly TurnEngineRankDataRow[],
@@ -181,12 +171,7 @@ const mapGeneralRow = (
item: normalizeCode(row.itemCode),
};
const rawMeta = { ...(asTriggerRecord(row.meta) as Record<string, unknown>) };
for (const rank of rankRows) {
if (rank.type === 'experience' || rank.type === 'dedication') {
continue;
}
rawMeta[GENERAL_RANK_META_PREFIX_TYPES.has(rank.type) ? `rank_${rank.type}` : rank.type] = rank.value;
}
applyPersistedRankRowsToMeta(rawMeta, rankRows);
const inheritancePoints = Object.fromEntries(inheritanceRows.map((entry) => [entry.key, entry.value]));
const itemInventory = readItemInventoryFromMeta(rawMeta, legacySlots);
return {