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);