312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
import { useState, useRef, useEffect } from "react";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { Link, useSearchParams } from "react-router";
|
||
import { canBreed } from "@botanica/genetics";
|
||
import { PlantPreview } from "../components/PlantPreview";
|
||
import { QueryMessage } from "../components/QueryMessage";
|
||
import { api, loadAllParents } from "../lib/api";
|
||
import type { PlantDto } from "@botanica/shared";
|
||
import { useGame } from "../lib/game";
|
||
import { newRequestId } from "../lib/requestId";
|
||
function Parent({
|
||
label,
|
||
id,
|
||
onChange,
|
||
plants,
|
||
}: {
|
||
label: string;
|
||
id: string;
|
||
onChange: (id: string) => void;
|
||
plants: PlantDto[];
|
||
}) {
|
||
const plant = plants.find((p) => p.id === id);
|
||
return (
|
||
<section className="parent-card">
|
||
<label>
|
||
{label}
|
||
<select
|
||
aria-label={label}
|
||
value={id}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
>
|
||
<option value="">Choose a saved plant</option>
|
||
{plants.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.name} · {p.id.slice(0, 8)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
{plant ? (
|
||
<>
|
||
<PlantPreview
|
||
phenotype={plant.phenotype}
|
||
visualSeed={plant.visualSeed}
|
||
stage="flowering"
|
||
size={512}
|
||
/>
|
||
<h2>{plant.name}</h2>
|
||
<p>
|
||
{plant.phenotype.pigment} · {plant.phenotype.leafShape} ·{" "}
|
||
{plant.phenotype.leafPattern}
|
||
</p>
|
||
<span className="badge">
|
||
Generation {plant.generation} · {plant.rarity.tier}
|
||
</span>
|
||
<Link className="text-link" to={"/plants/" + plant.id}>
|
||
Inspect parent →
|
||
</Link>
|
||
</>
|
||
) : (
|
||
<div className="parent-empty">Select a parent to see its traits.</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
export function BreedingLab() {
|
||
const game = useGame();
|
||
const [search] = useSearchParams();
|
||
const parents = useQuery({ queryKey: ["parents"], queryFn: loadAllParents }),
|
||
[a, setA] = useState(search.get("parent") ?? ""),
|
||
[b, setB] = useState(""),
|
||
[cursor, setCursor] = useState<string | undefined>(),
|
||
history = useQuery({
|
||
queryKey: ["breeding", cursor],
|
||
queryFn: () => api.events(cursor),
|
||
}),
|
||
client = useQueryClient();
|
||
const key = useRef<{ pair: string; id: string } | null>(null);
|
||
const cross = useMutation({
|
||
mutationFn: api.cross,
|
||
onSuccess: () => {
|
||
void client.invalidateQueries({ queryKey: ["game"] });
|
||
void client.invalidateQueries({ queryKey: ["parents"] });
|
||
void client.invalidateQueries({ queryKey: ["plants"] });
|
||
void client.invalidateQueries({ queryKey: ["collection-stats"] });
|
||
void client.invalidateQueries({ queryKey: ["breeding"] });
|
||
},
|
||
});
|
||
const resultHeading = useRef<HTMLHeadingElement>(null);
|
||
useEffect(() => {
|
||
if (cross.data) resultHeading.current?.focus();
|
||
}, [cross.data]);
|
||
const list = parents.data ?? [],
|
||
left = list.find((p) => p.id === a),
|
||
right = list.find((p) => p.id === b);
|
||
let message = "Choose two saved parents to check compatibility.",
|
||
compatible = false;
|
||
if (left && right) {
|
||
if (a === b) message = "Select two different parent plants.";
|
||
else {
|
||
try {
|
||
compatible = canBreed(left.speciesId, right.speciesId).compatible;
|
||
} catch {
|
||
compatible = false;
|
||
}
|
||
message = compatible
|
||
? "Compatible velvetleaf pair. The server will choose and save the child."
|
||
: "These species have no approved compatibility or hybrid rule.";
|
||
}
|
||
}
|
||
function choose(side: "A" | "B", id: string) {
|
||
if (side === "A") setA(id);
|
||
else setB(id);
|
||
key.current = null;
|
||
cross.reset();
|
||
}
|
||
function breed() {
|
||
const pair = a + ":" + b;
|
||
if (!key.current || key.current.pair !== pair)
|
||
key.current = { pair, id: newRequestId() };
|
||
cross.mutate({
|
||
parentAId: a,
|
||
parentBId: b,
|
||
idempotencyKey: key.current.id,
|
||
});
|
||
}
|
||
return (
|
||
<>
|
||
<section className="lab-heading">
|
||
<div>
|
||
<p className="eyebrow">The next generation</p>
|
||
<h1>Let their traits meet.</h1>
|
||
<p>
|
||
Choose two saved plants. Inheritance, mutations and the child's
|
||
identity are decided once by the server.
|
||
</p>
|
||
</div>
|
||
<Link className="button-link secondary" to="/lab">
|
||
Advanced genetics →
|
||
</Link>
|
||
</section>
|
||
{parents.isPending ? (
|
||
<QueryMessage loading />
|
||
) : parents.error ? (
|
||
<QueryMessage
|
||
error={parents.error}
|
||
retry={() => void parents.refetch()}
|
||
/>
|
||
) : !list.length ? (
|
||
<div className="empty">
|
||
<h2>No saved parents yet</h2>
|
||
<p>
|
||
Grow and harvest two plants in your greenhouse. Breeding unlocks at
|
||
level 3 and costs {game.breedCost} coins; both parents are kept.
|
||
</p>
|
||
<Link className="text-link" to="/">
|
||
Open greenhouse →
|
||
</Link>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="breeding-pair">
|
||
<Parent
|
||
label="Parent A"
|
||
id={a}
|
||
onChange={(id) => choose("A", id)}
|
||
plants={list}
|
||
/>
|
||
<div className="cross-symbol" aria-hidden="true">
|
||
×
|
||
</div>
|
||
<Parent
|
||
label="Parent B"
|
||
id={b}
|
||
onChange={(id) => choose("B", id)}
|
||
plants={list}
|
||
/>
|
||
</div>
|
||
<section className="cross-controls">
|
||
<p role="status">{message}</p>
|
||
<p>
|
||
{game.player.level < 3
|
||
? "Reach level 3 to unlock breeding."
|
||
: game.player.coins < game.breedCost
|
||
? "Not enough coins for this cross."
|
||
: `${game.breedCost} coins per cross · instant offspring · parents kept.`}
|
||
</p>
|
||
<button
|
||
onClick={breed}
|
||
disabled={
|
||
!compatible ||
|
||
cross.isPending ||
|
||
!!cross.data ||
|
||
game.player.level < 3 ||
|
||
game.player.coins < game.breedCost ||
|
||
game.pendingWork
|
||
}
|
||
>
|
||
{cross.isPending
|
||
? "Crossing…"
|
||
: cross.data
|
||
? "Cross saved"
|
||
: "Breed selected parents"}
|
||
</button>
|
||
{cross.error && (
|
||
<p className="error-text" role="alert">
|
||
{cross.error.message} Retry uses the same request key.
|
||
</p>
|
||
)}
|
||
{cross.data && (
|
||
<button
|
||
className="secondary"
|
||
onClick={() => {
|
||
key.current = null;
|
||
cross.reset();
|
||
}}
|
||
>
|
||
Start another cross
|
||
</button>
|
||
)}
|
||
</section>
|
||
</>
|
||
)}
|
||
{cross.data && (
|
||
<section className="offspring-card" aria-labelledby="offspring-title">
|
||
<PlantPreview
|
||
phenotype={cross.data.plant.phenotype}
|
||
visualSeed={cross.data.plant.visualSeed}
|
||
stage="flowering"
|
||
size={128}
|
||
/>
|
||
<div>
|
||
<p className="eyebrow">A new discovery</p>
|
||
<p role="status" className="saved-notice">
|
||
Cross saved to your collection.
|
||
</p>
|
||
<h2 id="offspring-title" ref={resultHeading} tabIndex={-1}>
|
||
{cross.data.plant.name}
|
||
</h2>
|
||
<p>
|
||
{cross.data.plant.phenotype.pigment} ·{" "}
|
||
{cross.data.plant.phenotype.leafPattern} ·{" "}
|
||
{cross.data.plant.rarity.tier}
|
||
</p>
|
||
<p>
|
||
{cross.data.event.mutationEvents.length
|
||
? cross.data.event.mutationEvents.length +
|
||
" mutation events recorded."
|
||
: "No mutations in this cross."}
|
||
</p>
|
||
<Link className="text-link" to={"/plants/" + cross.data.plant.id}>
|
||
Inspect child & lineage →
|
||
</Link>
|
||
</div>
|
||
</section>
|
||
)}
|
||
<section className="breeding-history">
|
||
<div className="section-heading">
|
||
<h2>The breeding journal</h2>
|
||
{cursor && (
|
||
<button className="secondary" onClick={() => setCursor(undefined)}>
|
||
Latest crosses
|
||
</button>
|
||
)}
|
||
</div>
|
||
{history.isPending ? (
|
||
<QueryMessage loading />
|
||
) : history.error ? (
|
||
<QueryMessage
|
||
error={history.error}
|
||
retry={() => void history.refetch()}
|
||
/>
|
||
) : history.data?.items.length ? (
|
||
<>
|
||
<ol>
|
||
{history.data.items.map((event) => (
|
||
<li key={event.id}>
|
||
<div>
|
||
<Link to={"/plants/" + event.parentAId}>
|
||
Parent {event.parentAId.slice(0, 8)}
|
||
</Link>
|
||
<span> × </span>
|
||
<Link to={"/plants/" + event.parentBId}>
|
||
Parent {event.parentBId.slice(0, 8)}
|
||
</Link>
|
||
</div>
|
||
<Link to={"/plants/" + event.childId}>View child →</Link>
|
||
<time dateTime={event.createdAt}>
|
||
{new Date(event.createdAt).toLocaleString("en-GB")}
|
||
</time>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
{history.data.nextCursor && (
|
||
<button
|
||
className="secondary"
|
||
onClick={() => setCursor(history.data!.nextCursor!)}
|
||
>
|
||
Older crosses
|
||
</button>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="empty">
|
||
<h3>The first cross is still to come</h3>
|
||
<p>Every saved offspring will leave a trace here.</p>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</>
|
||
);
|
||
}
|