CHE 아이템 상태 동결로 인한 턴 정지 수정

This commit is contained in:
2026-08-29 08:56:40 +00:00
parent 23678df9de
commit 52a75ca9b7
7 changed files with 89 additions and 3 deletions
@@ -28,6 +28,7 @@ import {
addOccupiedUniqueItemKeys, addOccupiedUniqueItemKeys,
buildGenericUniqueSeed, buildGenericUniqueSeed,
countOccupiedUniqueItems, countOccupiedUniqueItems,
cloneItemInventory,
createItemModuleRegistry, createItemModuleRegistry,
loadItemModules, loadItemModules,
resolveUniqueConfig, resolveUniqueConfig,
@@ -317,6 +318,7 @@ const readConfigNumber = (config: ScenarioConfig, key: string, fallback: number)
const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({ const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({
...general, ...general,
...(general.itemInventory ? { itemInventory: cloneItemInventory(general.itemInventory) } : {}),
...(general.inheritancePoints ? { inheritancePoints: { ...general.inheritancePoints } } : {}), ...(general.inheritancePoints ? { inheritancePoints: { ...general.inheritancePoints } } : {}),
stats: { ...general.stats }, stats: { ...general.stats },
role: { role: {
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { ConstantRNG, RandUtil } from '@sammo-ts/common'; import { ConstantRNG, RandUtil } from '@sammo-ts/common';
import { LogFormat } from '@sammo-ts/logic'; import {
LogFormat,
equipNewItem,
getEquippedItemInstance,
loadActionModuleBundle,
} from '@sammo-ts/logic';
import type { TurnSchedule } from '@sammo-ts/logic/turn/calendar.js'; import type { TurnSchedule } from '@sammo-ts/logic/turn/calendar.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnTestHarness } from './helpers/turnTestHarness.js'; import { createTurnTestHarness } from './helpers/turnTestHarness.js';
@@ -247,6 +252,29 @@ describe('legacy general-turn execution contract', () => {
}); });
}); });
it('keeps multi-use item state mutable across consecutive general turns', async () => {
const general = makeGeneral({ injury: 30 });
equipNewItem(general, 'item', 'che_치료_환약', { charges: 3 });
const actionModules = await loadActionModuleBundle();
const commandEnv = buildCommandEnv(makeSnapshot(general).scenarioConfig);
commandEnv.generalActionModules = actionModules.general;
const harness = await createTurnTestHarness({
snapshot: makeSnapshot(general),
state: makeState(),
schedule,
map,
commandEnv,
});
await harness.runOneTick();
expect(getEquippedItemInstance(harness.world.getGeneralById(1)!, 'item')?.state.charges).toBe(2);
harness.world.updateGeneral(1, { injury: 30 });
await harness.runOneTick();
expect(getEquippedItemInstance(harness.world.getGeneralById(1)!, 'item')?.state.charges).toBe(1);
});
it('fails closed instead of silently resting on an unknown queued command', async () => { it('fails closed instead of silently resting on an unknown queued command', async () => {
const harness = await createTurnTestHarness({ const harness = await createTurnTestHarness({
snapshot: makeSnapshot(makeGeneral()), snapshot: makeSnapshot(makeGeneral()),
@@ -75,6 +75,7 @@ export type TurnTestHarnessOptions = {
onActionResolved?: Parameters<typeof createReservedTurnHandler>[0]['onActionResolved']; onActionResolved?: Parameters<typeof createReservedTurnHandler>[0]['onActionResolved'];
onActionProfiled?: Parameters<typeof createReservedTurnHandler>[0]['onActionProfiled']; onActionProfiled?: Parameters<typeof createReservedTurnHandler>[0]['onActionProfiled'];
commandRngFactory?: Parameters<typeof createReservedTurnHandler>[0]['commandRngFactory']; commandRngFactory?: Parameters<typeof createReservedTurnHandler>[0]['commandRngFactory'];
commandEnv?: Parameters<typeof createReservedTurnHandler>[0]['commandEnv'];
wrapGeneralTurnHandler?: (handler: GeneralTurnHandler) => GeneralTurnHandler; wrapGeneralTurnHandler?: (handler: GeneralTurnHandler) => GeneralTurnHandler;
extraCalendarHandlers?: TurnCalendarHandler[]; extraCalendarHandlers?: TurnCalendarHandler[];
collectLogs?: boolean; collectLogs?: boolean;
@@ -115,6 +116,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) =>
onActionResolved: options.onActionResolved, onActionResolved: options.onActionResolved,
onActionProfiled: options.onActionProfiled, onActionProfiled: options.onActionProfiled,
commandRngFactory: options.commandRngFactory, commandRngFactory: options.commandRngFactory,
commandEnv: options.commandEnv,
}); });
const generalTurnHandler = options.wrapGeneralTurnHandler ? options.wrapGeneralTurnHandler(handler) : handler; const generalTurnHandler = options.wrapGeneralTurnHandler ? options.wrapGeneralTurnHandler(handler) : handler;
@@ -38,6 +38,7 @@ const installFixture = async (
afterRequestActions?: RuntimeAction[]; afterRequestActions?: RuntimeAction[];
pendingProfileReads?: number; pendingProfileReads?: number;
profileStatus?: 'RUNNING' | 'PAUSED' | 'COMPLETED' | 'STOPPED'; profileStatus?: 'RUNNING' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
pauseReason?: string;
currentScenario?: string | null; currentScenario?: string | null;
gameIsUnited?: number; gameIsUnited?: number;
openerOnly?: boolean; openerOnly?: boolean;
@@ -183,6 +184,7 @@ const installFixture = async (
scenario: options.currentScenario ?? 'default', scenario: options.currentScenario ?? 'default',
apiPort: 15015, apiPort: 15015,
status: completedCloseRequested ? 'STOPPED' : (options.profileStatus ?? 'RUNNING'), status: completedCloseRequested ? 'STOPPED' : (options.profileStatus ?? 'RUNNING'),
lastError: options.pauseReason,
buildStatus: 'SUCCEEDED', buildStatus: 'SUCCEEDED',
meta: {}, meta: {},
runtimeSettings: requestedRuntimeSettings runtimeSettings: requestedRuntimeSettings
@@ -388,14 +390,31 @@ test('updates live game options from the authoritative database snapshot', async
await page.screenshot({ path: testInfo.outputPath('runtime-settings-mobile.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('runtime-settings-mobile.png'), fullPage: true });
}); });
test('distinguishes a turn pause from an inaccessible stopped server in operator controls', async ({ page }) => { test('distinguishes a turn pause from an inaccessible stopped server in operator controls', async ({ page }, testInfo) => {
await installFixture(page, { profileStatus: 'PAUSED' }); await installFixture(page, {
profileStatus: 'PAUSED',
pauseReason: "Cannot assign to read only property 'charges' of object '#<Object>'",
});
await page.goto('/gateway/admin/servers/hwe%3Adefault'); await page.goto('/gateway/admin/servers/hwe%3Adefault');
await expect(page.getByTestId('profile-lifecycle-description')).toContainText('게임 조회와 예약턴 입력 가능'); await expect(page.getByTestId('profile-lifecycle-description')).toContainText('게임 조회와 예약턴 입력 가능');
await expect(page.getByTestId('profile-pause-reason')).toContainText('턴 정지 사유');
await expect(page.getByTestId('profile-pause-reason')).toContainText("read only property 'charges'");
await expect(page.getByRole('button', { name: '턴 재개' })).toBeEnabled(); await expect(page.getByRole('button', { name: '턴 재개' })).toBeEnabled();
await expect(page.getByRole('button', { name: '일시정지' })).toBeDisabled(); await expect(page.getByRole('button', { name: '일시정지' })).toBeDisabled();
await expect(page.getByRole('button', { name: '중지', exact: true })).toBeEnabled(); await expect(page.getByRole('button', { name: '중지', exact: true })).toBeEnabled();
await page.screenshot({ path: testInfo.outputPath('paused-reason-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
const pauseReason = page.getByTestId('profile-pause-reason');
await expect(pauseReason).toBeVisible();
const mobileReasonGeometry = await pauseReason.evaluate((element) => {
const rect = element.getBoundingClientRect();
return { left: rect.left, right: rect.right, viewport: window.innerWidth };
});
expect(mobileReasonGeometry.left).toBeGreaterThanOrEqual(0);
expect(mobileReasonGeometry.right).toBeLessThanOrEqual(mobileReasonGeometry.viewport);
await page.screenshot({ path: testInfo.outputPath('paused-reason-mobile.png'), fullPage: true });
}); });
test('lets a scenario opener close only a unified server and keeps the control usable on mobile', async ({ test('lets a scenario opener close only a unified server and keeps the control usable on mobile', async ({
@@ -205,6 +205,7 @@ type AdminProfile = {
tournamentRunning: boolean; tournamentRunning: boolean;
}; };
buildCommitSha?: string; buildCommitSha?: string;
lastError?: string;
activeOperation?: { activeOperation?: {
id: string; id: string;
status: 'QUEUED' | 'RUNNING'; status: 'QUEUED' | 'RUNNING';
@@ -2443,6 +2444,18 @@ onMounted(() => {
<div class="text-xs text-zinc-400">빌드 커밋: {{ profile.buildCommitSha ?? '미지정' }}</div> <div class="text-xs text-zinc-400">빌드 커밋: {{ profile.buildCommitSha ?? '미지정' }}</div>
<div
v-if="profile.status === 'PAUSED' && profile.lastError"
class="rounded border border-red-700/70 bg-red-950/40 p-3 text-sm text-red-100"
role="alert"
data-testid="profile-pause-reason"
>
<div class="font-semibold">턴 정지 사유</div>
<div class="mt-1 break-words font-mono text-xs text-red-200">
{{ profile.lastError }}
</div>
</div>
<div <div
v-if=" v-if="
hasCapability('admin.scenarios.reset', profile.profileName) && hasCapability('admin.scenarios.reset', profile.profileName) &&
@@ -40,6 +40,7 @@ import { simpleSerialize } from '@sammo-ts/logic/war/utils.js';
import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js'; import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic/world/types.js';
import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { cloneItemInventory } from '@sammo-ts/logic/items/inventory.js';
import { buildNationFrontStatePatches } from '../../../diplomacy/frontState.js'; import { buildNationFrontStatePatches } from '../../../diplomacy/frontState.js';
import type { TracePort } from '../../../ports/trace.js'; import type { TracePort } from '../../../ports/trace.js';
import { formatDestCityConstraintFailure } from '../constraintFailure.js'; import { formatDestCityConstraintFailure } from '../constraintFailure.js';
@@ -343,6 +344,7 @@ const cloneGeneral = <TriggerState extends GeneralTriggerState>(
general: General<TriggerState> general: General<TriggerState>
): General<TriggerState> => ({ ): General<TriggerState> => ({
...general, ...general,
...(general.itemInventory ? { itemInventory: cloneItemInventory(general.itemInventory) } : {}),
stats: { ...general.stats }, stats: { ...general.stats },
role: { role: {
...general.role, ...general.role,
+20
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { General } from '../src/domain/entities.js'; import type { General } from '../src/domain/entities.js';
import { import {
cloneItemInventory,
consumeEquippedItemCharge, consumeEquippedItemCharge,
createItemInventoryFromSlots, createItemInventoryFromSlots,
equipNewItem, equipNewItem,
@@ -110,4 +111,23 @@ describe('GeneralItemInventory', () => {
expect(getEquippedItemInstance({ ...general, itemInventory: parsed }, 'item')?.state.charges).toBe(-1); expect(getEquippedItemInstance({ ...general, itemInventory: parsed }, 'item')?.state.charges).toBe(-1);
}); });
it('clones an Immer-frozen inventory into mutable next-turn state', () => {
const general = makeGeneral();
equipNewItem(general, 'item', 'che_치료_환약', { charges: 3 });
const inventory = general.itemInventory!;
const instance = getEquippedItemInstance(general, 'item')!;
Object.freeze(instance.state);
Object.freeze(instance);
Object.freeze(inventory.instances);
Object.freeze(inventory.equipped);
Object.freeze(inventory);
const cloned = cloneItemInventory(inventory);
const nextGeneral = { ...general, itemInventory: cloned };
expect(consumeEquippedItemCharge(nextGeneral, 'item', 'che_치료_환약')).toBe(false);
expect(getEquippedItemInstance(nextGeneral, 'item')?.state.charges).toBe(2);
expect(instance.state.charges).toBe(3);
});
}); });