优化堆叠和取东西
This commit is contained in:
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
@@ -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 {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 '这里只收购材料、灵种、道装、道装图纸、功法玉简、丹药与灵果,这件物品请先留好。';
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user