From d4be5347fe435dfe6be8aaf812ec24299cff61ff Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 25 Jul 2026 06:13:03 +0000 Subject: [PATCH] Refactor equipped items into persistent instances --- app/game-api/src/turns/commandTable.ts | 90 ++++--- app/game-engine/src/auction/finalizer.ts | 18 +- app/game-engine/src/turn/databaseHooks.ts | 5 +- .../src/turn/reservedTurnCommands.ts | 1 + .../src/turn/worldCommandHandler.ts | 44 ++-- app/game-engine/src/turn/worldLoader.ts | 119 +++++---- packages/logic/src/actions/turn/commandEnv.ts | 1 + .../src/actions/turn/general/che_장비매매.ts | 47 ++-- packages/logic/src/domain/entities.ts | 21 ++ packages/logic/src/items/che_치료_환약.ts | 10 +- packages/logic/src/items/index.ts | 14 ++ packages/logic/src/items/inventory.ts | 234 ++++++++++++++++++ packages/logic/src/items/types.ts | 1 + packages/logic/src/items/utils.ts | 36 ++- packages/logic/src/rewards/uniqueLottery.ts | 14 +- .../generalTriggers/che_아이템치료.ts | 7 +- packages/logic/test/itemInventory.test.ts | 93 +++++++ 17 files changed, 582 insertions(+), 173 deletions(-) create mode 100644 packages/logic/src/items/inventory.ts create mode 100644 packages/logic/test/itemInventory.test.ts diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 36a2372..e7a4401 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -3,6 +3,7 @@ import type { Constraint, ConstraintContext, General, + GeneralItemSlots, GeneralActionDefinition, GeneralTurnCommandSpec, Nation, @@ -13,6 +14,7 @@ import type { TriggerValue, } from '@sammo-ts/logic'; import { evaluateConstraints, loadGeneralTurnCommandSpecs, loadNationTurnCommandSpecs } from '@sammo-ts/logic'; +import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js'; import { asRecord, isRecord } from '@sammo-ts/common'; import type { CityRow, GeneralRow, NationRow, WorldStateRow } from '../context.js'; @@ -220,48 +222,54 @@ const buildConstraintEnv = (worldState: WorldStateRow): Record }; }; -const mapGeneralRow = (row: GeneralRow): General => ({ - id: row.id, - name: row.name, - nationId: row.nationId, - cityId: row.cityId, - troopId: row.troopId, - stats: { - leadership: row.leadership, - strength: row.strength, - intelligence: row.intel, - }, - experience: row.experience, - dedication: row.dedication, - officerLevel: row.officerLevel, - role: { - personality: normalizeCode(row.personalCode), - specialDomestic: normalizeCode(row.specialCode), - specialWar: normalizeCode(row.special2Code), - items: { - horse: normalizeCode(row.horseCode), - weapon: normalizeCode(row.weaponCode), - book: normalizeCode(row.bookCode), - item: normalizeCode(row.itemCode), +const mapGeneralRow = (row: GeneralRow): General => { + const legacySlots: GeneralItemSlots = { + horse: normalizeCode(row.horseCode), + weapon: normalizeCode(row.weaponCode), + book: normalizeCode(row.bookCode), + item: normalizeCode(row.itemCode), + }; + const meta = ensureGeneralMeta(asTriggerRecord(row.meta), row.id); + const itemInventory = readItemInventoryFromMeta(meta, legacySlots); + return { + id: row.id, + name: row.name, + nationId: row.nationId, + cityId: row.cityId, + troopId: row.troopId, + stats: { + leadership: row.leadership, + strength: row.strength, + intelligence: row.intel, }, - }, - injury: row.injury, - gold: row.gold, - rice: row.rice, - crew: row.crew, - crewTypeId: row.crewTypeId, - train: row.train, - atmos: row.atmos, - age: row.age, - npcState: row.npcState, - triggerState: { - flags: {}, - counters: {}, - modifiers: {}, - meta: {}, - }, - meta: ensureGeneralMeta(asTriggerRecord(row.meta), row.id), -}); + experience: row.experience, + dedication: row.dedication, + officerLevel: row.officerLevel, + role: { + personality: normalizeCode(row.personalCode), + specialDomestic: normalizeCode(row.specialCode), + specialWar: normalizeCode(row.special2Code), + items: projectItemSlots(itemInventory), + }, + injury: row.injury, + gold: row.gold, + rice: row.rice, + crew: row.crew, + crewTypeId: row.crewTypeId, + train: row.train, + atmos: row.atmos, + age: row.age, + npcState: row.npcState, + triggerState: { + flags: {}, + counters: {}, + modifiers: {}, + meta: {}, + }, + itemInventory, + meta, + }; +}; const mapCityRow = (row: CityRow): City => { const meta = asTriggerRecord(row.meta); diff --git a/app/game-engine/src/auction/finalizer.ts b/app/game-engine/src/auction/finalizer.ts index c115070..5aa65c2 100644 --- a/app/game-engine/src/auction/finalizer.ts +++ b/app/game-engine/src/auction/finalizer.ts @@ -1,5 +1,6 @@ import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra'; import { ActionLogger, ItemLoader, LogFormat, UserLogger, isItemKey } from '@sammo-ts/logic'; +import { cloneItemInventory, ensureItemInventory, equipNewItem } from '@sammo-ts/logic/items/index.js'; import { JosaUtil } from '@sammo-ts/common'; import type { TurnDaemonCommandResult } from '../lifecycle/types.js'; @@ -392,14 +393,17 @@ export const createAuctionFinalizer = async (options: { }; } } + const nextBidder = { + ...bidder, + role: { ...bidder.role, items: { ...bidder.role.items } }, + itemInventory: cloneItemInventory(ensureItemInventory(bidder)), + }; + equipNewItem(nextBidder, slot, itemKey, { + ...(itemModule.initialCharges === undefined ? {} : { charges: itemModule.initialCharges }), + }); world.updateGeneral(bidder.id, { - role: { - ...bidder.role, - items: { - ...bidder.role.items, - [slot]: itemKey, - }, - }, + role: nextBidder.role, + itemInventory: nextBidder.itemInventory, }); const bidderLogger = new ActionLogger({ generalId: bidder.id, nationId: bidder.nationId }); diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 9245fdc..bad8b89 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -20,6 +20,7 @@ import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/type import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; import { buildDiplomacyMeta } from '@sammo-ts/logic'; +import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js'; export interface DatabaseTurnHooks { hooks: TurnDaemonHooks; @@ -137,7 +138,7 @@ const buildGeneralUpdate = ( personalCode: toCode(general.role.personality), specialCode: toCode(general.role.specialDomestic), special2Code: toCode(general.role.specialWar), - meta: asJson(general.meta), + meta: asJson(withSerializedItemInventory(general.meta, ensureItemInventory(general))), turnTime: general.turnTime, recentWarTime: general.recentWarTime ?? null, }); @@ -172,7 +173,7 @@ const buildGeneralCreate = ( personalCode: toCode(general.role.personality), specialCode: toCode(general.role.specialDomestic), special2Code: toCode(general.role.specialWar), - meta: asJson(general.meta), + meta: asJson(withSerializedItemInventory(general.meta, ensureItemInventory(general))), turnTime: general.turnTime, recentWarTime: general.recentWarTime ?? null, }); diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index e8c677b..6560ea7 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -146,6 +146,7 @@ export const buildReservedTurnDefinitions = async (options: { reqSecu: item.reqSecu, buyable: item.buyable, unique: item.unique, + ...(item.initialCharges === undefined ? {} : { initialCharges: item.initialCharges }), }, ]) ); diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index cd0fa29..5e9d525 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -20,6 +20,12 @@ import { type ItemModule, type TriggerValue, } from '@sammo-ts/logic'; +import { + cloneItemInventory, + ensureItemInventory, + equipNewItem, + removeEquippedItem, +} from '@sammo-ts/logic/items/index.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { TurnGeneral } from './types.js'; @@ -622,21 +628,22 @@ async function handleDropItem( if (!general) { return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; } - const { itemType } = command; - const items = { ...general.role.items }; - if (items.horse === itemType) items.horse = null; - else if (items.weapon === itemType) items.weapon = null; - else if (items.book === itemType) items.book = null; - else if (items.item === itemType) items.item = null; - else { + const slot = (['horse', 'weapon', 'book', 'item'] as const).find( + (candidate) => general.role.items[candidate] === command.itemType + ); + if (!slot) { return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템을 가지고 있지 않습니다.' }; } + const nextGeneral = { + ...general, + role: { ...general.role, items: { ...general.role.items } }, + itemInventory: cloneItemInventory(ensureItemInventory(general)), + }; + removeEquippedItem(nextGeneral, slot); world.updateGeneral(command.generalId, { - role: { - ...general.role, - items, - }, + role: nextGeneral.role, + itemInventory: nextGeneral.itemInventory, }); return { type: 'dropItem', ok: true, generalId: command.generalId }; } @@ -1068,13 +1075,16 @@ async function handleVoteReward( reason: '유니크 아이템을 찾을 수 없습니다.', }; } - patch.role = { - ...general.role, - items: { - ...general.role.items, - [itemModule.slot]: itemKey, - }, + const nextGeneral = { + ...general, + role: { ...general.role, items: { ...general.role.items } }, + itemInventory: cloneItemInventory(ensureItemInventory(general)), }; + equipNewItem(nextGeneral, itemModule.slot, itemKey, { + ...(itemModule.initialCharges === undefined ? {} : { charges: itemModule.initialCharges }), + }); + patch.role = nextGeneral.role; + patch.itemInventory = nextGeneral.itemInventory; const nationName = world.getNationById(general.nationId)?.name ?? '재야'; const generalName = general.name; diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index ef05b2a..5fa9177 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -8,7 +8,16 @@ import { type TurnEngineNationRow, type TurnEngineTroopRow, } from '@sammo-ts/infra'; -import type { City, Nation, ScenarioConfig, ScenarioMeta, Troop, TriggerValue } from '@sammo-ts/logic'; +import type { + City, + GeneralItemSlots, + Nation, + ScenarioConfig, + ScenarioMeta, + Troop, + TriggerValue, +} from '@sammo-ts/logic'; +import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js'; import { z } from 'zod'; import { asRecord, isRecord } from '@sammo-ts/common'; @@ -133,58 +142,64 @@ const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => { return parsed.data; }; -const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => ({ - ...((): { meta: TurnGeneral['meta'] } => { - const meta = asTriggerRecord(row.meta) as Record; - const killturn = readMetaNumber(meta, 'killturn'); - if (killturn === null) { - throw new Error(`general.meta.killturn is required (generalId=${row.id}).`); - } - return { meta: { ...meta, killturn } as TurnGeneral['meta'] }; - })(), - id: row.id, - name: row.name, - nationId: row.nationId, - cityId: row.cityId, - troopId: row.troopId, - stats: { - leadership: row.leadership, - strength: row.strength, - intelligence: row.intel, - }, - experience: row.experience, - dedication: row.dedication, - officerLevel: row.officerLevel, - role: { - personality: normalizeCode(row.personalCode), - specialDomestic: normalizeCode(row.specialCode), - specialWar: normalizeCode(row.special2Code), - items: { - horse: normalizeCode(row.horseCode), - weapon: normalizeCode(row.weaponCode), - book: normalizeCode(row.bookCode), - item: normalizeCode(row.itemCode), +const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => { + const legacySlots: GeneralItemSlots = { + horse: normalizeCode(row.horseCode), + weapon: normalizeCode(row.weaponCode), + book: normalizeCode(row.bookCode), + item: normalizeCode(row.itemCode), + }; + const rawMeta = asTriggerRecord(row.meta) as Record; + const itemInventory = readItemInventoryFromMeta(rawMeta, legacySlots); + return { + ...((): { meta: TurnGeneral['meta'] } => { + const meta = rawMeta; + const killturn = readMetaNumber(meta, 'killturn'); + if (killturn === null) { + throw new Error(`general.meta.killturn is required (generalId=${row.id}).`); + } + return { meta: { ...meta, killturn } as TurnGeneral['meta'] }; + })(), + id: row.id, + name: row.name, + nationId: row.nationId, + cityId: row.cityId, + troopId: row.troopId, + stats: { + leadership: row.leadership, + strength: row.strength, + intelligence: row.intel, }, - }, - injury: row.injury, - gold: row.gold, - rice: row.rice, - crew: row.crew, - crewTypeId: row.crewTypeId, - train: row.train, - atmos: row.atmos, - age: row.age, - npcState: row.npcState, - triggerState: { - flags: {}, - counters: {}, - modifiers: {}, - meta: {}, - }, - // meta는 상단에서 보장 처리됨. - turnTime: row.turnTime, - recentWarTime: row.recentWarTime ?? null, -}); + experience: row.experience, + dedication: row.dedication, + officerLevel: row.officerLevel, + role: { + personality: normalizeCode(row.personalCode), + specialDomestic: normalizeCode(row.specialCode), + specialWar: normalizeCode(row.special2Code), + items: projectItemSlots(itemInventory), + }, + injury: row.injury, + gold: row.gold, + rice: row.rice, + crew: row.crew, + crewTypeId: row.crewTypeId, + train: row.train, + atmos: row.atmos, + age: row.age, + npcState: row.npcState, + triggerState: { + flags: {}, + counters: {}, + modifiers: {}, + meta: {}, + }, + itemInventory, + // meta는 상단에서 보장 처리됨. + turnTime: row.turnTime, + recentWarTime: row.recentWarTime ?? null, + }; +}; const mapCityRow = (row: TurnEngineCityRow): City => { const meta = asTriggerRecord(row.meta); diff --git a/packages/logic/src/actions/turn/commandEnv.ts b/packages/logic/src/actions/turn/commandEnv.ts index f6353bf..3f675e2 100644 --- a/packages/logic/src/actions/turn/commandEnv.ts +++ b/packages/logic/src/actions/turn/commandEnv.ts @@ -10,6 +10,7 @@ export interface TurnCommandItemCatalogEntry { reqSecu: number; buyable: boolean; unique: boolean; + initialCharges?: number; } export interface TurnCommandEnv { diff --git a/packages/logic/src/actions/turn/general/che_장비매매.ts b/packages/logic/src/actions/turn/general/che_장비매매.ts index 96b5df7..1580b2e 100644 --- a/packages/logic/src/actions/turn/general/che_장비매매.ts +++ b/packages/logic/src/actions/turn/general/che_장비매매.ts @@ -13,14 +13,17 @@ import { createGeneralPatchEffect } from '@sammo-ts/logic/actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import { JosaUtil } from '@sammo-ts/common'; import { z } from 'zod'; -import type { - TurnCommandEnv, - TurnCommandItemCatalogEntry, -} from '@sammo-ts/logic/actions/turn/commandEnv.js'; +import type { TurnCommandEnv, TurnCommandItemCatalogEntry } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; +import { + cloneItemInventory, + ensureItemInventory, + equipNewItem, + removeEquippedItem, +} from '@sammo-ts/logic/items/inventory.js'; const ACTION_NAME = '장비매매'; const ACTION_KEY = 'che_장비매매'; @@ -140,7 +143,10 @@ export class ActionDefinition< return constraints; } - resolve(context: GeneralActionResolveContext, args: TradeItemArgs): GeneralActionOutcome { + resolve( + context: GeneralActionResolveContext, + args: TradeItemArgs + ): GeneralActionOutcome { const general = context.general; const nation = context.nation; @@ -161,13 +167,18 @@ export class ActionDefinition< const josaUl = JosaUtil.pick(itemRawName, '을'); const nextGold = buying ? Math.max(0, general.gold - itemCost) : general.gold + Math.floor(itemCost / 2); - const nextRole = { - ...general.role, - items: { - ...general.role.items, - [itemType]: buying ? finalItemCode : null, - }, + const nextGeneral = { + ...general, + role: { ...general.role, items: { ...general.role.items } }, + itemInventory: cloneItemInventory(ensureItemInventory(general)), }; + if (buying) { + equipNewItem(nextGeneral, itemType, finalItemCode, { + ...(item?.initialCharges === undefined ? {} : { charges: item.initialCharges }), + }); + } else { + removeEquippedItem(nextGeneral, itemType); + } if (buying) { context.addLog(`${itemName}${josaUl} 구입했습니다.`, { @@ -189,10 +200,13 @@ export class ActionDefinition< scope: LogScope.SYSTEM, category: LogCategory.ACTION, }); - context.addLog(`【판매】${nation.name}${general.name}${josaYi} ${itemName}${josaUl} 판매했습니다!`, { - scope: LogScope.SYSTEM, - category: LogCategory.HISTORY, - }); + context.addLog( + `【판매】${nation.name}${general.name}${josaYi} ${itemName}${josaUl} 판매했습니다!`, + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + } + ); } tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME }); @@ -202,7 +216,8 @@ export class ActionDefinition< createGeneralPatchEffect({ gold: nextGold, experience: general.experience + 10, - role: nextRole, + role: nextGeneral.role, + itemInventory: nextGeneral.itemInventory, }), ], }; diff --git a/packages/logic/src/domain/entities.ts b/packages/logic/src/domain/entities.ts index 6b6c672..a147d2f 100644 --- a/packages/logic/src/domain/entities.ts +++ b/packages/logic/src/domain/entities.ts @@ -36,6 +36,25 @@ export interface GeneralItemSlots { item: string | null; } +export type GeneralItemSlot = keyof GeneralItemSlots; + +export interface GeneralItemInstanceState { + charges?: number; + values: Record; +} + +export interface GeneralItemInstance { + id: string; + itemKey: string; + state: GeneralItemInstanceState; +} + +export interface GeneralItemInventory { + nextInstanceId: number; + instances: Record; + equipped: Partial>; +} + export interface GeneralRole { // General.php의 raw 컬럼을 그대로 유지하는 역할 데이터. personality: string | null; @@ -69,6 +88,8 @@ export interface General { @@ -29,11 +30,4 @@ export const itemModule: ItemModule = { }) ); }, - onArbitraryAction: (context, actionType, phase, aux) => { - if (actionType !== '장비매매' || phase !== '구매') { - return aux ?? null; - } - setItemRemain(context.general, ITEM_KEY, 3); - return aux ?? null; - }, }; diff --git a/packages/logic/src/items/index.ts b/packages/logic/src/items/index.ts index 65f0870..dbd4fae 100644 --- a/packages/logic/src/items/index.ts +++ b/packages/logic/src/items/index.ts @@ -572,3 +572,17 @@ export { getItemRemain, setItemRemain, } from './utils.js'; +export { + cloneItemInventory, + consumeEquippedItemCharge, + createItemInventoryFromSlots, + ensureItemInventory, + equipNewItem, + getEquippedItemInstance, + parseItemInventory, + projectItemSlots, + readItemInventoryFromMeta, + removeEquippedItem, + serializeItemInventory, + withSerializedItemInventory, +} from './inventory.js'; diff --git a/packages/logic/src/items/inventory.ts b/packages/logic/src/items/inventory.ts new file mode 100644 index 0000000..aa5da94 --- /dev/null +++ b/packages/logic/src/items/inventory.ts @@ -0,0 +1,234 @@ +import type { + General, + GeneralItemInstance, + GeneralItemInventory, + GeneralItemInstanceState, + GeneralItemSlot, + GeneralItemSlots, + GeneralTriggerState, + TriggerValue, +} from '@sammo-ts/logic/domain/entities.js'; + +const ITEM_SLOTS: GeneralItemSlot[] = ['horse', 'weapon', 'book', 'item']; +const INVENTORY_META_KEY = 'itemInventory'; + +const emptyState = (): GeneralItemInstanceState => ({ values: {} }); + +const cloneState = (state: GeneralItemInstanceState): GeneralItemInstanceState => ({ + ...(state.charges === undefined ? {} : { charges: state.charges }), + values: { ...state.values }, +}); + +export const createItemInventoryFromSlots = (slots: GeneralItemSlots): GeneralItemInventory => { + const inventory: GeneralItemInventory = { + nextInstanceId: 1, + instances: {}, + equipped: {}, + }; + for (const slot of ITEM_SLOTS) { + const itemKey = slots[slot]; + if (!itemKey || itemKey === 'None') { + continue; + } + const id = `legacy:${slot}`; + inventory.instances[id] = { id, itemKey, state: emptyState() }; + inventory.equipped[slot] = id; + } + return inventory; +}; + +export const cloneItemInventory = (inventory: GeneralItemInventory): GeneralItemInventory => ({ + nextInstanceId: inventory.nextInstanceId, + instances: Object.fromEntries( + Object.entries(inventory.instances).map(([id, instance]) => [ + id, + { ...instance, state: cloneState(instance.state) }, + ]) + ), + equipped: { ...inventory.equipped }, +}); + +const asRecord = (value: unknown): Record | null => + typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : null; + +const readState = (value: unknown): GeneralItemInstanceState | null => { + const record = asRecord(value); + if (!record) { + return null; + } + const charges = + typeof record['charges'] === 'number' && Number.isInteger(record['charges']) && record['charges'] >= 0 + ? record['charges'] + : undefined; + const valuesRecord = asRecord(record['values']) ?? {}; + const values: Record = {}; + for (const [key, entry] of Object.entries(valuesRecord)) { + if ( + typeof entry === 'boolean' || + typeof entry === 'number' || + typeof entry === 'string' || + (typeof entry === 'object' && entry !== null && !Array.isArray(entry)) + ) { + values[key] = entry as TriggerValue; + } + } + return { ...(charges === undefined ? {} : { charges }), values }; +}; + +export const parseItemInventory = (value: unknown, fallbackSlots: GeneralItemSlots): GeneralItemInventory => { + const record = asRecord(value); + if (!record) { + return createItemInventoryFromSlots(fallbackSlots); + } + const rawInstances = asRecord(record['instances']); + const rawEquipped = asRecord(record['equipped']); + const nextInstanceId = + typeof record['nextInstanceId'] === 'number' && + Number.isInteger(record['nextInstanceId']) && + record['nextInstanceId'] > 0 + ? record['nextInstanceId'] + : 1; + if (!rawInstances || !rawEquipped) { + return createItemInventoryFromSlots(fallbackSlots); + } + + const inventory: GeneralItemInventory = { + nextInstanceId, + instances: {}, + equipped: {}, + }; + for (const [id, rawInstance] of Object.entries(rawInstances)) { + const instance = asRecord(rawInstance); + const itemKey = instance?.['itemKey']; + const state = readState(instance?.['state']); + if (typeof itemKey !== 'string' || !itemKey || !state) { + continue; + } + inventory.instances[id] = { id, itemKey, state }; + } + for (const slot of ITEM_SLOTS) { + const instanceId = rawEquipped[slot]; + if (typeof instanceId === 'string' && inventory.instances[instanceId]) { + inventory.equipped[slot] = instanceId; + } + } + return inventory; +}; + +export const readItemInventoryFromMeta = ( + meta: Record, + fallbackSlots: GeneralItemSlots +): GeneralItemInventory => parseItemInventory(meta[INVENTORY_META_KEY], fallbackSlots); + +export const serializeItemInventory = (inventory: GeneralItemInventory): Record => ({ + nextInstanceId: inventory.nextInstanceId, + instances: Object.fromEntries( + Object.entries(inventory.instances).map(([id, instance]) => [ + id, + { + itemKey: instance.itemKey, + state: { + ...(instance.state.charges === undefined ? {} : { charges: instance.state.charges }), + values: instance.state.values, + }, + }, + ]) + ), + equipped: { ...inventory.equipped }, +}); + +export const withSerializedItemInventory = >( + meta: T, + inventory: GeneralItemInventory +): T & { itemInventory: Record } => ({ + ...meta, + itemInventory: serializeItemInventory(inventory), +}); + +export const ensureItemInventory = ( + general: General +): GeneralItemInventory => { + if (!general.itemInventory) { + general.itemInventory = createItemInventoryFromSlots(general.role.items); + } + return general.itemInventory; +}; + +export const projectItemSlots = (inventory: GeneralItemInventory): GeneralItemSlots => { + const slots: GeneralItemSlots = { horse: null, weapon: null, book: null, item: null }; + for (const slot of ITEM_SLOTS) { + const instanceId = inventory.equipped[slot]; + slots[slot] = instanceId ? (inventory.instances[instanceId]?.itemKey ?? null) : null; + } + return slots; +}; + +export const getEquippedItemInstance = ( + general: General, + slot: GeneralItemSlot +): GeneralItemInstance | null => { + const inventory = ensureItemInventory(general); + const instanceId = inventory.equipped[slot]; + return instanceId ? (inventory.instances[instanceId] ?? null) : null; +}; + +export const equipNewItem = ( + general: General, + slot: GeneralItemSlot, + itemKey: string, + initialState: Partial = {} +): GeneralItemInstance => { + const inventory = ensureItemInventory(general); + const previousId = inventory.equipped[slot]; + if (previousId) { + delete inventory.instances[previousId]; + } + const id = `${general.id}:${inventory.nextInstanceId}`; + inventory.nextInstanceId += 1; + const instance: GeneralItemInstance = { + id, + itemKey, + state: { + ...(initialState.charges === undefined ? {} : { charges: initialState.charges }), + values: { ...(initialState.values ?? {}) }, + }, + }; + inventory.instances[id] = instance; + inventory.equipped[slot] = id; + general.role.items[slot] = itemKey; + return instance; +}; + +export const removeEquippedItem = ( + general: General, + slot: GeneralItemSlot +): GeneralItemInstance | null => { + const inventory = ensureItemInventory(general); + const instanceId = inventory.equipped[slot]; + const instance = instanceId ? (inventory.instances[instanceId] ?? null) : null; + if (instanceId) { + delete inventory.instances[instanceId]; + } + delete inventory.equipped[slot]; + general.role.items[slot] = null; + return instance; +}; + +export const consumeEquippedItemCharge = ( + general: General, + slot: GeneralItemSlot, + itemKey: string, + fallbackCharges = 1 +): boolean => { + const instance = getEquippedItemInstance(general, slot); + if (!instance || instance.itemKey !== itemKey) { + return false; + } + const charges = instance.state.charges ?? fallbackCharges; + if (charges > 1) { + instance.state.charges = charges - 1; + return false; + } + removeEquippedItem(general, slot); + return true; +}; diff --git a/packages/logic/src/items/types.ts b/packages/logic/src/items/types.ts index 2dbfc25..c3a1965 100644 --- a/packages/logic/src/items/types.ts +++ b/packages/logic/src/items/types.ts @@ -26,6 +26,7 @@ export interface ItemModule { if (typeof value === 'boolean') { @@ -28,12 +27,11 @@ export const isInventoryEnabled = (config: ScenarioConfig): boolean => { export const listEquippedItemKeys = ( general: General ): string[] => { - const items = [ - general.role.items.horse, - general.role.items.weapon, - general.role.items.book, - general.role.items.item, - ]; + const inventory = ensureItemInventory(general); + const items = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => { + const instanceId = inventory.equipped[slot]; + return instanceId ? (inventory.instances[instanceId]?.itemKey ?? null) : null; + }); const seen = new Set(); const result: string[] = []; for (const key of items) { @@ -50,7 +48,8 @@ export const getItemRemain = ( general: General, itemKey: string ): number | null => { - const value = general.triggerState.counters[`${ITEM_REMAIN_PREFIX}${itemKey}`]; + const instance = getEquippedItemInstance(general, 'item'); + const value = instance?.itemKey === itemKey ? instance.state.charges : undefined; return typeof value === 'number' && value > 0 ? value : null; }; @@ -59,12 +58,15 @@ export const setItemRemain = ( itemKey: string, remain: number | null ): void => { - const key = `${ITEM_REMAIN_PREFIX}${itemKey}`; - if (remain === null || remain <= 0) { - delete general.triggerState.counters[key]; + const instance = getEquippedItemInstance(general, 'item'); + if (!instance || instance.itemKey !== itemKey) { return; } - general.triggerState.counters[key] = remain; + if (remain === null || remain <= 0) { + delete instance.state.charges; + return; + } + instance.state.charges = remain; }; export const consumeItemRemain = ( @@ -72,13 +74,7 @@ export const consumeItemRemain = ( itemKey: string, fallbackRemain = 1 ): boolean => { - const remain = getItemRemain(general, itemKey) ?? fallbackRemain; - if (remain > 1) { - setItemRemain(general, itemKey, remain - 1); - return false; - } - setItemRemain(general, itemKey, null); - return true; + return consumeEquippedItemCharge(general, 'item', itemKey, fallbackRemain); }; export const canAcquireItem = (options: { diff --git a/packages/logic/src/rewards/uniqueLottery.ts b/packages/logic/src/rewards/uniqueLottery.ts index 971ef5c..8acddbb 100644 --- a/packages/logic/src/rewards/uniqueLottery.ts +++ b/packages/logic/src/rewards/uniqueLottery.ts @@ -3,11 +3,12 @@ import type { General, GeneralItemSlots, GeneralTriggerState } from '../domain/e import type { GeneralActionResolveContext } from '../actions/engine.js'; import { LogCategory, LogFormat, LogScope } from '../logging/types.js'; import type { ItemModule } from '../items/types.js'; +import { equipNewItem } from '../items/inventory.js'; export type UniqueItemPool = Record>; export const UNIQUE_ACQUIRE_TYPES = ['아이템', '설문조사', '랜덤 임관', '건국'] as const; -export type UniqueAcquireType = typeof UNIQUE_ACQUIRE_TYPES[number]; +export type UniqueAcquireType = (typeof UNIQUE_ACQUIRE_TYPES)[number]; export type UniqueLotteryRequest = { acquireType: UniqueAcquireType; @@ -230,8 +231,7 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => { return null; } - const relMonthByInit = - joinYearMonth(currentYear, currentMonth) - joinYearMonth(initYear, initMonth); + const relMonthByInit = joinYearMonth(currentYear, currentMonth) - joinYearMonth(initYear, initMonth); const availableBuyUnique = relMonthByInit >= config.minMonthToAllowInheritItem; let prob: number; @@ -242,9 +242,9 @@ export const rollUniqueLottery = (input: UniqueLotteryInput): string | null => { } if (resolvedAcquireType === '설문조사') { - prob = 1 / (userCount * itemTypeCnt * 0.7 / 3); + prob = 1 / ((userCount * itemTypeCnt * 0.7) / 3); } else if (resolvedAcquireType === '랜덤 임관') { - prob = 1 / (userCount * itemTypeCnt / 10 / 2); + prob = 1 / ((userCount * itemTypeCnt) / 10 / 2); } prob *= config.uniqueTrialCoef; @@ -316,7 +316,9 @@ export const applyUniqueItemGain = ${itemName}${josaUl} 습득했습니다!`, { scope: LogScope.GENERAL, diff --git a/packages/logic/src/triggers/generalTriggers/che_아이템치료.ts b/packages/logic/src/triggers/generalTriggers/che_아이템치료.ts index be95fee..a32b159 100644 --- a/packages/logic/src/triggers/generalTriggers/che_아이템치료.ts +++ b/packages/logic/src/triggers/generalTriggers/che_아이템치료.ts @@ -2,6 +2,7 @@ import { JosaUtil } from '@sammo-ts/common'; import { TriggerPriority } from '@sammo-ts/logic/triggers/core.js'; import { BaseGeneralTrigger, type GeneralTriggerContext } from '@sammo-ts/logic/triggers/general.js'; import type { General } from '@sammo-ts/logic/domain/entities.js'; +import { getEquippedItemInstance } from '@sammo-ts/logic/items/inventory.js'; interface ItemHealOptions { itemKey: string; @@ -23,7 +24,7 @@ export class CheItemHealTrigger extends BaseGeneralTrigger { action(context: GeneralTriggerContext, env: Record): Record { const { general } = context; - if (general.role.items.item !== this.options.itemKey) { + if (getEquippedItemInstance(general, 'item')?.itemKey !== this.options.itemKey) { return env; } if (general.injury < this.options.injuryTarget) { @@ -36,9 +37,7 @@ export class CheItemHealTrigger extends BaseGeneralTrigger { const josa = JosaUtil.pick(this.options.itemRawName, '을'); context.log?.push(`${this.options.itemName}${josa} 사용하여 치료합니다!`); - if (this.options.consume()) { - general.role.items.item = null; - } + this.options.consume(); return env; } diff --git a/packages/logic/test/itemInventory.test.ts b/packages/logic/test/itemInventory.test.ts new file mode 100644 index 0000000..fefead0 --- /dev/null +++ b/packages/logic/test/itemInventory.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; + +import type { General } from '../src/domain/entities.js'; +import { + consumeEquippedItemCharge, + createItemInventoryFromSlots, + equipNewItem, + getEquippedItemInstance, + parseItemInventory, + projectItemSlots, + serializeItemInventory, +} from '../src/items/inventory.js'; + +const makeGeneral = (): General => ({ + id: 7, + name: '테스트', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 70, strength: 70, intelligence: 70 }, + experience: 0, + dedication: 0, + officerLevel: 0, + role: { + personality: null, + specialDomestic: null, + specialWar: null, + items: { horse: null, weapon: null, book: null, item: null }, + }, + injury: 30, + gold: 1000, + rice: 1000, + crew: 1000, + crewTypeId: 1100, + train: 100, + atmos: 100, + age: 30, + npcState: 0, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, +}); + +describe('GeneralItemInventory', () => { + it('upgrades legacy equipped columns into canonical item instances', () => { + const inventory = createItemInventoryFromSlots({ + horse: 'che_명마_01_노기', + weapon: null, + book: 'che_서적_01_효경전', + item: 'che_치료_환약', + }); + + expect(projectItemSlots(inventory)).toEqual({ + horse: 'che_명마_01_노기', + weapon: null, + book: 'che_서적_01_효경전', + item: 'che_치료_환약', + }); + expect(Object.keys(inventory.instances)).toHaveLength(3); + }); + + it('keeps consumable charges in the equipped item instance', () => { + const general = makeGeneral(); + equipNewItem(general, 'item', 'che_치료_환약', { charges: 3 }); + + expect(consumeEquippedItemCharge(general, 'item', 'che_치료_환약')).toBe(false); + expect(getEquippedItemInstance(general, 'item')?.state.charges).toBe(2); + expect(consumeEquippedItemCharge(general, 'item', 'che_치료_환약')).toBe(false); + expect(consumeEquippedItemCharge(general, 'item', 'che_치료_환약')).toBe(true); + expect(general.role.items.item).toBeNull(); + expect(getEquippedItemInstance(general, 'item')).toBeNull(); + }); + + it('round-trips inventory state through the persisted meta representation', () => { + const general = makeGeneral(); + equipNewItem(general, 'item', 'che_치료_환약', { + charges: 2, + values: { source: 'shop' }, + }); + const serialized = serializeItemInventory(general.itemInventory!); + const parsed = parseItemInventory(serialized, { + horse: null, + weapon: null, + book: null, + item: null, + }); + + expect(projectItemSlots(parsed).item).toBe('che_치료_환약'); + expect(Object.values(parsed.instances)[0]?.state).toEqual({ + charges: 2, + values: { source: 'shop' }, + }); + }); +});