feat : custom stats with double price in commission
CI / Verify (push) Successful in 1m49s
CI / Build immutable images and deploy (push) Successful in 2m14s

This commit is contained in:
2026-10-10 05:08:06 +07:00 Unverified
parent 0e813a0a3e
commit 6fc4427ae1
10 changed files with 186 additions and 10 deletions
+37 -3
View File
@@ -8,6 +8,7 @@ const transaction = vi.fn();
const forUpdate = vi.fn();
const returning = vi.fn();
const insert = vi.fn();
const values = vi.fn();
vi.mock("@/lib/commission/server", () => ({ requireCommissionUser }));
vi.mock("@/lib/security/rate-limit", () => ({ limitRequest }));
@@ -17,10 +18,12 @@ vi.mock("@/db", () => ({ getDb: () => ({ transaction }) }));
const { POST } = await import("./route");
function request() {
function request(customStats?: string) {
return new Request("https://guide.sudloh.com/api/commission/checkouts", {
method: "POST", headers: { Origin: "https://guide.sudloh.com", "Content-Type": "application/json" },
body: JSON.stringify({ teams: [], weapons: [{ characterKey: "c", weaponKeys: ["w1", "w2"] }],
body: JSON.stringify({ teams: customStats === undefined ? [] : [{ members: ["a", "b", "c", "d"].map((characterKey, index) => ({
characterKey, weaponKey: "w1", artifactKey: "artifact", customStats: index === 0 ? customStats : undefined,
})) }], weapons: [{ characterKey: "c", weaponKeys: ["w1", "w2"] }],
constellations: [] }),
});
}
@@ -37,7 +40,8 @@ describe("commission checkout availability", () => {
select: () => ({ from: () => ({ where: () => ({ for: forUpdate }) }) }),
insert,
}));
insert.mockReturnValue({ values: () => ({ returning }) });
insert.mockReturnValue({ values });
values.mockReturnValue({ returning });
returning.mockResolvedValue([{ id: "checkout-1" }]);
});
@@ -55,5 +59,35 @@ describe("commission checkout availability", () => {
expect(response.status).toBe(201);
expect(await response.json()).toEqual({ checkoutId: "checkout-1" });
expect(insert).toHaveBeenCalledOnce();
expect(values).toHaveBeenCalledWith(expect.objectContaining({ amountBaht: 140 }));
});
it("stores custom stats and charges twice the total on the server", async () => {
forUpdate.mockResolvedValue([{ isOpen: true }]);
const response = await POST(request("ATK 2000"));
expect(response.status).toBe(201);
expect(values).toHaveBeenCalledWith(expect.objectContaining({
amountBaht: 280, request: expect.objectContaining({ teams: [{ members: [
expect.objectContaining({ characterKey: "a", customStats: "ATK 2000" }),
expect.objectContaining({ characterKey: "b" }), expect.objectContaining({ characterKey: "c" }),
expect.objectContaining({ characterKey: "d" }),
] }] }),
}));
});
it("charges twice the total for custom stats on either comparison type without a team", async () => {
forUpdate.mockResolvedValue([{ isOpen: true }]);
for (const kind of ["weapons", "constellations"]) {
values.mockClear();
const body = { teams: [], weapons: [], constellations: [], [kind]: kind === "weapons"
? [{ characterKey: "a", weaponKeys: ["one", "two"], customStats: "ATK 2000" }]
: [{ characterKey: "a", levels: [0, 1], customStats: "ER 180%" }] };
const response = await POST(new Request("https://guide.sudloh.com/api/commission/checkouts", {
method: "POST", headers: { Origin: "https://guide.sudloh.com", "Content-Type": "application/json" },
body: JSON.stringify(body),
}));
expect(response.status).toBe(201);
expect(values).toHaveBeenCalledWith(expect.objectContaining({ amountBaht: 280,
request: expect.objectContaining({ [kind]: expect.arrayContaining([expect.objectContaining({ customStats: body[kind][0].customStats })]) }),
}));
}
});
});
@@ -68,6 +68,7 @@ export function CommissionIntro() {
<p className="flex justify-between gap-3"><span>จัดทีม 4 ตัวละคร</span><strong>฿100 ต่อทีม</strong></p>
<p className="flex justify-between gap-3"><span>เปรียบเทียบอาวุธ</span><strong>฿20 ต่ออาวุธ</strong></p>
<p className="flex justify-between gap-3"><span>เปรียบเทียบกลุ่มดาว</span><strong>฿20 ต่อระดับ</strong></p>
<p className="flex justify-between gap-3"><span>กำหนดค่าสเตตัสเอง (Custom stats)</span><strong>ยอดรวมทั้งหมด ×2</strong></p>
<p className="border-t pt-3 text-muted-foreground">หากไม่เลือกทีม จะมีค่าบริการพื้นฐาน ฿100</p>
</CardContent>
</Card>
+49 -3
View File
@@ -9,9 +9,10 @@ import { ConstellationSelect, RefinementSelect } from "@/components/admin/ui/opt
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { FieldError, FieldGroup, FieldLegend, FieldSet } from "@/components/ui/field";
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { commissionPrice, type CommissionRequest } from "@/lib/commission/request";
import { commissionPrice, hasCustomStats, type CommissionRequest } from "@/lib/commission/request";
import { commissionDraftKey, restoreCommissionDraft, serializeCommissionDraft } from "@/lib/commission/draft";
import { commissionRequestIssues } from "@/lib/commission/request-validation";
import { elementBackgroundColor } from "@/lib/catalog/element";
@@ -24,6 +25,26 @@ const blankMember = (): CommissionRequest["teams"][number]["members"][number] =>
});
const blankTeam = () => ({ members: Array.from({ length: 4 }, blankMember) });
function CustomStatsFields({ id, characterName, value, disabled, invalid, onValueChange }: {
id: string; characterName: string; value: string | undefined; disabled: boolean;
invalid: boolean; onValueChange: (value: string | undefined) => void;
}) {
return <FieldGroup className="mt-2 gap-2">
<Field orientation="horizontal">
<Checkbox id={id} disabled={disabled} checked={value !== undefined}
onCheckedChange={(checked) => onValueChange(checked ? "" : undefined)} />
<FieldLabel htmlFor={id}>Custom stats</FieldLabel>
</Field>
{value !== undefined && <Field data-invalid={invalid}>
<FieldLabel htmlFor={`${id}-details`}>ค่าสเตตัสของ {characterName}</FieldLabel>
<Textarea id={`${id}-details`} value={value} maxLength={4000} required aria-invalid={invalid}
aria-describedby={`${id}-help`} placeholder="ATK 2,000, CRIT Rate 70%, CRIT DMG 140%, ER 180%"
onChange={(event) => onValueChange(event.target.value)} />
<FieldDescription id={`${id}-help`}>ราคาทั้งคำขอ ×2 เมื่อมีตัวละครใช้ Custom stats</FieldDescription>
</Field>}
</FieldGroup>;
}
function checkoutError(code: string | undefined, status: number): string {
if (status === 401) return "เซสชันหมดอายุ กรุณาเข้าสู่ระบบอีกครั้ง";
if (status === 403) return "ไม่สามารถส่งคำขอจากหน้านี้ได้ กรุณาโหลดหน้าใหม่แล้วลองอีกครั้ง";
@@ -123,7 +144,7 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u
<p className="text-sm text-muted-foreground" role="status">{draftNotice}</p>
<p className="text-sm text-muted-foreground">ระยะเวลาจัดทำ 1–7 วันตามขนาดคำขอ งานเล็กอาจเสร็จภายในประมาณ 4 ชั่วโมงหลังยืนยันการชำระเงิน</p>
{attempted && issues.some((issue) => !issue.path) && <Alert variant="destructive" role="alert"><AlertDescription>เพิ่มทีมหรือรายการเปรียบเทียบอย่างน้อยหนึ่งรายการ</AlertDescription></Alert>}
<Card><CardHeader><CardTitle>ทีม - ฿100 ต่อทีม</CardTitle><CardDescription>เลือก 4 ตัวละคร พร้อมอาวุธและอาร์ติแฟกต์ของแต่ละตัว</CardDescription></CardHeader>
<Card><CardHeader><CardTitle>ทีม - ฿100 ต่อทีม</CardTitle><CardDescription>เลือก 4 ตัวละคร พร้อมอาวุธและอาร์ติแฟกต์ของแต่ละตัว หากกำหนดค่าสเตตัสเองให้ตัวละครใด ราคาทั้งคำขอจะเป็น 2 เท่าของยอดรวมปกติ</CardDescription></CardHeader>
<CardContent className="flex flex-col gap-4">
{request.teams.map((team, teamIndex) => <Card key={teamIndex} className="w-full"><CardHeader><CardTitle>ทีม {teamIndex + 1}</CardTitle></CardHeader><CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
@@ -171,6 +192,14 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u
}} triggerProps={{ size: "sm", className: "h-6 min-w-0 flex-1 justify-center px-1 text-xs [&>svg]:hidden" }} />
</div>
<p className="mt-1 truncate text-center text-xs font-medium" title={character?.name}>{character?.name ?? "เลือกตัวละคร"}</p>
<CustomStatsFields id={`custom-stats-team-${teamIndex}-${memberIndex}`}
characterName={character?.name ?? `ตัวละครที่ ${memberIndex + 1}`} value={member.customStats}
disabled={!character} invalid={invalid(`teams.${teamIndex}.members.${memberIndex}.customStats`)}
onValueChange={(customStats) => {
const teams = structuredClone(request.teams);
teams[teamIndex].members[memberIndex].customStats = customStats;
patch({ teams });
}} />
{errorsAt(`teams.${teamIndex}.members.${memberIndex}`).length > 0 && <FieldError>{errorsAt(`teams.${teamIndex}.members.${memberIndex}`).join(" - ")}</FieldError>}
</div>;
})}
@@ -234,6 +263,14 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u
{catalog.characters.find((item) => item.key === set.characterKey)?.name ?? "เลือกตัวละคร"}</p>
</div>
</div>
<CustomStatsFields id={`custom-stats-weapons-${index}`}
characterName={catalog.characters.find((item) => item.key === set.characterKey)?.name ?? "ตัวละคร"}
value={set.customStats} disabled={!set.characterKey} invalid={invalid(`weapons.${index}.customStats`)}
onValueChange={(customStats) => {
const weapons = structuredClone(request.weapons);
weapons[index].customStats = customStats;
patch({ weapons });
}} />
{errorsAt(`weapons.${index}`).length > 0 && <FieldError data-commission-errors tabIndex={-1}>{errorsAt(`weapons.${index}`).join(" - ")}</FieldError>}
<Button variant="outline" disabled={set.weaponKeys.length >= 100} onClick={() => {
const weapons = structuredClone(request.weapons); weapons[index].weaponKeys.push(""); patch({ weapons });
@@ -263,6 +300,14 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u
{catalog.characters.find((item) => item.key === set.characterKey)?.name ?? "เลือกตัวละคร"}</p>
</div>
</div>
<CustomStatsFields id={`custom-stats-constellations-${index}`}
characterName={catalog.characters.find((item) => item.key === set.characterKey)?.name ?? "ตัวละคร"}
value={set.customStats} disabled={!set.characterKey} invalid={invalid(`constellations.${index}.customStats`)}
onValueChange={(customStats) => {
const constellations = structuredClone(request.constellations);
constellations[index].customStats = customStats;
patch({ constellations });
}} />
{errorsAt(`constellations.${index}`).length > 0 && <FieldError data-commission-errors tabIndex={-1}>{errorsAt(`constellations.${index}`).join(" - ")}</FieldError>}
<Button variant="outline" onClick={() => patch({ constellations: request.constellations.filter((_, i) => i !== index) })}>ลบชุดนี้</Button>
</FieldGroup>)}
@@ -275,6 +320,7 @@ export function CommissionRequestForm({ catalog, userId }: { catalog: Catalog; u
<CardFooter className="flex flex-wrap items-center justify-between gap-3">
{error && <Alert variant="destructive" role="alert" className="w-full"><AlertDescription>{error}</AlertDescription></Alert>}
<div><strong className="text-lg">รวม ฿{commissionPrice(request)}</strong>
{hasCustomStats(request) && <p className="text-sm text-muted-foreground">Custom stats: ยอดรวมปกติ ฿{commissionPrice(request) / 2} ×2</p>}
<p className="text-xs text-muted-foreground sm:hidden">{request.teams.length} ทีม - {request.weapons.reduce((sum, set) => sum + set.weaponKeys.filter(Boolean).length, 0)} อาวุธ - {request.constellations.reduce((sum, set) => sum + set.levels.length, 0)} ระดับกลุ่มดาว</p>
{request.teams.length === 0 && request.weapons.length + request.constellations.length > 0 && <p className="text-sm text-muted-foreground">รวมค่าบริการพื้นฐาน ฿100</p>}
</div>
+21 -1
View File
@@ -6,7 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Kbd } from "@/components/ui/kbd";
import { elementBackgroundColor } from "@/lib/catalog/element";
import { characterRarityGradientClass, rarityGradientClass } from "@/lib/catalog/rarity";
import type { CommissionRequest } from "@/lib/commission/request";
import { hasCustomStats, type CommissionRequest } from "@/lib/commission/request";
import { cn } from "@/lib/utils";
type Item = { key: string; name: string; imageUrl: string; rarity?: number | null; element?: string | null };
@@ -60,6 +60,10 @@ function CommissionTeamCard({ team, index, catalog }: { team: CommissionRequest[
<Badge variant="secondary" className="h-4 px-0.5 text-[9px] lg:h-auto lg:px-2 lg:text-xs">R{member.refinement ?? "1"}</Badge>
</div>
<p className="mt-1 truncate text-center text-[10px] font-medium lg:text-xs" title={character?.name}>{character?.name ?? member.characterKey}</p>
{member.customStats !== undefined && <div className="mt-2 flex flex-col gap-1">
<Badge variant="secondary">Custom stats</Badge>
<p className="whitespace-pre-wrap wrap-break-word text-xs">{member.customStats}</p>
</div>}
</div>;
})}
</CardContent>
@@ -75,6 +79,14 @@ export function CommissionRequestSummary({ request, catalog, amountBaht }: {
request.constellations.reduce((sum, set) => sum + set.levels.length * 20, 0);
return <div className="@container flex flex-col gap-4">
{hasCustomStats(request) && <Card size="sm">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle>กำหนดค่าสเตตัสเอง (Custom stats)</CardTitle><Badge variant="secondary">ราคาทั้งหมด ×2</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">เพิ่มจากยอดรวมปกติ ฿{amountBaht / 2} รวมทั้งหมด ฿{amountBaht}</p>
</CardContent>
</Card>}
{request.teams.length === 0 && amountBaht > comparisonTotal ?
<Card size="sm"><CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle>ค่าบริการพื้นฐาน</CardTitle><Badge variant="secondary">฿100</Badge>
@@ -98,6 +110,10 @@ export function CommissionRequestSummary({ request, catalog, amountBaht }: {
})}
</div>
{character ? <div className="w-16 min-w-0"><ItemImage item={character} size="large" character /><p className="mt-1 truncate text-center text-xs" title={character.name}>{character.name}</p></div> : null}
{set.customStats !== undefined && <div className="col-span-2 flex flex-col gap-1">
<Badge variant="secondary">Custom stats ของ {character?.name ?? set.characterKey}</Badge>
<p className="whitespace-pre-wrap wrap-break-word text-sm">{set.customStats}</p>
</div>}
</CardContent>
</Card>;
})}
@@ -110,6 +126,10 @@ export function CommissionRequestSummary({ request, catalog, amountBaht }: {
<CardContent className="grid grid-cols-[minmax(0,1fr)_64px] items-center gap-3">
<div className="flex min-w-0 flex-wrap gap-2">{set.levels.map((level) => <Badge key={level} variant="outline">C{level}</Badge>)}</div>
{character ? <div className="w-16 min-w-0"><ItemImage item={character} size="large" character /><p className="mt-1 truncate text-center text-xs" title={character.name}>{character.name}</p></div> : null}
{set.customStats !== undefined && <div className="col-span-2 flex flex-col gap-1">
<Badge variant="secondary">Custom stats ของ {character?.name ?? set.characterKey}</Badge>
<p className="whitespace-pre-wrap wrap-break-word text-sm">{set.customStats}</p>
</div>}
</CardContent>
</Card>;
})}
+8
View File
@@ -16,6 +16,14 @@ const request: CommissionRequest = {
};
describe("commission draft recovery", () => {
it("preserves separate character stats including an enabled but unfinished field", () => {
const custom = structuredClone(request);
custom.teams[0].members[0].customStats = "ATK 2000\nCRIT Rate 70%";
custom.teams[0].members[1].customStats = "";
custom.weapons[0].customStats = "CRIT Rate 70%";
custom.constellations[0].customStats = "";
expect(restoreCommissionDraft(serializeCommissionDraft(custom, now), catalog, now)).toEqual(custom);
});
it("preserves a partially completed request and its refinements", () => {
expect(restoreCommissionDraft(serializeCommissionDraft(request, now), catalog, now)).toEqual(request);
});
+3
View File
@@ -7,12 +7,15 @@ const draftRequestSchema = z.object({
characterKey: draftKey, weaponKey: draftKey, artifactKey: draftKey,
constellation: z.enum(["0", "1", "2", "3", "4", "5", "6", "0-6"]),
refinement: z.enum(["1", "2", "3", "4", "5", "1-5"]),
customStats: z.string().max(4000).optional(),
})).length(4) })).max(20),
weapons: z.array(z.object({
customStats: z.string().max(4000).optional(),
characterKey: draftKey, weaponKeys: z.array(draftKey).min(1).max(100),
refinements: z.record(draftKey, z.enum(["1", "2", "3", "4", "5"])).optional(),
})).max(20),
constellations: z.array(z.object({
customStats: z.string().max(4000).optional(),
characterKey: draftKey, levels: z.array(z.number().int().min(0).max(6)).max(7),
})).max(20),
});
@@ -5,6 +5,14 @@ import type { CommissionRequest } from "./request";
const member = (key: string) => ({ characterKey: key, weaponKey: "weapon", artifactKey: "artifact", constellation: "0", refinement: "1" } as const);
describe("commission request feedback", () => {
it("identifies missing custom stats in each comparison", () => {
const issues = commissionRequestIssues({ teams: [],
weapons: [{ characterKey: "a", weaponKeys: ["one", "two"], customStats: "" }],
constellations: [{ characterKey: "a", levels: [0], customStats: " " }],
});
expect(issues.map((issue) => issue.path)).toEqual(["weapons.0.customStats", "constellations.0.customStats"]);
expect(issues.every((issue) => issue.message.includes("ค่าสเตตัส"))).toBe(true);
});
it("identifies each missing selection so errors can appear beside their pickers", () => {
const request: CommissionRequest = { teams: [{ members: [member("a"), member("b"), { ...member("c"), weaponKey: "", artifactKey: "" }, member("d")] }], weapons: [], constellations: [] };
expect(commissionRequestIssues(request)).toEqual([
+7 -2
View File
@@ -11,10 +11,15 @@ export function commissionRequestIssues(request: CommissionRequest) {
message = memberNumber && path[4] === "characterKey" ? `ทีม ${teamNumber}: เลือกตัวละครที่ ${memberNumber}`
: memberNumber && path[4] === "weaponKey" ? `ทีม ${teamNumber}: เลือกอาวุธให้ตัวละครที่ ${memberNumber}`
: memberNumber && path[4] === "artifactKey" ? `ทีม ${teamNumber}: เลือกอาร์ติแฟกต์ให้ตัวละครที่ ${memberNumber}`
: memberNumber && path[4] === "customStats" ? `ทีม ${teamNumber}: ระบุค่าสเตตัสให้ตัวละครที่ ${memberNumber} ไม่เกิน 4,000 ตัวอักษร`
: `ทีม ${teamNumber}: เลือกตัวละคร 4 ตัวที่ไม่ซ้ำกัน พร้อมอาวุธและอาร์ติแฟกต์`;
}
if (path[0] === "weapons") message = `ชุดเปรียบเทียบอาวุธ ${Number(path[1]) + 1}: เลือกตัวละครและอาวุธต่างกันอย่างน้อย 2 ชิ้น`;
if (path[0] === "constellations") message = `ชุดเปรียบเทียบกลุ่มดาว ${Number(path[1]) + 1}: เลือกตัวละครและกลุ่มดาวอย่างน้อย 1 ระดับ`;
if (path[0] === "weapons") message = path[2] === "customStats"
? `ชุดเปรียบเทียบอาวุธ ${Number(path[1]) + 1}: ระบุค่าสเตตัสที่ต้องการ ไม่เกิน 4,000 ตัวอักษร`
: `ชุดเปรียบเทียบอาวุธ ${Number(path[1]) + 1}: เลือกตัวละครและอาวุธต่างกันอย่างน้อย 2 ชิ้น`;
if (path[0] === "constellations") message = path[2] === "customStats"
? `ชุดเปรียบเทียบกลุ่มดาว ${Number(path[1]) + 1}: ระบุค่าสเตตัสที่ต้องการ ไม่เกิน 4,000 ตัวอักษร`
: `ชุดเปรียบเทียบกลุ่มดาว ${Number(path[1]) + 1}: เลือกตัวละครและกลุ่มดาวอย่างน้อย 1 ระดับ`;
return { path: path.join("."), message };
});
}
+41
View File
@@ -10,6 +10,47 @@ const request = {
};
describe("commission request pricing and shape", () => {
it("doubles weapon and constellation comparisons including the base fee", () => {
for (const kind of ["weapons", "constellations"] as const) {
const standard = commissionRequestSchema.parse({ teams: [], weapons: [], constellations: [], [kind]: request[kind] });
const custom = commissionRequestSchema.parse({ ...standard, [kind]: standard[kind].map((set) => ({ ...set, customStats: " ER 180% " })) });
expect(custom[kind][0].customStats).toBe("ER 180%");
expect(commissionPrice(custom)).toBe(commissionPrice(standard) * 2);
delete custom[kind][0].customStats;
expect(commissionPrice(custom)).toBe(commissionPrice(standard));
for (const customStats of ["", " ", "a".repeat(4001)])
expect(commissionRequestSchema.safeParse({ ...standard, [kind]: standard[kind].map((set) => ({ ...set, customStats })) }).success).toBe(false);
}
});
it("applies the multiplier once when teams and both comparison types have custom stats", () => {
const custom = commissionRequestSchema.parse({ ...request,
teams: request.teams.map((team) => ({ members: team.members.map((member) => ({ ...member, customStats: "ATK 2000" })) })),
weapons: request.weapons.map((set) => ({ ...set, customStats: "CRIT Rate 70%" })),
constellations: request.constellations.map((set) => ({ ...set, customStats: "ER 180%" })),
});
expect(commissionPrice(custom)).toBe(400);
});
it("doubles the entire subtotal once when any team character has custom stats", () => {
const standard = commissionRequestSchema.parse(request);
for (const count of [1, 2, 4]) {
const custom = commissionRequestSchema.parse({ ...request, teams: [{ members: request.teams[0].members.map((member, index) =>
index < count ? { ...member, customStats: ` ATK ${2000 + index} ` } : member) }] });
expect(commissionPrice(custom)).toBe(commissionPrice(standard) * 2);
expect(custom.teams[0].members[0].customStats).toBe("ATK 2000");
expect(custom.teams[0].members[1].customStats).toBe(count > 1 ? "ATK 2001" : undefined);
}
const custom = commissionRequestSchema.parse({ teams: request.teams.concat([{ members: request.teams[0].members.map((member, index) =>
index === 3 ? { ...member, customStats: "ER 180%" } : member) }]), weapons: [], constellations: [] });
expect(commissionPrice(custom)).toBe(400);
delete custom.teams[1].members[3].customStats;
expect(commissionPrice(custom)).toBe(200);
});
it("requires nonblank, bounded custom stats for each selected character", () => {
for (const customStats of ["", " ", "a".repeat(4001), true]) {
expect(commissionRequestSchema.safeParse({ ...request, teams: [{ members: request.teams[0].members.map((member, index) =>
index === 0 ? { ...member, customStats } : member) }] }).success).toBe(false);
}
});
it("accepts one complete team without either comparison type", () => {
const teamOnly = { teams: request.teams, weapons: [], constellations: [] };
const parsed = commissionRequestSchema.safeParse(teamOnly);
+11 -1
View File
@@ -7,6 +7,7 @@ const member = z.object({
artifactKey: key,
constellation: z.enum(["0", "1", "2", "3", "4", "5", "6", "0-6"]).default("0"),
refinement: z.enum(["1", "2", "3", "4", "5", "1-5"]).default("1"),
customStats: z.string().trim().min(1).max(4000).optional(),
});
const team = z.object({ members: z.array(member).length(4) }).refine(
({ members }) => new Set(members.map((item) => item.characterKey)).size === 4,
@@ -14,6 +15,7 @@ const team = z.object({ members: z.array(member).length(4) }).refine(
);
const weaponComparison = z.object({
characterKey: key,
customStats: z.string().trim().min(1).max(4000).optional(),
weaponKeys: z.array(key).min(2).max(100),
refinements: z.record(key, z.enum(["1", "2", "3", "4", "5"])).optional(),
}).refine(({ weaponKeys }) => new Set(weaponKeys).size === weaponKeys.length, "Choose different weapons")
@@ -22,6 +24,7 @@ const weaponComparison = z.object({
"Refinements must belong to selected weapons");
const constellationComparison = z.object({
characterKey: key,
customStats: z.string().trim().min(1).max(4000).optional(),
levels: z.array(z.number().int().min(0).max(6)).min(1).max(7),
}).refine(({ levels }) => new Set(levels).size === levels.length, "Choose different constellation levels");
@@ -34,9 +37,16 @@ export const commissionRequestSchema = z.object({
export type CommissionRequest = z.infer<typeof commissionRequestSchema>;
export function hasCustomStats(request: CommissionRequest): boolean {
return request.teams.some((team) => team.members.some((member) => member.customStats !== undefined)) ||
request.weapons.some((set) => set.customStats !== undefined) ||
request.constellations.some((set) => set.customStats !== undefined);
}
export function commissionPrice(request: CommissionRequest): number {
const hasItems = request.teams.length + request.weapons.length + request.constellations.length > 0;
return (hasItems ? Math.max(1, request.teams.length) * 100 : 0) +
const subtotal = (hasItems ? Math.max(1, request.teams.length) * 100 : 0) +
request.weapons.reduce((sum, item) => sum + item.weaponKeys.filter(Boolean).length * 20, 0) +
request.constellations.reduce((sum, item) => sum + item.levels.length * 20, 0);
return subtotal * (hasCustomStats(request) ? 2 : 1);
}