Refactor equipped items into persistent instances
This commit is contained in:
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -26,6 +26,7 @@ export interface ItemModule<TriggerState extends GeneralTriggerState = GeneralTr
|
||||
cost: number | null;
|
||||
buyable: boolean;
|
||||
consumable: boolean;
|
||||
initialCharges?: number;
|
||||
reqSecu: number;
|
||||
unique: boolean;
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user