Merge branch 'main' into feature/nation-personnel-finance-parity

# Conflicts:
#	docs/frontend-legacy-parity.md
This commit is contained in:
2026-07-26 04:35:16 +00:00
32 changed files with 2629 additions and 1485 deletions
@@ -198,6 +198,7 @@ const zAdjustGeneralResources = z.object({
generalId: zFiniteNumber,
goldDelta: zFiniteNumber.optional(),
riceDelta: zFiniteNumber.optional(),
minGoldAfter: zFiniteNumber.optional(),
})
.refine((value) => value.goldDelta !== undefined || value.riceDelta !== undefined)
)
@@ -857,7 +857,8 @@ export const createReservedTurnHandler = async (options: {
fallbackDefinition: GeneralActionDefinition,
command: ReservedTurnEntry,
applyNextTurnAt: boolean,
alternativeDepth = 0
alternativeDepth = 0,
sharedActionRng?: RandUtil
): { nextTurnAt?: Date; actionKey: string; usedFallback: boolean; blockedReason?: string } => {
const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition);
const rawArgs = extractArgsRecord(command.args);
@@ -954,11 +955,12 @@ export const createReservedTurnHandler = async (options: {
uniqueConfig,
getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys,
});
let actionRng = sharedActionRng ?? buildRng(actionKey);
let baseContext: ActionContextBase = {
general: currentGeneral,
city: currentCity,
nation: currentNation,
rng: buildRng(actionKey),
rng: actionRng,
uniqueLottery,
};
let specificContext = buildActionContext(
@@ -985,11 +987,12 @@ export const createReservedTurnHandler = async (options: {
usedFallback = true;
blockedReason = '예약된 명령을 실행하지 못했습니다.';
logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.'));
actionRng = sharedActionRng ?? buildRng(actionKey);
baseContext = {
general: currentGeneral,
city: currentCity,
nation: currentNation,
rng: buildRng(actionKey),
rng: actionRng,
};
specificContext = baseContext;
}
@@ -1287,7 +1290,8 @@ export const createReservedTurnHandler = async (options: {
args: extractArgsRecord(resolution.alternative.args),
},
applyNextTurnAt,
alternativeDepth + 1
alternativeDepth + 1,
actionRng
);
}
@@ -194,7 +194,8 @@ async function handleAdjustGeneralResources(
}
const nextGold = general.gold + (adjustment.goldDelta ?? 0);
const nextRice = general.rice + (adjustment.riceDelta ?? 0);
if (nextGold < 0 || nextRice < 0) {
const minGoldAfter = adjustment.minGoldAfter ?? 0;
if (nextGold < minGoldAfter || nextRice < 0) {
return { type: 'adjustGeneralResources', ok: false, reason: '자원이 부족합니다.' };
}
}
@@ -307,7 +308,7 @@ async function handleTournamentMatchResult(
};
}
const rankKey = (suffix: string): string => `rank_${prefix}${suffix}`;
const rankKey = (suffix: string): string => `${prefix}${suffix}`;
const getRankNumber = (general: TurnGeneral | null, key: string): number =>
general && typeof general.meta[key] === 'number' ? Number(general.meta[key]) : 0;
@@ -1394,8 +1395,8 @@ async function handleTournamentBettingPayout(
continue;
}
const nextMeta = { ...general.meta } as TurnGeneral['meta'];
const betwinKey = 'rank_betwin';
const betwingoldKey = 'rank_betwingold';
const betwinKey = 'betwin';
const betwingoldKey = 'betwingold';
const currentBetwin = typeof nextMeta[betwinKey] === 'number' ? Number(nextMeta[betwinKey]) : 0;
const currentBetwingold = typeof nextMeta[betwingoldKey] === 'number' ? Number(nextMeta[betwingoldKey]) : 0;
nextMeta[betwinKey] = currentBetwin + delta.betwin;
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest';
import type { TurnSchedule } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js';
const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] };
const buildGeneral = (id: number, meta: Record<string, number> = {}): TurnGeneral => ({
id,
name: `장수${id}`,
nationId: 1,
cityId: 1,
troopId: 0,
stats: { leadership: 50, strength: 50, intelligence: 50 },
turnTime: new Date('0180-01-01T00:00:00Z'),
recentWarTime: null,
role: {
items: { horse: null, weapon: null, book: null, item: null },
personality: null,
specialDomestic: null,
specialWar: null,
},
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: { killturn: 24, ...meta },
penalty: {},
officerLevel: 1,
experience: 0,
dedication: 0,
injury: 0,
gold: 1_000,
rice: 1_000,
crew: 100,
crewTypeId: 0,
train: 0,
atmos: 0,
age: 30,
npcState: 0,
});
const buildWorld = (generals: TurnGeneral[]): InMemoryTurnWorld => {
const state: TurnWorldState = {
id: 1,
currentYear: 180,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: new Date('0180-01-01T00:00:00Z'),
meta: {},
};
const snapshot: TurnWorldSnapshot = {
generals,
cities: [],
nations: [],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {},
environment: { mapName: 'test', unitSet: 'test' },
},
map: {
id: 'test',
name: 'test',
cities: [],
defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 },
},
};
return new InMemoryTurnWorld(state, snapshot, { schedule });
};
describe('tournament world commands', () => {
it('updates the persisted tt rank keys for a tournament match', async () => {
const world = buildWorld([
buildGeneral(1, { ttg: 10, ttw: 2 }),
buildGeneral(2, { ttg: 5, ttl: 1 }),
]);
const handler = createTurnDaemonCommandHandler({ world });
await expect(
handler.handle({
type: 'tournamentMatchResult',
tournamentType: 0,
attackerId: 1,
defenderId: 2,
result: 'attacker',
})
).resolves.toMatchObject({ ok: true });
expect(world.getGeneralById(1)?.meta).toMatchObject({ ttg: 11, ttw: 3 });
expect(world.getGeneralById(2)?.meta).toMatchObject({ ttg: 5, ttl: 2 });
expect(world.getGeneralById(1)?.meta).not.toHaveProperty('rank_ttg');
});
it('updates persisted betting ranks when paying a winner', async () => {
const world = buildWorld([buildGeneral(1, { betwin: 2, betwingold: 100 })]);
const handler = createTurnDaemonCommandHandler({ world });
await expect(
handler.handle({
type: 'tournamentBettingPayout',
bettingId: 1,
payouts: [{ generalId: 1, amount: 500 }],
})
).resolves.toMatchObject({ ok: true, totalPayout: 500 });
expect(world.getGeneralById(1)).toMatchObject({
gold: 1_500,
meta: { betwin: 3, betwingold: 600 },
});
expect(world.getGeneralById(1)?.meta).not.toHaveProperty('rank_betwin');
});
it('enforces a command-specific minimum remaining gold atomically', async () => {
const world = buildWorld([buildGeneral(1)]);
const handler = createTurnDaemonCommandHandler({ world });
await expect(
handler.handle({
type: 'adjustGeneralResources',
adjustments: [{ generalId: 1, goldDelta: -501, minGoldAfter: 500 }],
})
).resolves.toMatchObject({ ok: false });
expect(world.getGeneralById(1)?.gold).toBe(1_000);
await expect(
handler.handle({
type: 'adjustGeneralResources',
adjustments: [{ generalId: 1, goldDelta: -500, minGoldAfter: 500 }],
})
).resolves.toMatchObject({ ok: true });
expect(world.getGeneralById(1)?.gold).toBe(500);
});
});