优化堆叠和取东西

This commit is contained in:
桃树夭 committed 2026-09-25 14:50:15 +08:00
1 parent df4a618059
commit a699e2c833
16 files changed
+9473 -128

No files matched your search

@@ -0,0 +1,35 @@
CREATE FUNCTION pg_temp.canonical_consumable_json(value jsonb)
RETURNS text
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE serialized text;
BEGIN
IF jsonb_typeof(value) = 'object' THEN
SELECT string_agg(to_jsonb(entry.key)::text || ':' || pg_temp.canonical_consumable_json(entry.value), ',' ORDER BY entry.key COLLATE "C")
INTO serialized
FROM jsonb_each(value) AS entry(key, value);
RETURN '{' || coalesce(serialized, '') || '}';
ELSIF jsonb_typeof(value) = 'array' THEN
SELECT string_agg(pg_temp.canonical_consumable_json(entry.value), ',' ORDER BY entry.position)
INTO serialized
FROM jsonb_array_elements(value) WITH ORDINALITY AS entry(value, position);
RETURN '[' || coalesce(serialized, '') || ']';
ELSIF jsonb_typeof(value) = 'number' THEN
RETURN trim_scale((value #>> '{}')::numeric)::text;
END IF;
RETURN value::text;
END;
$$;--> statement-breakpoint
UPDATE "wanjiedaoyou_inventory_items"
SET "stack_key" = 'consumable.v1:' || encode(sha256(convert_to(
'consumable.v2:[' ||
to_jsonb("instance_data"->>'name')::text || ',' ||
to_jsonb("instance_data"->>'type')::text || ',' ||
to_jsonb("instance_data"->>'quality')::text || ',' ||
to_jsonb(pg_temp.canonical_consumable_json("instance_data"->'spec'))::text ||
']'
, 'UTF8')), 'hex')
WHERE "definition_id" = 'consumable.v1'
AND "instance_data" ?& array['name', 'type', 'quality', 'spec'];--> statement-breakpoint
DROP FUNCTION pg_temp.canonical_consumable_json(jsonb);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -400,6 +400,13 @@
"when": 1790175589827,
"tag": "0056_cultivator_stories",
"breakpoints": true
},
{
"idx": 57,
"version": "7",
"when": 1790318696206,
"tag": "0057_consumable_stack_identity",
"breakpoints": true
}
]
}
@@ -41,8 +41,6 @@ function VaultList({ onChanged }: { onChanged?: () => void }) {
const [page, setPage] = useState(0);
const [search, setSearch] = useState('');
const [refresh, setRefresh] = useState(0);
const [selected, setSelected] = useState<string>();
const [quantity, setQuantity] = useState(1);
const [pending, setPending] = useState(false);
const [error, setError] = useState('');
const busy = useRef(false);
@@ -72,28 +70,41 @@ function VaultList({ onChanged }: { onChanged?: () => void }) {
});
return () => controller.abort();
}, [page, search, kind, refresh]);
async function withdraw(item: VaultView['items'][number]) {
if (busy.current || item.unavailableReason) return;
async function withdraw(items: VaultView['items']) {
if (busy.current || items.length === 0) return;
busy.current = true;
setPending(true);
try {
await consumeResourceMutation(
const result = await consumeResourceMutation<{ stored: boolean }>(
await fetch(
'/api/combat-v6/forging/vault/withdraw',
mutationBody({
id: item.id,
kind: item.kind,
quantity,
expectedQuantity: item.quantity,
}),
items.length === 1
? '/api/combat-v6/forging/vault/withdraw'
: '/api/combat-v6/forging/vault/withdraw-page',
mutationBody(
items.length === 1
? {
id: items[0].id,
kind: items[0].kind,
expectedQuantity: items[0].quantity,
}
: {
items: items.map((item) => ({
id: item.id,
kind: item.kind,
expectedQuantity: item.quantity,
})),
},
),
),
);
if (!alive.current) return;
pushToast({
message: `已取出 ${item.name} ×${quantity}`,
message:
items.length === 1
? `已取出 ${items[0].name} ×${items[0].quantity}${result.stored ? ',背包放不下的部分已存入储藏室' : ''}`
: `已取出本页 ${items.length} 种物品${result.stored ? ',背包放不下的部分已存入储藏室' : ''}`,
tone: 'success',
});
setSelected(undefined);
onChanged?.();
} catch (e) {
if (alive.current)
@@ -120,7 +131,6 @@ function VaultList({ onChanged }: { onChanged?: () => void }) {
onChange={(e) => {
setKind(e.target.value as typeof kind);
setPage(0);
setSelected(undefined);
setView(undefined);
}}
>
@@ -136,7 +146,6 @@ function VaultList({ onChanged }: { onChanged?: () => void }) {
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
setSelected(undefined);
setView(undefined);
}}
/>
@@ -159,6 +168,17 @@ function VaultList({ onChanged }: { onChanged?: () => void }) {
{view?.total === 0 ? (
<p className="text-ink-secondary">暂无物品。</p>
) : null}
{view && view.items.some((item) => !item.unavailableReason) ? (
<InkButton
disabled={pending}
pending={pending}
onClick={() =>
void withdraw(view.items.filter((item) => !item.unavailableReason))
}
>
取出本页
</InkButton>
) : null}
<div className="divide-ink/10 divide-y">
{view?.items.map((item) => (
<div key={item.id} className="space-y-2 py-3">
@@ -179,12 +199,9 @@ function VaultList({ onChanged }: { onChanged?: () => void }) {
</div>
<InkButton
disabled={pending || !!item.unavailableReason}
onClick={() => {
setSelected(selected === item.id ? undefined : item.id);
setQuantity(1);
}}
onClick={() => void withdraw([item])}
>
取出
全部取出
</InkButton>
</div>
{item.description ? (
@@ -195,41 +212,6 @@ function VaultList({ onChanged }: { onChanged?: () => void }) {
{item.unavailableReason ? (
<p className="text-ink-secondary">{item.unavailableReason}</p>
) : null}
{selected === item.id && !item.unavailableReason ? (
<div className="flex flex-wrap items-center gap-3">
<label>
取出数量{' '}
<input
aria-label="取出数量"
type="number"
min={1}
max={Math.min(item.quantity, 3960)}
value={quantity}
disabled={pending}
className="border-ink/20 w-20 border bg-transparent p-2 font-mono"
onChange={(e) => setQuantity(Number(e.target.value))}
/>
</label>
<InkButton
pending={pending}
disabled={
pending ||
!Number.isInteger(quantity) ||
quantity < 1 ||
quantity > Math.min(item.quantity, 3960)
}
onClick={() => void withdraw(item)}
>
确认取出
</InkButton>
<InkButton
disabled={pending}
onClick={() => setSelected(undefined)}
>
取消
</InkButton>
</div>
) : null}
</div>
))}
</div>
@@ -240,7 +222,6 @@ function VaultList({ onChanged }: { onChanged?: () => void }) {
onClick={() => {
setPage(view.page - 1);
setView(undefined);
setSelected(undefined);
}}
>
上一页
@@ -253,7 +234,6 @@ function VaultList({ onChanged }: { onChanged?: () => void }) {
onClick={() => {
setPage(view.page + 1);
setView(undefined);
setSelected(undefined);
}}
>
下一页
@@ -133,9 +133,9 @@ export function BeastPanel({
) : (
<InkTag className="shrink-0 text-xs">
{beast.originKind === 'wild'
? '成年'
? '野生'
: beast.originKind === 'pseudo_baby'
? '假幼崽'
? '野生'
: '幼崽'}
</InkTag>
)}
@@ -12,7 +12,7 @@ export function FusionIdentityTag({ beast }: { beast: SummonedBeast }) {
: beast.originKind === 'wild'
? '成年'
: beast.originKind === 'pseudo_baby'
? '假幼崽'
? '成年'
: '幼崽'}
</InkTag>
);
@@ -80,7 +80,7 @@ export default function MarketRecyclePage() {
const [selection, setSelection] = useState<RecycleSelection[]>([]);
const [quote, setQuote] = useState<{ key: string; value: RecycleQuote }>();
const [message, setMessage] = useState(
'把要出手的材料、丹药和灵果挑出来,我给你报个实价。',
'把要出手的材料、灵种、道装、图纸、功法玉简、丹药和灵果挑出来,我给你报个实价。',
);
const [receipts, setReceipts] = useState<string[]>([]);
const [pending, setPending] = useState(false);
@@ -6,9 +6,18 @@ import type {
RecycleResult,
RecycleSelection,
} from '@shared/contracts/recycle';
import { InventoryEquipmentSchema } from '@shared/inventory/equipment';
import { recycleBlockingReason } from '@shared/inventory/recycle';
import {
blueprintRecycleUnitPrice,
equipmentRecycleUnitPrice,
manualJadeRecycleUnitPrice,
seedRecycleUnitPrice,
} from '@shared/inventory/recyclePrice';
import { ConsumableFactsSchema } from '@shared/items/definitions/consumables';
import { SeedFactsSchema } from '@shared/items/definitions/seeds';
import { materialFactsOf } from '@shared/items/material';
import { findItemDefinition } from '@shared/items/registry';
import { calculateSpiritFruitRecycleUnitPrice } from '@shared/lib/pillRecyclePrice';
import { QUALITY_ORDER } from '@shared/types/constants';
import { and, eq, inArray, sql } from 'drizzle-orm';
@@ -60,6 +69,37 @@ export async function previewBagRecycle(
const snapshots = await selectedItems(owner, selection);
const items = snapshots.map((item, index) => {
const ref = selection[index];
const definition = findItemDefinition(item.definitionId);
if (definition?.kind === 'seed') {
const facts = SeedFactsSchema.parse(item.instanceData);
return {
...ref,
name: facts.name,
unitPrice: seedRecycleUnitPrice(facts.seedSpec.plant.quality),
};
}
if (definition?.kind === 'manual_jade' && definition.manualId) {
return {
...ref,
name: definition.name,
unitPrice: manualJadeRecycleUnitPrice(definition.manualId),
};
}
if (definition?.kind === 'blueprint' && definition.level) {
return {
...ref,
name: definition.name,
unitPrice: blueprintRecycleUnitPrice(definition.level),
};
}
if (definition?.kind === 'equipment') {
const equipment = InventoryEquipmentSchema.parse(item.instanceData);
return {
...ref,
name: equipment.name,
unitPrice: equipmentRecycleUnitPrice(equipment),
};
}
if (item.definitionId === 'consumable.v1') {
const facts = ConsumableFactsSchema.parse(item.instanceData);
return {
+78 -59
View File
@@ -5,6 +5,7 @@ import type {
VaultQuerySchema,
VaultView,
WithdrawMaterialSchema,
WithdrawVaultPageSchema,
} from '@shared/contracts/forging';
import {
BEAST_SPECIES,
@@ -104,6 +105,11 @@ async function mutate<T>(
operation: 'invalidate',
eventType: 'forging.profile.changed',
},
{
resourceTopic: 'inventory.bag',
operation: 'invalidate',
eventType: 'inventory.bag.changed',
},
{
resourceTopic: 'inventory.consumables',
operation: 'invalidate',
@@ -411,71 +417,84 @@ export async function readVault(
export async function withdrawMaterial(
owner: string,
input: z.infer<typeof WithdrawMaterialSchema>,
) {
return mutate(owner, (tx) => withdrawVaultItem(owner, input, tx));
}
export async function withdrawVaultPage(
owner: string,
input: z.infer<typeof WithdrawVaultPageSchema>,
) {
return mutate(owner, async (tx) => {
const table = input.kind === 'consumable' ? consumables : materials;
const [row] = await tx
.select()
.from(table)
.where(and(eq(table.id, input.id), eq(table.cultivatorId, owner)))
.for('update');
if (
!row ||
row.quantity !== input.expectedQuantity ||
input.quantity > row.quantity
)
throw new InventoryError('物品已变化,请刷新宝库');
if ('rank' in row) {
const blocked = legacyMaterialUnavailableReason(row);
if (blocked) throw new InventoryError(blocked);
const facts =
row.type === 'seed'
? seedFactsOf(row)
: MaterialFactsSchema.parse({
name: row.name,
type: row.type,
rank: row.rank,
element: row.element,
description: row.description ?? '',
});
await grantInventory(
owner,
[
{
definitionId: row.type === 'seed' ? 'seed.v1' : 'material.v1',
quantity: input.quantity,
instanceData: facts,
},
],
tx,
false,
);
} else {
const facts = consumableFactsOf(mapConsumableRow(row));
await grantInventory(
owner,
[
{
definitionId: 'consumable.v1',
quantity: input.quantity,
instanceData: facts,
},
],
tx,
false,
);
let withdrawn = 0;
let stored = false;
for (const item of input.items) {
const result = await withdrawVaultItem(owner, item, tx);
withdrawn += result.withdrawn;
stored ||= result.stored;
}
if (row.quantity === input.quantity)
await tx.delete(table).where(eq(table.id, row.id));
else
await tx
.update(table)
.set({ quantity: row.quantity - input.quantity })
.where(eq(table.id, row.id));
return { withdrawn: input.quantity };
return { withdrawn, count: input.items.length, stored };
});
}
async function withdrawVaultItem(
owner: string,
input: z.infer<typeof WithdrawMaterialSchema>,
tx: DbTransaction,
) {
const table = input.kind === 'consumable' ? consumables : materials;
const [row] = await tx
.select()
.from(table)
.where(and(eq(table.id, input.id), eq(table.cultivatorId, owner)))
.for('update');
if (!row || row.quantity !== input.expectedQuantity || row.quantity <= 0)
throw new InventoryError('物品已变化,请刷新宝库');
let stored: boolean;
if ('rank' in row) {
const blocked = legacyMaterialUnavailableReason(row);
if (blocked) throw new InventoryError(blocked);
const facts =
row.type === 'seed'
? seedFactsOf(row)
: MaterialFactsSchema.parse({
name: row.name,
type: row.type,
rank: row.rank,
element: row.element,
description: row.description ?? '',
});
const granted = await grantInventory(
owner,
[
{
definitionId: row.type === 'seed' ? 'seed.v1' : 'material.v1',
quantity: row.quantity,
instanceData: facts,
},
],
tx,
);
stored = granted.some((item) => item.location === 'storage');
} else {
const facts = consumableFactsOf(mapConsumableRow(row));
const granted = await grantInventory(
owner,
[
{
definitionId: 'consumable.v1',
quantity: row.quantity,
instanceData: facts,
},
],
tx,
);
stored = granted.some((item) => item.location === 'storage');
}
await tx.delete(table).where(eq(table.id, row.id));
return { withdrawn: row.quantity, stored };
}
export async function grantDevResources(input: z.infer<typeof DevGrantSchema>) {
return mutate(input.cultivatorId, async (tx) => {
const ids: string[] = [];
+14
View File
@@ -13,6 +13,7 @@ import {
readForge,
readVault,
withdrawMaterial,
withdrawVaultPage,
} from '@server/lib/services/ForgingService';
import { InventoryError } from '@server/lib/services/InventoryService';
import { QiServiceError } from '@server/lib/services/QiService';
@@ -20,6 +21,7 @@ import {
ForgeRequestSchema,
VaultQuerySchema,
WithdrawMaterialSchema,
WithdrawVaultPageSchema,
} from '@shared/contracts/forging';
import { InventoryRuleError } from '@shared/inventory';
import { Hono } from 'hono';
@@ -86,4 +88,16 @@ router.post(
)),
}),
);
router.post(
'/vault/withdraw-page',
validateJson(WithdrawVaultPageSchema),
async (c) =>
c.json({
success: true,
...(await withdrawVaultPage(
c.get('activeCultivatorRef')!.cultivatorId,
getValidatedJson(c),
)),
}),
);
export default router;
+5 -2
View File
@@ -37,14 +37,17 @@ export type ForgeView = {
spiritStones: number;
qi: number;
};
export const WithdrawMaterialSchema = z
const VaultWithdrawalItemSchema = z
.object({
kind: z.enum(['material', 'consumable']).default('material'),
id: z.uuid(),
quantity: z.number().int().min(1).max(3960),
expectedQuantity: z.number().int().positive(),
})
.strict();
export const WithdrawMaterialSchema = VaultWithdrawalItemSchema;
export const WithdrawVaultPageSchema = z
.object({ items: z.array(VaultWithdrawalItemSchema).min(1).max(40) })
.strict();
export const VaultQuerySchema = z
.object({
kind: z.enum(['material', 'consumable']).default('material'),
+11
View File
@@ -106,6 +106,17 @@ describe('alchemy inventory migration', () => {
expect(sorted.reduce((total, item) => total + item.quantity, 0)).toBe(120);
for (const item of sorted) expect(item.instanceData).toEqual(pill);
expect(ConsumableFactsSchema.parse(pill).spec).toEqual(pill.spec);
expect(inventoryStackIdentity('consumable.v1', pill)).toBe(
inventoryStackIdentity('consumable.v1', {
...pill,
description: '另一段描述',
prompt: '另一段提示词',
score: pill.score + 1,
}),
);
expect(inventoryStackIdentity('consumable.v1', pill)).not.toBe(
inventoryStackIdentity('consumable.v1', { ...pill, name: '另一种丹药' }),
);
expect(inventoryStackIdentity('consumable.v1', pill)).not.toBe(
inventoryStackIdentity('consumable.v1', {
...pill,
+115 -1
View File
@@ -1,8 +1,18 @@
import { describe, expect, it } from 'vitest';
import { RecycleRequestSchema } from '../contracts/recycle';
import { generateForgedEquipment } from '../engine/combat-v6/equipment/forging';
import { buildSpiritFieldSeedDetails } from '../engine/spirit-field/seedMaterial';
import type { SpiritFieldPlantSnapshot } from '../engine/spirit-field/types';
import { consumableFactsOf } from '../items/definitions/consumables';
import { seedFactsOf } from '../items/definitions/seeds';
import type { Consumable } from '../types/cultivator';
import { recycleBlockingReason } from './recycle';
import {
blueprintRecycleUnitPrice,
equipmentRecycleUnitPrice,
manualJadeRecycleUnitPrice,
seedRecycleUnitPrice,
} from './recyclePrice';
describe('回收边界', () => {
it('允许灵果回收,但仓库灵果仍需先取入随身背包', () => {
@@ -30,6 +40,7 @@ describe('回收边界', () => {
},
};
const item = {
id: 'fruit',
definitionId: 'consumable.v1',
instanceData: consumableFactsOf(fruit),
location: 'bag' as const,
@@ -70,6 +81,7 @@ describe('回收边界', () => {
});
it('实例材料仅允许随身背包回收', () => {
const item = {
id: 'material',
definitionId: 'material.v1',
instanceData: { name: '玄铁', type: 'ore', rank: '凡品' },
};
@@ -78,15 +90,117 @@ describe('回收边界', () => {
recycleBlockingReason({ ...item, location: 'storage' }),
).not.toBeNull();
});
it('不把道装、未知定义或损坏的消耗品误当可出售丹药', () => {
it('合法灵种与功法玉简可以回收,损坏事实仍被拒绝', () => {
const plant: SpiritFieldPlantSnapshot = {
id: 'recycle-seed',
seedName: '青露种',
seedDescription: '可培育灵果。',
clueTexts: [],
quality: '玄品',
element: '木',
minRealm: '炼气',
growthForm: 'shrub',
harvestPart: 'fruit',
preferredMethods: [],
avoidedMethods: [],
preferredHabitats: [],
avoidedHabitats: [],
growthTraits: [],
useTags: [],
outcomeBiases: [],
creationTags: [],
stageDurationMs: {
germination: 60_000,
nourishing: 60_000,
forming: 60_000,
},
baseYieldMin: 1,
baseYieldMax: 2,
};
const seed = {
id: 'seed',
definitionId: 'seed.v1',
location: 'bag' as const,
instanceData: seedFactsOf({
type: 'seed',
rank: '玄品',
details: buildSpiritFieldSeedDetails(plant),
}),
};
expect(recycleBlockingReason(seed)).toBeNull();
expect(seedRecycleUnitPrice('玄品')).toBe(300);
expect(recycleBlockingReason({ ...seed, instanceData: {} })).not.toBeNull();
const jade = {
id: 'jade',
definitionId: 'jade.character_manual.changchun',
location: 'bag' as const,
instanceData: null,
};
expect(recycleBlockingReason(jade)).toBeNull();
expect(manualJadeRecycleUnitPrice('character_manual.changchun')).toBe(45);
expect(
recycleBlockingReason({ ...jade, location: 'storage' }),
).not.toBeNull();
expect(recycleBlockingReason({ ...jade, instanceData: {} })).not.toBeNull();
});
it('背包图纸与有效未穿戴道装可以回收,报价按铸造成本计算', () => {
const blueprint = {
id: 'blueprint',
definitionId: 'blueprint.weapon.10',
location: 'bag' as const,
instanceData: null,
};
expect(recycleBlockingReason(blueprint)).toBeNull();
expect(blueprintRecycleUnitPrice(10)).toBe(20);
expect(
recycleBlockingReason({ ...blueprint, location: 'storage' }),
).not.toBeNull();
const generated = generateForgedEquipment({
id: 'equipment',
createdAt: '2026-09-25T00:00:00Z',
seed: 42,
templateId: 'dao_equipment.standard.weapon.v1',
equipmentLevel: 10,
boosts: { ore: 1, essence: 0, attributes: 0 },
});
expect(generated.ok).toBe(true);
if (!generated.ok) return;
const equipment = {
id: 'equipment',
definitionId: 'equipment.v6',
location: 'bag' as const,
instanceData: generated.instance,
};
expect(recycleBlockingReason(equipment)).toBeNull();
expect(
equipmentRecycleUnitPrice({ equipmentLevel: 10, baseQuality: 0 }),
).toBe(50);
expect(
equipmentRecycleUnitPrice({ equipmentLevel: 10, baseQuality: 1 }),
).toBe(75);
expect(
recycleBlockingReason({ ...equipment, location: 'equipped' }),
).not.toBeNull();
expect(recycleBlockingReason({ ...equipment, id: 'other' })).not.toBeNull();
});
it('不把未知定义或损坏的道装、消耗品当作可回收物品', () => {
for (const definitionId of ['equipment.v6', 'unknown', 'consumable.v1']) {
expect(
recycleBlockingReason({
id: 'invalid',
definitionId,
location: 'bag',
instanceData: {},
}),
).not.toBeNull();
}
expect(
recycleBlockingReason({
id: 'blueprint',
definitionId: 'blueprint.weapon.10',
location: 'bag',
instanceData: {},
}),
).not.toBeNull();
});
});
+32 -3
View File
@@ -1,12 +1,41 @@
import { isEquipmentLevel } from '../engine/combat-v6/equipment/realm';
import { ConsumableFactsSchema } from '../items/definitions/consumables';
import { SeedFactsSchema } from '../items/definitions/seeds';
import { findItemDefinition } from '../items/registry';
import { InventoryEquipmentSchema } from './equipment';
import type { InventoryItem } from './index';
export function recycleBlockingReason(
item: Pick<InventoryItem, 'location' | 'definitionId' | 'instanceData'>,
item: Pick<
InventoryItem,
'id' | 'location' | 'definitionId' | 'instanceData'
>,
): string | null {
if (item.location !== 'bag') return '请先取入随身物品栏。';
if (findItemDefinition(item.definitionId)?.kind === 'material') return null;
const definition = findItemDefinition(item.definitionId);
if (definition?.kind === 'material') return null;
if (
definition?.kind === 'seed' &&
SeedFactsSchema.safeParse(item.instanceData).success
)
return null;
if (definition?.kind === 'manual_jade' && item.instanceData === null)
return null;
if (
definition?.kind === 'blueprint' &&
definition.level &&
item.instanceData === null
)
return null;
if (definition?.kind === 'equipment') {
const parsed = InventoryEquipmentSchema.safeParse(item.instanceData);
if (
parsed.success &&
parsed.data.id === item.id &&
isEquipmentLevel(parsed.data.equipmentLevel)
)
return null;
}
if (item.definitionId === 'consumable.v1') {
const parsed = ConsumableFactsSchema.safeParse(item.instanceData);
if (
@@ -17,5 +46,5 @@ export function recycleBlockingReason(
)
return null;
}
return '这里只收购材料、丹药与灵果,这件物品请先留好。';
return '这里只收购材料、灵种、道装、道装图纸、功法玉简、丹药与灵果,这件物品请先留好。';
}
+45
View File
@@ -0,0 +1,45 @@
import { DAO_EQUIPMENT_FORGING } from '../engine/combat-v6/equipment/forging-content';
import { CHARACTER_MANUALS_V1 } from '../engine/combat-v6/manuals/content';
import {
BASE_PRICES,
TYPE_MULTIPLIERS,
} from '../engine/material/creation/config';
import { QUALITY_VALUES, REALM_VALUES, type Quality } from '../types/constants';
export function seedRecycleUnitPrice(quality: Quality): number {
return Math.max(1, Math.floor(BASE_PRICES[quality] * 0.3));
}
export function manualJadeRecycleUnitPrice(manualId: string): number {
const manual = CHARACTER_MANUALS_V1.find((entry) => entry.id === manualId);
if (!manual) throw new Error('功法玉简定义无效');
const realmIndex = REALM_VALUES.indexOf(manual.realm);
const quality =
QUALITY_VALUES[Math.min(realmIndex, QUALITY_VALUES.length - 1)];
return Math.max(
1,
Math.floor(BASE_PRICES[quality] * TYPE_MULTIPLIERS.gongfa_manual * 0.3),
);
}
export function blueprintRecycleUnitPrice(level: number): number {
const cost = DAO_EQUIPMENT_FORGING.costs.find(
(entry) => entry.level === level,
);
if (!cost) throw new Error('道装图纸境界无效');
return Math.max(1, Math.floor(cost.spiritStones * 0.2));
}
export function equipmentRecycleUnitPrice(equipment: {
equipmentLevel: number;
baseQuality: number;
}): number {
const cost = DAO_EQUIPMENT_FORGING.costs.find(
(entry) => entry.level === equipment.equipmentLevel,
);
if (!cost) throw new Error('道装境界无效');
return Math.max(
1,
Math.floor(cost.spiritStones * (0.5 + equipment.baseQuality * 0.25)),
);
}
+1 -1
View File
@@ -13,7 +13,7 @@ export function inventoryStackIdentity(
if (definitionId === 'equipment.v6') return null;
if (definitionId === 'consumable.v1') {
const facts = ConsumableFactsSchema.parse(data);
return `consumable.v1:${JSON.stringify([facts.name, facts.type, facts.quality, facts.description, facts.prompt, facts.score, stableSerializeConsumableSpec(facts.spec)])}`;
return `consumable.v2:${JSON.stringify([facts.name, facts.type, facts.quality, stableSerializeConsumableSpec(facts.spec)])}`;
}
if (definitionId !== 'material.v1') return `definition.v1:${definitionId}`;
const facts = MaterialFactsSchema.parse(data);