Refactor equipped items into persistent instances

This commit is contained in:
2026-07-25 06:13:03 +00:00
parent 4b13c6d4f5
commit d4be5347fe
17 changed files with 582 additions and 173 deletions
+49 -41
View File
@@ -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<string, unknown>
};
};
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);
+11 -7
View File
@@ -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 });
+3 -2
View File
@@ -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,
});
@@ -146,6 +146,7 @@ export const buildReservedTurnDefinitions = async (options: {
reqSecu: item.reqSecu,
buyable: item.buyable,
unique: item.unique,
...(item.initialCharges === undefined ? {} : { initialCharges: item.initialCharges }),
},
])
);
+27 -17
View File
@@ -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;
+67 -52
View File
@@ -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<string, unknown>;
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<string, unknown>;
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);
@@ -10,6 +10,7 @@ export interface TurnCommandItemCatalogEntry {
reqSecu: number;
buyable: boolean;
unique: boolean;
initialCharges?: number;
}
export interface TurnCommandEnv {
@@ -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<TriggerState>, args: TradeItemArgs): GeneralActionOutcome<TriggerState> {
resolve(
context: GeneralActionResolveContext<TriggerState>,
args: TradeItemArgs
): GeneralActionOutcome<TriggerState> {
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(`<C>${itemName}</>${josaUl} 구입했습니다.`, {
@@ -189,10 +200,13 @@ export class ActionDefinition<
scope: LogScope.SYSTEM,
category: LogCategory.ACTION,
});
context.addLog(`<R><b>【판매】</b></><D><b>${nation.name}</b></>의 <Y>${general.name}</>${josaYi} <C>${itemName}</>${josaUl} 판매했습니다!`, {
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
});
context.addLog(
`<R><b>【판매】</b></><D><b>${nation.name}</b></>의 <Y>${general.name}</>${josaYi} <C>${itemName}</>${josaUl} 판매했습니다!`,
{
scope: LogScope.SYSTEM,
category: LogCategory.HISTORY,
}
);
}
tryApplyUniqueLottery(context, { acquireType: '아이템', reason: ACTION_NAME });
@@ -202,7 +216,8 @@ export class ActionDefinition<
createGeneralPatchEffect<TriggerState>({
gold: nextGold,
experience: general.experience + 10,
role: nextRole,
role: nextGeneral.role,
itemInventory: nextGeneral.itemInventory,
}),
],
};
+21
View File
@@ -36,6 +36,25 @@ export interface GeneralItemSlots {
item: string | null;
}
export type GeneralItemSlot = keyof GeneralItemSlots;
export interface GeneralItemInstanceState {
charges?: number;
values: Record<string, TriggerValue>;
}
export interface GeneralItemInstance {
id: string;
itemKey: string;
state: GeneralItemInstanceState;
}
export interface GeneralItemInventory {
nextInstanceId: number;
instances: Record<string, GeneralItemInstance>;
equipped: Partial<Record<GeneralItemSlot, string>>;
}
export interface GeneralRole {
// General.php의 raw 컬럼을 그대로 유지하는 역할 데이터.
personality: string | null;
@@ -69,6 +88,8 @@ export interface General<TriggerState extends GeneralTriggerState = GeneralTrigg
age: number;
npcState: number;
triggerState: TriggerState;
// 아이템의 canonical 소유/상태 모델. role.items는 레거시 DB 컬럼/API용 projection이다.
itemInventory?: GeneralItemInventory;
meta: GeneralMeta;
}
@@ -1,6 +1,6 @@
import { GeneralTriggerCaller } from '@sammo-ts/logic/triggers/general.js';
import { CheItemHealTrigger } from '@sammo-ts/logic/triggers/generalTriggers/che_아이템치료.js';
import { consumeItemRemain, setItemRemain } from './utils.js';
import { consumeItemRemain } from './utils.js';
import type { ItemModule } from './types.js';
const ITEM_KEY = 'che_치료_환약';
@@ -14,6 +14,7 @@ export const itemModule: ItemModule = {
cost: 200,
buyable: true,
consumable: true,
initialCharges: 3,
reqSecu: 0,
unique: false,
getPreTurnExecuteTriggerList: (context) => {
@@ -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;
},
};
+14
View File
@@ -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';
+234
View File
@@ -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<string, unknown> | null =>
typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : 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<string, TriggerValue> = {};
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<string, unknown>,
fallbackSlots: GeneralItemSlots
): GeneralItemInventory => parseItemInventory(meta[INVENTORY_META_KEY], fallbackSlots);
export const serializeItemInventory = (inventory: GeneralItemInventory): Record<string, TriggerValue> => ({
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 = <T extends Record<string, unknown>>(
meta: T,
inventory: GeneralItemInventory
): T & { itemInventory: Record<string, TriggerValue> } => ({
...meta,
itemInventory: serializeItemInventory(inventory),
});
export const ensureItemInventory = <TriggerState extends GeneralTriggerState>(
general: General<TriggerState>
): 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 = <TriggerState extends GeneralTriggerState>(
general: General<TriggerState>,
slot: GeneralItemSlot
): GeneralItemInstance | null => {
const inventory = ensureItemInventory(general);
const instanceId = inventory.equipped[slot];
return instanceId ? (inventory.instances[instanceId] ?? null) : null;
};
export const equipNewItem = <TriggerState extends GeneralTriggerState>(
general: General<TriggerState>,
slot: GeneralItemSlot,
itemKey: string,
initialState: Partial<GeneralItemInstanceState> = {}
): 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 = <TriggerState extends GeneralTriggerState>(
general: General<TriggerState>,
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 = <TriggerState extends GeneralTriggerState>(
general: General<TriggerState>,
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;
};
+1
View File
@@ -26,6 +26,7 @@ export interface ItemModule<TriggerState extends GeneralTriggerState = GeneralTr
cost: number | null;
buyable: boolean;
consumable: boolean;
initialCharges?: number;
reqSecu: number;
unique: boolean;
+16 -20
View File
@@ -1,8 +1,7 @@
import type { ScenarioConfig } from '@sammo-ts/logic/scenario/types.js';
import type { General, GeneralTriggerState } from '@sammo-ts/logic/domain/entities.js';
import type { ItemModule } from './types.js';
const ITEM_REMAIN_PREFIX = 'itemRemain:';
import { consumeEquippedItemCharge, ensureItemInventory, getEquippedItemInstance } from './inventory.js';
const toBoolean = (value: unknown): boolean => {
if (typeof value === 'boolean') {
@@ -28,12 +27,11 @@ export const isInventoryEnabled = (config: ScenarioConfig): boolean => {
export const listEquippedItemKeys = <TriggerState extends GeneralTriggerState>(
general: General<TriggerState>
): 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<string>();
const result: string[] = [];
for (const key of items) {
@@ -50,7 +48,8 @@ export const getItemRemain = <TriggerState extends GeneralTriggerState>(
general: General<TriggerState>,
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 = <TriggerState extends GeneralTriggerState>(
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 = <TriggerState extends GeneralTriggerState>(
@@ -72,13 +74,7 @@ export const consumeItemRemain = <TriggerState extends GeneralTriggerState>(
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 = <TriggerState extends GeneralTriggerState>(options: {
+8 -6
View File
@@ -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<string, Record<string, number>>;
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 = <TriggerState extends GeneralTriggerState = G
const josaYi = JosaUtil.pick(generalName, '이');
const josaUl = JosaUtil.pick(itemRawName, '을');
general.role.items[itemModule.slot] = itemModule.key;
equipNewItem(general, itemModule.slot, itemModule.key, {
...(itemModule.initialCharges === undefined ? {} : { charges: itemModule.initialCharges }),
});
context.addLog(`<C>${itemName}</>${josaUl} 습득했습니다!`, {
scope: LogScope.GENERAL,
@@ -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<string, unknown>): Record<string, unknown> {
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(`<C>${this.options.itemName}</>${josa} 사용하여 치료합니다!`);
if (this.options.consume()) {
general.role.items.item = null;
}
this.options.consume();
return env;
}
+93
View File
@@ -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' },
});
});
});