feat: add batch size selector for hiring adventurers

Closes #12
This commit is contained in:
2026-03-07 13:35:18 -08:00
committed by Naomi Carrigan
parent cddf4fa692
commit 3856dda734
3 changed files with 99 additions and 14 deletions
@@ -12,20 +12,48 @@ const CLASS_ICONS: Record<string, string> = {
paladin: "🛡️",
};
const adventurerCost = (adventurer: Adventurer): number =>
Math.ceil(10 * Math.pow(1.15, adventurer.count));
type BatchSize = 1 | 5 | 10 | 25 | 100 | "max";
const BATCH_OPTIONS: BatchSize[] = [1, 5, 10, 25, 100, "max"];
const computeBatchCost = (adventurer: Adventurer, quantity: number): number => {
let total = 0;
for (let i = 0; i < quantity; i++) {
total += 10 * Math.pow(1.15, adventurer.count + i);
}
return total;
};
const computeMaxAffordable = (adventurer: Adventurer, gold: number): number => {
let total = 0;
let quantity = 0;
for (let i = 0; i < 100_000; i++) {
const cost = 10 * Math.pow(1.15, adventurer.count + i);
if (total + cost > gold) break;
total += cost;
quantity++;
}
return quantity;
};
interface AdventurerCardProps {
adventurer: Adventurer;
currentGold: number;
batchSize: BatchSize;
unlockHint?: string | undefined;
formatNumber: (n: number) => string;
}
const AdventurerCard = ({ adventurer, currentGold, unlockHint, formatNumber }: AdventurerCardProps): React.JSX.Element => {
const AdventurerCard = ({ adventurer, currentGold, batchSize, unlockHint, formatNumber }: AdventurerCardProps): React.JSX.Element => {
const { buyAdventurer } = useGame();
const cost = adventurerCost(adventurer);
const canAfford = currentGold >= cost;
const resolvedQuantity =
batchSize === "max" ? computeMaxAffordable(adventurer, currentGold) : batchSize;
const cost = computeBatchCost(adventurer, resolvedQuantity);
const canAfford = resolvedQuantity > 0 && currentGold >= cost;
const handleBuy = (): void => {
buyAdventurer(adventurer.id, resolvedQuantity);
};
return (
<div className={`adventurer-card ${!adventurer.unlocked ? "locked" : ""}`}>
@@ -41,10 +69,12 @@ const AdventurerCard = ({ adventurer, currentGold, unlockHint, formatNumber }: A
<button
className="buy-button"
disabled={!canAfford || !adventurer.unlocked}
onClick={() => { buyAdventurer(adventurer.id); }}
onClick={handleBuy}
type="button"
>
{adventurer.unlocked ? `🪙 ${formatNumber(cost)}` : "🔒 Locked"}
{adventurer.unlocked
? `🪙 ${formatNumber(Math.ceil(cost))}${batchSize === "max" && resolvedQuantity > 0 ? ` (×${resolvedQuantity})` : ""}`
: "🔒 Locked"}
</button>
{!adventurer.unlocked && unlockHint && (
<p className="unlock-hint">📜 Complete: {unlockHint}</p>
@@ -56,6 +86,7 @@ const AdventurerCard = ({ adventurer, currentGold, unlockHint, formatNumber }: A
export const AdventurerPanel = (): React.JSX.Element => {
const { state, formatNumber } = useGame();
const [showLocked, setShowLocked] = useState(true);
const [batchSize, setBatchSize] = useState<BatchSize>(1);
if (!state) return <section className="panel"><p>Loading...</p></section>;
@@ -81,11 +112,24 @@ export const AdventurerPanel = (): React.JSX.Element => {
onToggle={() => { setShowLocked((v) => !v); }}
/>
</div>
<div className="batch-selector">
{BATCH_OPTIONS.map((option) => (
<button
key={option}
className={`batch-button ${batchSize === option ? "active" : ""}`}
onClick={() => { setBatchSize(option); }}
type="button"
>
{option === "max" ? "xMax" : `x${option}`}
</button>
))}
</div>
<div className="adventurer-list">
{visible.map((adventurer) => (
<AdventurerCard
key={adventurer.id}
adventurer={adventurer}
batchSize={batchSize}
currentGold={state.resources.gold}
unlockHint={adventurerUnlockHints.get(adventurer.id)}
formatNumber={formatNumber}
+18 -7
View File
@@ -39,8 +39,8 @@ interface GameContextValue {
error: string | null;
/** Click the crystal to earn gold */
handleClick: () => void;
/** Buy an adventurer */
buyAdventurer: (adventurerId: string) => void;
/** Buy one or more of an adventurer tier; quantity > 1 buys in batch */
buyAdventurer: (adventurerId: string, quantity: number) => void;
/** Buy an upgrade */
buyUpgrade: (upgradeId: string) => void;
/** Purchase a buyable equipment item */
@@ -439,20 +439,31 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
});
}, []);
const buyAdventurer = useCallback((adventurerId: string) => {
const buyAdventurer = useCallback((adventurerId: string, quantity: number) => {
setState((prev) => {
if (!prev) return prev;
const adventurer = prev.adventurers.find((a) => a.id === adventurerId);
if (!adventurer || !adventurer.unlocked) return prev;
const cost = 10 * Math.pow(1.15, adventurer.count);
if (prev.resources.gold < cost) return prev;
let gold = prev.resources.gold;
let count = adventurer.count;
let purchased = 0;
for (let i = 0; i < quantity; i++) {
const cost = 10 * Math.pow(1.15, count);
if (gold < cost) break;
gold -= cost;
count++;
purchased++;
}
if (purchased === 0) return prev;
return {
...prev,
resources: { ...prev.resources, gold: prev.resources.gold - cost },
resources: { ...prev.resources, gold },
adventurers: prev.adventurers.map((a) =>
a.id === adventurerId ? { ...a, count: a.count + 1 } : a,
a.id === adventurerId ? { ...a, count } : a,
),
};
});
+30
View File
@@ -248,6 +248,36 @@ body {
}
/* ===================== ADVENTURERS ===================== */
.batch-selector {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.batch-button {
background: var(--colour-surface);
border: 1px solid var(--colour-border);
border-radius: var(--radius);
color: var(--colour-text);
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
padding: 0.3rem 0.6rem;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.batch-button:hover {
border-color: var(--colour-accent);
color: var(--colour-accent);
}
.batch-button.active {
background: var(--colour-accent);
border-color: var(--colour-accent);
color: #fff;
}
.adventurer-list {
display: flex;
flex-direction: column;