Refactor equipped items into persistent instances
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user