From 52a75ca9b742e875288a35d34d29256795d25a30 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 29 Aug 2026 08:56:40 +0000 Subject: [PATCH] =?UTF-8?q?CHE=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=8F=99=EA=B2=B0=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20?= =?UTF-8?q?=ED=84=B4=20=EC=A0=95=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/turn/reservedTurnHandler.ts | 2 ++ .../generalTurnLegacyCompatibility.test.ts | 30 ++++++++++++++++++- .../test/helpers/turnTestHarness.ts | 2 ++ .../e2e/admin-runtime-actions.spec.ts | 23 ++++++++++++-- app/gateway-frontend/src/views/AdminView.vue | 13 ++++++++ .../src/actions/turn/general/che_출병.ts | 2 ++ packages/logic/test/itemInventory.test.ts | 20 +++++++++++++ 7 files changed, 89 insertions(+), 3 deletions(-) diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 0c6f1a42..3e074c20 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -28,6 +28,7 @@ import { addOccupiedUniqueItemKeys, buildGenericUniqueSeed, countOccupiedUniqueItems, + cloneItemInventory, createItemModuleRegistry, loadItemModules, resolveUniqueConfig, @@ -317,6 +318,7 @@ const readConfigNumber = (config: ScenarioConfig, key: string, fallback: number) const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({ ...general, + ...(general.itemInventory ? { itemInventory: cloneItemInventory(general.itemInventory) } : {}), ...(general.inheritancePoints ? { inheritancePoints: { ...general.inheritancePoints } } : {}), stats: { ...general.stats }, role: { diff --git a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts index f4355368..958023ba 100644 --- a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts +++ b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; 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 { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.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 () => { const harness = await createTurnTestHarness({ snapshot: makeSnapshot(makeGeneral()), diff --git a/app/game-engine/test/helpers/turnTestHarness.ts b/app/game-engine/test/helpers/turnTestHarness.ts index b77d5f74..4e830c5b 100644 --- a/app/game-engine/test/helpers/turnTestHarness.ts +++ b/app/game-engine/test/helpers/turnTestHarness.ts @@ -75,6 +75,7 @@ export type TurnTestHarnessOptions = { onActionResolved?: Parameters[0]['onActionResolved']; onActionProfiled?: Parameters[0]['onActionProfiled']; commandRngFactory?: Parameters[0]['commandRngFactory']; + commandEnv?: Parameters[0]['commandEnv']; wrapGeneralTurnHandler?: (handler: GeneralTurnHandler) => GeneralTurnHandler; extraCalendarHandlers?: TurnCalendarHandler[]; collectLogs?: boolean; @@ -115,6 +116,7 @@ export const createTurnTestHarness = async (options: TurnTestHarnessOptions) => onActionResolved: options.onActionResolved, onActionProfiled: options.onActionProfiled, commandRngFactory: options.commandRngFactory, + commandEnv: options.commandEnv, }); const generalTurnHandler = options.wrapGeneralTurnHandler ? options.wrapGeneralTurnHandler(handler) : handler; diff --git a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts index 8ec646c3..f737baf1 100644 --- a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts +++ b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts @@ -38,6 +38,7 @@ const installFixture = async ( afterRequestActions?: RuntimeAction[]; pendingProfileReads?: number; profileStatus?: 'RUNNING' | 'PAUSED' | 'COMPLETED' | 'STOPPED'; + pauseReason?: string; currentScenario?: string | null; gameIsUnited?: number; openerOnly?: boolean; @@ -183,6 +184,7 @@ const installFixture = async ( scenario: options.currentScenario ?? 'default', apiPort: 15015, status: completedCloseRequested ? 'STOPPED' : (options.profileStatus ?? 'RUNNING'), + lastError: options.pauseReason, buildStatus: 'SUCCEEDED', meta: {}, 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 }); }); -test('distinguishes a turn pause from an inaccessible stopped server in operator controls', async ({ page }) => { - await installFixture(page, { profileStatus: 'PAUSED' }); +test('distinguishes a turn pause from an inaccessible stopped server in operator controls', async ({ page }, testInfo) => { + await installFixture(page, { + profileStatus: 'PAUSED', + pauseReason: "Cannot assign to read only property 'charges' of object '#'", + }); await page.goto('/gateway/admin/servers/hwe%3Adefault'); 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: '일시정지' })).toBeDisabled(); 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 ({ diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index d5b10923..dca8251c 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -205,6 +205,7 @@ type AdminProfile = { tournamentRunning: boolean; }; buildCommitSha?: string; + lastError?: string; activeOperation?: { id: string; status: 'QUEUED' | 'RUNNING'; @@ -2443,6 +2444,18 @@ onMounted(() => {
빌드 커밋: {{ profile.buildCommitSha ?? '미지정' }}
+ +