refactor: improve war module type safety and reserved turns test data handling

This commit is contained in:
2026-01-05 17:34:36 +00:00
parent 2e45a2d194
commit 44df19b079
10 changed files with 137 additions and 111 deletions
+22 -20
View File
@@ -17,14 +17,10 @@ const buildDb = () => {
type GeneralTurnFindManyArgs = Parameters<DatabaseClient['generalTurn']['findMany']>[0]; type GeneralTurnFindManyArgs = Parameters<DatabaseClient['generalTurn']['findMany']>[0];
type GeneralTurnDeleteManyArgs = NonNullable<Parameters<DatabaseClient['generalTurn']['deleteMany']>[0]>; type GeneralTurnDeleteManyArgs = NonNullable<Parameters<DatabaseClient['generalTurn']['deleteMany']>[0]>;
type GeneralTurnCreateManyArgs = NonNullable<Parameters<DatabaseClient['generalTurn']['createMany']>[0]>; type GeneralTurnCreateManyArgs = NonNullable<Parameters<DatabaseClient['generalTurn']['createMany']>[0]>;
type GeneralTurnCreateManyData = GeneralTurnCreateManyArgs['data'];
type GeneralTurnCreateManyRow = GeneralTurnCreateManyData extends Array<infer Row> ? Row : never;
type NationTurnFindManyArgs = Parameters<DatabaseClient['nationTurn']['findMany']>[0]; type NationTurnFindManyArgs = Parameters<DatabaseClient['nationTurn']['findMany']>[0];
type NationTurnDeleteManyArgs = NonNullable<Parameters<DatabaseClient['nationTurn']['deleteMany']>[0]>; type NationTurnDeleteManyArgs = NonNullable<Parameters<DatabaseClient['nationTurn']['deleteMany']>[0]>;
type NationTurnCreateManyArgs = NonNullable<Parameters<DatabaseClient['nationTurn']['createMany']>[0]>; type NationTurnCreateManyArgs = NonNullable<Parameters<DatabaseClient['nationTurn']['createMany']>[0]>;
type NationTurnCreateManyData = NationTurnCreateManyArgs['data'];
type NationTurnCreateManyRow = NationTurnCreateManyData extends Array<infer Row> ? Row : never;
const db = { const db = {
worldState: { worldState: {
@@ -45,20 +41,23 @@ const buildDb = () => {
return generalId !== undefined ? (generalTurns.get(generalId) ?? []) : []; return generalId !== undefined ? (generalTurns.get(generalId) ?? []) : [];
}, },
deleteMany: async ({ where }: GeneralTurnDeleteManyArgs) => { deleteMany: async ({ where }: GeneralTurnDeleteManyArgs) => {
if (typeof where.generalId === 'number') { if (where && typeof where.generalId === 'number') {
generalTurns.delete(where.generalId); generalTurns.delete(where.generalId);
} }
return {}; return {};
}, },
createMany: async ({ data }: GeneralTurnCreateManyArgs) => { createMany: async ({ data }: GeneralTurnCreateManyArgs) => {
const rows = data.map((row: GeneralTurnCreateManyRow, index: number) => ({ const dataList = (Array.isArray(data) ? data : [data]) as Record<string, unknown>[];
const rows: GeneralTurnRow[] = dataList.map((row, index: number) => ({
id: index + 1, id: index + 1,
generalId: row.generalId, generalId: row.generalId as number,
turnIdx: row.turnIdx, turnIdx: row.turnIdx as number,
actionCode: row.actionCode, actionCode: row.actionCode as string,
arg: row.arg, arg: row.arg as unknown as GeneralTurnRow['arg'],
createdAt: new Date(),
})); }));
const generalId = data[0]?.generalId; const firstRow = dataList[0];
const generalId = firstRow?.generalId as number | undefined;
if (generalId !== undefined) { if (generalId !== undefined) {
generalTurns.set(generalId, rows); generalTurns.set(generalId, rows);
} }
@@ -76,22 +75,25 @@ const buildDb = () => {
return nationTurns.get(`${nationId}:${officerLevel}`) ?? []; return nationTurns.get(`${nationId}:${officerLevel}`) ?? [];
}, },
deleteMany: async ({ where }: NationTurnDeleteManyArgs) => { deleteMany: async ({ where }: NationTurnDeleteManyArgs) => {
if (typeof where.nationId === 'number' && typeof where.officerLevel === 'number') { if (where && typeof where.nationId === 'number' && typeof where.officerLevel === 'number') {
nationTurns.delete(`${where.nationId}:${where.officerLevel}`); nationTurns.delete(`${where.nationId}:${where.officerLevel}`);
} }
return {}; return {};
}, },
createMany: async ({ data }: NationTurnCreateManyArgs) => { createMany: async ({ data }: NationTurnCreateManyArgs) => {
const rows = data.map((row: NationTurnCreateManyRow, index: number) => ({ const dataList = (Array.isArray(data) ? data : [data]) as Record<string, unknown>[];
const rows: NationTurnRow[] = dataList.map((row, index: number) => ({
id: index + 1, id: index + 1,
nationId: row.nationId, nationId: row.nationId as number,
officerLevel: row.officerLevel, officerLevel: row.officerLevel as number,
turnIdx: row.turnIdx, turnIdx: row.turnIdx as number,
actionCode: row.actionCode, actionCode: row.actionCode as string,
arg: row.arg, arg: row.arg as unknown as NationTurnRow['arg'],
createdAt: new Date(),
})); }));
const nationId = data[0]?.nationId; const firstRow = dataList[0];
const officerLevel = data[0]?.officerLevel; const nationId = firstRow?.nationId as number | undefined;
const officerLevel = firstRow?.officerLevel as number | undefined;
if (nationId !== undefined && officerLevel !== undefined) { if (nationId !== undefined && officerLevel !== undefined) {
nationTurns.set(`${nationId}:${officerLevel}`, rows); nationTurns.set(`${nationId}:${officerLevel}`, rows);
} }
+15 -14
View File
@@ -60,6 +60,7 @@ const handleLogout = async () => {
<div class="max-w-5xl mx-auto py-8 px-4 space-y-8"> <div class="max-w-5xl mx-auto py-8 px-4 space-y-8">
<!-- Notice --> <!-- Notice -->
<div v-if="notice" class="text-center"> <div v-if="notice" class="text-center">
<!-- eslint-disable-next-line vue/no-v-html -->
<span class="text-orange-500 text-3xl font-bold" v-html="notice"></span> <span class="text-orange-500 text-3xl font-bold" v-html="notice"></span>
</div> </div>
@@ -91,15 +92,15 @@ const handleLogout = async () => {
:style="{ color: profile.color }" :style="{ color: profile.color }"
class="text-lg font-bold cursor-help" class="text-lg font-bold cursor-help"
:title=" :title="
profileDetails[profile.profileName] profileDetails[profile.profileName]?.starttime
? `시작일: ${profileDetails[profile.profileName].starttime}` ? `시작일: ${profileDetails[profile.profileName]?.starttime}`
: '' : ''
" "
> >
{{ profile.korName }} {{ profile.korName }}
</div> </div>
<div v-if="profileDetails[profile.profileName]" class="text-xs text-zinc-500 mt-1"> <div v-if="profileDetails[profile.profileName]" class="text-xs text-zinc-500 mt-1">
&lt;{{ profileDetails[profile.profileName].nationCnt }} 경쟁중&gt; &lt;{{ profileDetails[profile.profileName]?.nationCnt }} 경쟁중&gt;
</div> </div>
</td> </td>
@@ -108,25 +109,25 @@ const handleLogout = async () => {
<template v-if="profileDetails[profile.profileName]"> <template v-if="profileDetails[profile.profileName]">
<div class="space-y-1"> <div class="space-y-1">
<div> <div>
서기 {{ profileDetails[profile.profileName].year }} 서기 {{ profileDetails[profile.profileName]?.year }}
{{ profileDetails[profile.profileName].month }} (<span {{ profileDetails[profile.profileName]?.month }} (<span
class="text-orange-400" class="text-orange-400"
>{{ profile.scenario }}</span >{{ profile.scenario }}</span
>) >)
</div> </div>
<div class="text-zinc-400"> <div class="text-zinc-400">
유저 : {{ profileDetails[profile.profileName].userCnt }} / 유저 : {{ profileDetails[profile.profileName]?.userCnt }} /
{{ profileDetails[profile.profileName].maxUserCnt }} {{ profileDetails[profile.profileName]?.maxUserCnt }}
<span class="text-cyan-400 ml-2" <span class="text-cyan-400 ml-2"
>NPC : {{ profileDetails[profile.profileName].npcCnt }}</span >NPC : {{ profileDetails[profile.profileName]?.npcCnt }}</span
> >
<span class="text-green-400 ml-2" <span class="text-green-400 ml-2"
>({{ profileDetails[profile.profileName].turnTerm }} 서버)</span >({{ profileDetails[profile.profileName]?.turnTerm }} 서버)</span
> >
</div> </div>
<div class="text-xs text-zinc-500"> <div class="text-xs text-zinc-500">
(상성 설정:{{ profileDetails[profile.profileName].fictionMode }}), (기타 (상성 설정:{{ profileDetails[profile.profileName]?.fictionMode }}), (기타
설정:{{ profileDetails[profile.profileName].otherTextInfo }}) 설정:{{ profileDetails[profile.profileName]?.otherTextInfo }})
</div> </div>
</div> </div>
</template> </template>
@@ -145,14 +146,14 @@ const handleLogout = async () => {
class="w-12 h-12 mx-auto bg-zinc-800 rounded overflow-hidden border border-zinc-700" class="w-12 h-12 mx-auto bg-zinc-800 rounded overflow-hidden border border-zinc-700"
> >
<img <img
:src="profileDetails[profile.profileName].myGeneral.picture" :src="profileDetails[profile.profileName]?.myGeneral?.picture ?? undefined"
class="w-full h-full object-cover" class="w-full h-full object-cover"
/> />
</div> </div>
</td> </td>
<td class="px-4 py-4 border-r border-zinc-800 text-center"> <td class="px-4 py-4 border-r border-zinc-800 text-center">
<div v-if="profileDetails[profile.profileName]?.myGeneral" class="font-medium"> <div v-if="profileDetails[profile.profileName]?.myGeneral" class="font-medium">
{{ profileDetails[profile.profileName].myGeneral.name }} {{ profileDetails[profile.profileName]?.myGeneral?.name }}
</div> </div>
<div v-else class="text-zinc-600">- -</div> <div v-else class="text-zinc-600">- -</div>
</td> </td>
@@ -161,7 +162,7 @@ const handleLogout = async () => {
<td class="px-4 py-4 text-center"> <td class="px-4 py-4 text-center">
<template v-if="profileDetails[profile.profileName]"> <template v-if="profileDetails[profile.profileName]">
<button <button
v-if="profileDetails[profile.profileName].myGeneral" v-if="profileDetails[profile.profileName]?.myGeneral"
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors" class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
> >
입장 입장
@@ -1,5 +1,4 @@
import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js'; import type { GeneralActionDefinition } from '@sammo-ts/logic/actions/definition.js';
import type { GeneralActionResolver } from '@sammo-ts/logic/actions/engine.js';
import type { ActionContextBuilder } from './actionContext.js'; import type { ActionContextBuilder } from './actionContext.js';
import type { TurnCommandEnv } from './commandEnv.js'; import type { TurnCommandEnv } from './commandEnv.js';
@@ -13,8 +12,5 @@ export interface TurnCommandSpecBase<TKey extends string = string> {
export interface TurnCommandModule<TSpec extends TurnCommandSpecBase = TurnCommandSpecBase> { export interface TurnCommandModule<TSpec extends TurnCommandSpecBase = TurnCommandSpecBase> {
commandSpec: TSpec; commandSpec: TSpec;
ActionDefinition: new (...args: unknown[]) => GeneralActionDefinition;
ActionResolver?: new (...args: unknown[]) => GeneralActionResolver;
CommandResolver?: new (...args: unknown[]) => unknown;
actionContextBuilder?: ActionContextBuilder; actionContextBuilder?: ActionContextBuilder;
} }
+49 -41
View File
@@ -12,54 +12,62 @@ import type {
} from './types.js'; } from './types.js';
export interface GeneralActionModule<TriggerState extends GeneralTriggerState = GeneralTriggerState> { export interface GeneralActionModule<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
getName?(): string; getName?: (() => string) | undefined;
getInfo?(): string; getInfo?: (() => string) | undefined;
getPreTurnExecuteTriggerList?( getPreTurnExecuteTriggerList?:
context: GeneralActionContext<TriggerState> | ((context: GeneralActionContext<TriggerState>) => GeneralTriggerCaller<TriggerState> | null)
): GeneralTriggerCaller<TriggerState> | null; | undefined;
onCalcDomestic?( onCalcDomestic?:
context: GeneralActionContext<TriggerState>, | ((
turnType: TriggerDomesticActionType, context: GeneralActionContext<TriggerState>,
varType: TriggerDomesticVarType, turnType: TriggerDomesticActionType,
value: number, varType: TriggerDomesticVarType,
aux?: unknown value: number,
): number; aux?: unknown
) => number)
| undefined;
onCalcStat?( onCalcStat?:
context: GeneralActionContext<TriggerState>, | ((
statName: GeneralStatName, context: GeneralActionContext<TriggerState>,
value: number, statName: GeneralStatName,
aux?: unknown value: number,
): number; aux?: unknown
) => number)
| undefined;
onCalcOpposeStat?( onCalcOpposeStat?:
context: GeneralActionContext<TriggerState>, | ((
statName: GeneralStatName, context: GeneralActionContext<TriggerState>,
value: number, statName: GeneralStatName,
aux?: unknown value: number,
): number; aux?: unknown
) => number)
| undefined;
onCalcStrategic?( onCalcStrategic?:
context: GeneralActionContext<TriggerState>, | ((
turnType: TriggerStrategicActionType, context: GeneralActionContext<TriggerState>,
varType: TriggerStrategicVarType, turnType: TriggerStrategicActionType,
value: number varType: TriggerStrategicVarType,
): number; value: number
) => number)
| undefined;
onCalcNationalIncome?( onCalcNationalIncome?:
context: GeneralActionContext<TriggerState>, | ((context: GeneralActionContext<TriggerState>, type: TriggerNationalIncomeType, amount: number) => number)
type: TriggerNationalIncomeType, | undefined;
amount: number
): number;
onArbitraryAction?( onArbitraryAction?:
context: GeneralActionContext<TriggerState>, | ((
actionType: TriggerActionType, context: GeneralActionContext<TriggerState>,
phase?: TriggerActionPhase | null, actionType: TriggerActionType,
aux?: Record<string, unknown> | null phase?: TriggerActionPhase | null,
): Record<string, unknown> | null; aux?: Record<string, unknown> | null
) => Record<string, unknown> | null)
| undefined;
} }
export class GeneralActionPipeline<TriggerState extends GeneralTriggerState = GeneralTriggerState> { export class GeneralActionPipeline<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
@@ -14,15 +14,16 @@ export const parseWarDexAux = (aux: unknown): WarDexAux => {
const opposeRaw = aux.opposeType; const opposeRaw = aux.opposeType;
if (!isRecord(opposeRaw)) { if (!isRecord(opposeRaw)) {
return { isAttacker }; return isAttacker === undefined ? {} : { isAttacker };
} }
const armType = opposeRaw.armType; const armType = opposeRaw.armType;
if (typeof armType !== 'number') { if (typeof armType !== 'number') {
return { isAttacker }; return isAttacker === undefined ? {} : { isAttacker };
} }
return { isAttacker, opposeType: { armType } }; const opposeType = { armType };
return isAttacker === undefined ? { opposeType } : { isAttacker, opposeType };
}; };
export const getAuxArmType = (aux: unknown): number | undefined => { export const getAuxArmType = (aux: unknown): number | undefined => {
@@ -44,7 +44,7 @@ export const traitModule: TraitModule = {
kind: 'war', kind: 'war',
getName: () => '견고', getName: () => '견고',
getInfo: () => '[전투] 상대 필살 확률 -20%p, 상대 계략 시도시 성공 확률 -10%p, 부상 없음, 아군 피해 -10%', getInfo: () => '[전투] 상대 필살 확률 -20%p, 상대 계략 시도시 성공 확률 -10%p, 부상 없음, 아군 피해 -10%',
onCalcOpposeStat: (_context, statName, value, _aux) => { onCalcOpposeStat: ((_context, statName, value, _aux) => {
if (statName === 'warMagicSuccessProb' && typeof value === 'number') { if (statName === 'warMagicSuccessProb' && typeof value === 'number') {
return value - 0.1; return value - 0.1;
} }
@@ -52,7 +52,7 @@ export const traitModule: TraitModule = {
return value - 0.2; return value - 0.2;
} }
return value; return value;
}, }) as TraitModule['onCalcOpposeStat'],
getBattleInitTriggerList: (_context) => { getBattleInitTriggerList: (_context) => {
if (!_context.unit) return null; if (!_context.unit) return null;
return new WarTriggerCaller(new che_부상무효(_context.unit)); return new WarTriggerCaller(new che_부상무효(_context.unit));
@@ -4,6 +4,8 @@ import type { WarActionContext } from '@sammo-ts/logic/war/actions.js';
import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js'; import type { TraitModule } from '@sammo-ts/logic/triggers/special/types.js';
import { getMetaNumber } from '@sammo-ts/logic/war/utils.js'; import { getMetaNumber } from '@sammo-ts/logic/war/utils.js';
import { WarUnit } from '@sammo-ts/logic/war/units.js';
type WarUnitWithGeneral = WarUnit & { getGeneral: () => { meta: Record<string, unknown> } }; type WarUnitWithGeneral = WarUnit & { getGeneral: () => { meta: Record<string, unknown> } };
const hasGeneral = (unit: WarUnit): unit is WarUnitWithGeneral => const hasGeneral = (unit: WarUnit): unit is WarUnitWithGeneral =>
@@ -44,7 +46,11 @@ export const traitModule: TraitModule = {
// Note: unit.getGeneral() is only available for WarUnitGeneral. // Note: unit.getGeneral() is only available for WarUnitGeneral.
// In a real scenario, we should check if unit is WarUnitGeneral. // In a real scenario, we should check if unit is WarUnitGeneral.
if (hasGeneral(unit)) { if (hasGeneral(unit)) {
const killnum = getMetaNumber(unit.getGeneral().meta, 'rank_killnum', 0); const killnum = getMetaNumber(
unit.getGeneral().meta as Record<string, import('@sammo-ts/logic/domain/entities.js').TriggerValue>,
'rank_killnum',
0
);
const logVal = Math.log2(Math.max(1, killnum / 5)); const logVal = Math.log2(Math.max(1, killnum / 5));
attackMultiplier += logVal / 20; attackMultiplier += logVal / 20;
defenceMultiplier -= logVal / 50; defenceMultiplier -= logVal / 50;
@@ -99,12 +99,12 @@ export const traitModule: TraitModule = {
getName: () => '반계', getName: () => '반계',
getInfo: () => getInfo: () =>
'[전투] 상대의 계략 성공 확률 -10%p, 상대의 계략을 40% 확률로 되돌림, 반목 성공시 대미지 추가(+60% → +150%)', '[전투] 상대의 계략 성공 확률 -10%p, 상대의 계략을 40% 확률로 되돌림, 반목 성공시 대미지 추가(+60% → +150%)',
onCalcOpposeStat: (_context, statName, value, _aux) => { onCalcOpposeStat: ((_context, statName, value, _aux) => {
if (statName === 'warMagicSuccessProb' && typeof value === 'number') { if (statName === 'warMagicSuccessProb' && typeof value === 'number') {
return value - 0.1; return value - 0.1;
} }
return value; return value;
}, }) as TraitModule['onCalcOpposeStat'],
getBattlePhaseTriggerList: (_context) => { getBattlePhaseTriggerList: (_context) => {
if (!_context.unit) return null; if (!_context.unit) return null;
return new WarTriggerCaller(new che_반계시도(_context.unit), new che_반계발동(_context.unit)); return new WarTriggerCaller(new che_반계시도(_context.unit), new che_반계발동(_context.unit));
+27 -21
View File
@@ -16,32 +16,38 @@ export interface WarActionContext<TriggerState extends GeneralTriggerState = Gen
} }
export interface WarActionModule<TriggerState extends GeneralTriggerState = GeneralTriggerState> { export interface WarActionModule<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
getName?(): string; getName?: (() => string) | undefined;
getInfo?(): string; getInfo?: (() => string) | undefined;
getBattleInitTriggerList?(context: WarActionContext<TriggerState>): WarTriggerCaller | null; getBattleInitTriggerList?: ((context: WarActionContext<TriggerState>) => WarTriggerCaller | null) | undefined;
getBattlePhaseTriggerList?(context: WarActionContext<TriggerState>): WarTriggerCaller | null; getBattlePhaseTriggerList?: ((context: WarActionContext<TriggerState>) => WarTriggerCaller | null) | undefined;
onCalcStat?( onCalcStat?:
context: WarActionContext<TriggerState>, | ((
statName: WarStatName, context: WarActionContext<TriggerState>,
value: number | [number, number], statName: WarStatName,
aux?: unknown value: number | [number, number],
): number | [number, number]; aux?: unknown
) => number | [number, number])
| undefined;
onCalcOpposeStat?( onCalcOpposeStat?:
context: WarActionContext<TriggerState>, | ((
statName: WarStatName, context: WarActionContext<TriggerState>,
value: number | [number, number], statName: WarStatName,
aux?: unknown value: number | [number, number],
): number | [number, number]; aux?: unknown
) => number | [number, number])
| undefined;
getWarPowerMultiplier?( getWarPowerMultiplier?:
context: WarActionContext<TriggerState>, | ((
unit: WarUnit<TriggerState>, context: WarActionContext<TriggerState>,
oppose: WarUnit<TriggerState> unit: WarUnit<TriggerState>,
): [number, number]; oppose: WarUnit<TriggerState>
) => [number, number])
| undefined;
} }
export class WarActionPipeline<TriggerState extends GeneralTriggerState = GeneralTriggerState> { export class WarActionPipeline<TriggerState extends GeneralTriggerState = GeneralTriggerState> {
+9 -3
View File
@@ -110,7 +110,10 @@ const isSupplyCity = (city: City): boolean => {
return city.supplyState > 0; return city.supplyState > 0;
}; };
export const computeBattleOrder = (defender: WarUnit, attacker: WarUnitGeneral): number => { export const computeBattleOrder = <TriggerState extends GeneralTriggerState>(
defender: WarUnit<TriggerState>,
attacker: WarUnitGeneral<TriggerState>
): number => {
if (defender instanceof WarUnitCity) { if (defender instanceof WarUnitCity) {
const context = attacker.getActionContext(); const context = attacker.getActionContext();
return attacker.getActionPipeline().onCalcOpposeStat(context, 'cityBattleOrder', -1); return attacker.getActionPipeline().onCalcOpposeStat(context, 'cityBattleOrder', -1);
@@ -257,11 +260,14 @@ export const resolveWarBattle = <TriggerState extends GeneralTriggerState = Gene
defenderGenerals.push(unit); defenderGenerals.push(unit);
} }
if (defenderGenerals.length > 0 && computeBattleOrder(cityUnit, attackerUnit) > 0) { if (defenderGenerals.length > 0 && computeBattleOrder<TriggerState>(cityUnit, attackerUnit) > 0) {
defenderUnits.push(cityUnit); defenderUnits.push(cityUnit);
} }
defenderUnits.sort((lhs, rhs) => computeBattleOrder(rhs, attackerUnit) - computeBattleOrder(lhs, attackerUnit)); defenderUnits.sort(
(lhs, rhs) =>
computeBattleOrder<TriggerState>(rhs, attackerUnit) - computeBattleOrder<TriggerState>(lhs, attackerUnit)
);
const iter = defenderUnits.values(); const iter = defenderUnits.values();
let defender: WarUnit<TriggerState> | null = null; let defender: WarUnit<TriggerState> | null = null;