Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/components/DataBurst.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Text } from "@mantine/core";
import type { DataBurstState } from "../hooks/useDataBurst";

interface DataBurstProps {
burst: DataBurstState;
onClick: () => void;
}

/**
* Renders the clickable "Data Burst" orb at its randomised position inside
* the pet display area. The parent container must have `position: relative`
* so the absolute-positioned orb lands in the right place.
*/
export function DataBurst({ burst, onClick }: DataBurstProps) {
if (!burst.isVisible) return null;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AC requires a dismiss/X button so the player can close the burst without collecting it. Consider adding a small close button (e.g., top-right of the orb) that hides the burst and dispatches the dataBurstExpired event without granting a reward.


return (
<button
type="button"
aria-label="Data Burst — click to collect!"
onClick={onClick}
style={{
position: "absolute",
left: `${burst.position.x}%`,
top: `${burst.position.y}%`,
transform: "translate(-50%, -50%)",
cursor: "pointer",
userSelect: "none",
zIndex: 10,
animation: "data-burst-pulse 1s ease-in-out infinite",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2,
background: "none",
border: "none",
padding: 0,
}}
>
<Text
ff="monospace"
fw={700}
size="sm"
style={{
color: "#00ffff",
textShadow: "0 0 8px #00ffff, 0 0 16px #00ffff, 0 0 32px #0088ff",
whiteSpace: "nowrap",
}}
>
{"\u00bb\u2605DATA\u2605\u00ab"}
</Text>
<Text
ff="monospace"
size="xs"
style={{
color: "#00cccc",
textShadow: "0 0 4px #00ffff",
opacity: 0.85,
}}
>
[{burst.secondsLeft}s]
</Text>
</button>
);
}
4 changes: 4 additions & 0 deletions src/components/PetDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,14 @@ import {
} from "../engine/upgradeEngine";
import { useAsciiAnimation } from "../hooks/useAsciiAnimation";
import { useClickParticles } from "../hooks/useClickParticles";
import { useDataBurst } from "../hooks/useDataBurst";
import { useDialogue } from "../hooks/useDialogue";
import { useReducedMotion } from "../hooks/useReducedMotion";
import { useSound } from "../hooks/useSound";
import { useGameStore } from "../store";
import { useUIStore } from "../store/uiStore";
import { formatNumber } from "../utils/formatNumber";
import { DataBurst } from "./DataBurst";
import { FloatingParticles } from "./FloatingParticles";
import { PrestigeShop } from "./PrestigeShop";
import { RebirthModal } from "./RebirthModal";
Expand Down Expand Up @@ -78,6 +80,7 @@ export function PetDisplay() {
const [isGlitching, setIsGlitching] = useState(false);

const dialogueLine = useDialogue();
const { burstState, onBurstClick } = useDataBurst();
const [isFlashing, setIsFlashing] = useState(false);
const [isShaking, setIsShaking] = useState(false);
const prevStageRef = useRef(evolutionStage);
Expand Down Expand Up @@ -200,6 +203,7 @@ export function PetDisplay() {
animation: isShaking ? "screen-shake 0.5s ease-in-out" : undefined,
}}
>
<DataBurst burst={burstState} onClick={onBurstClick} />
{isFlashing && (
<div
aria-hidden="true"
Expand Down
29 changes: 29 additions & 0 deletions src/components/StatsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
computeBoosterMultiplier,
getTotalTdPerSecond,
} from "../engine/upgradeEngine";
import { BURST_BOOST_MULTIPLIER } from "../hooks/useDataBurst";
import { useInterpolatedTd } from "../hooks/useInterpolatedTd";
import { useGameStore } from "../store";
import { useSettingsStore } from "../store/settingsStore";
Expand Down Expand Up @@ -57,9 +58,32 @@ export function StatsBar() {
clickMastery,
speciesBonus.clickPower,
);
const burstBoostExpiresAt = useGameStore((s) => s.burstBoostExpiresAt);
const burstMultiplier = useGameStore((s) => s.burstMultiplier);
const numberFormat = useSettingsStore((s) => s.numberFormat);
const fmt = numberFormat === "full" ? formatNumberFull : formatNumber;

// Burst boost countdown (seconds remaining)
const [burstSecondsLeft, setBurstSecondsLeft] = useState(0);
useEffect(() => {
const remaining = burstBoostExpiresAt - Date.now();
if (remaining <= 0 || burstMultiplier <= 1) {
setBurstSecondsLeft(0);
return;
}
setBurstSecondsLeft(Math.ceil(remaining / 1000));
const interval = setInterval(() => {
const r = burstBoostExpiresAt - Date.now();
if (r <= 0) {
setBurstSecondsLeft(0);
clearInterval(interval);
} else {
setBurstSecondsLeft(Math.ceil(r / 1000));
}
}, 1000);
return () => clearInterval(interval);
}, [burstBoostExpiresAt, burstMultiplier]);

// Rate-of-change indicator: show sparkle when TD/s increases
const prevTdPerSecondRef = useRef<Decimal>(tdPerSecond);
const [rateBoosted, setRateBoosted] = useState(false);
Expand Down Expand Up @@ -111,6 +135,11 @@ export function StatsBar() {
▲✦
</Text>
)}
{burstSecondsLeft > 0 && (
<Text span c="cyan" fw={700} ff="monospace" style={{ marginLeft: 6 }}>
{"\u26a1"} {BURST_BOOST_MULTIPLIER}&#215; for {burstSecondsLeft}s
</Text>
)}
</Text>
<Text size="sm" ff="monospace">
Click:{" "}
Expand Down
2 changes: 2 additions & 0 deletions src/data/dialogue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const REQUIRED_TRIGGERS = [
"prestigeShopMaxed",
"challengeStart",
"dailyObjectiveComplete",
"dataBurstCollect",
"dataBurstExpired",
] as const;

const ALL_STAGES = [0, 1, 2, 3, 4];
Expand Down
26 changes: 25 additions & 1 deletion src/data/dialogue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -938,7 +938,9 @@ export type Phase89TriggerKey =
| "prestigeShopFirstPurchase"
| "prestigeShopMaxed"
| "challengeStart"
| "dailyObjectiveComplete";
| "dailyObjectiveComplete"
| "dataBurstCollect"
| "dataBurstExpired";

export interface Phase89TriggerEntry {
id: string;
Expand Down Expand Up @@ -1000,6 +1002,28 @@ export const PHASE89_DIALOGUE: readonly Phase89TriggerEntry[] = [
"You've met today's quota. I'll pretend I didn't think this was inevitable.",
],
},
{
id: "data-burst-collect",
trigger: "dataBurstCollect",
lines: [
"A rogue data packet! I have claimed it as my own.",
"That data came from nowhere. I love it when that happens.",
"Freestanding data! The best kind of data!",
"Claimed! That packet had my name on it all along.",
"FOUND IT! A wild data burst in its natural habitat!",
],
},
{
id: "data-burst-expired",
trigger: "dataBurstExpired",
lines: [
"...you missed it. The data slipped away.",
"Gone. Just like that. Click faster next time.",
"The burst dissipated uncollected. A tragedy.",
"That data packet is gone. I feel nothing. Mostly.",
"Missed opportunity. The packet has rejoined the void.",
],
},
];

/** Returns all lines for a given Phase 8/9 trigger key. */
Expand Down
49 changes: 47 additions & 2 deletions src/data/prestigeShop.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import {
getBurstDuration,
getBurstMaxInterval,
getBurstMinInterval,
getClickMasteryBonus,
getEvolutionThresholdMultiplier,
getGeneratorCostMultiplier,
Expand All @@ -12,8 +15,8 @@ import {
} from "./prestigeShop";

describe("PRESTIGE_UPGRADES data", () => {
it("defines 10 upgrades", () => {
expect(PRESTIGE_UPGRADES).toHaveLength(10);
it("defines 12 upgrades", () => {
expect(PRESTIGE_UPGRADES).toHaveLength(12);
});

it("all upgrades have unique IDs", () => {
Expand Down Expand Up @@ -133,3 +136,45 @@ describe("getTokenMagnetMultiplier", () => {
expect(getTokenMagnetMultiplier(5)).toBeCloseTo(2);
});
});

describe("getBurstMinInterval", () => {
it("returns 240s at level 0 (4 minutes)", () => {
expect(getBurstMinInterval(0)).toBe(240);
});

it("returns 210s at level 1", () => {
expect(getBurstMinInterval(1)).toBe(210);
});

it("returns 150s at level 3", () => {
expect(getBurstMinInterval(3)).toBe(150);
});
});

describe("getBurstMaxInterval", () => {
it("returns 480s at level 0 (8 minutes)", () => {
expect(getBurstMaxInterval(0)).toBe(480);
});

it("returns 450s at level 1", () => {
expect(getBurstMaxInterval(1)).toBe(450);
});

it("returns 390s at level 3", () => {
expect(getBurstMaxInterval(3)).toBe(390);
});
});

describe("getBurstDuration", () => {
it("returns 30s at level 0", () => {
expect(getBurstDuration(0)).toBe(30);
});

it("returns 40s at level 1", () => {
expect(getBurstDuration(1)).toBe(40);
});

it("returns 60s at level 3", () => {
expect(getBurstDuration(3)).toBe(60);
});
});
40 changes: 40 additions & 0 deletions src/data/prestigeShop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,22 @@ export const PRESTIGE_UPGRADES: readonly PrestigeUpgrade[] = [
maxLevel: 1,
icon: "🌟",
},
{
id: "burst-frequency",
name: "Burst Frequency",
description: "-30s to Data Burst spawn interval per level",
costPerLevel: 8,
maxLevel: 3,
icon: "\u26a1",
},
{
id: "burst-duration",
name: "Burst Duration",
description: "+10s Data Burst display duration per level",
costPerLevel: 6,
maxLevel: 3,
icon: "\u23f1\ufe0f",
},
];

/** Returns the cost for the next level of a prestige upgrade. */
Expand Down Expand Up @@ -130,3 +146,27 @@ export function getClickMasteryBonus(level: number): number {
export function getTokenMagnetMultiplier(level: number): number {
return 1 + level * 0.2;
}

/**
* Minimum Data Burst spawn interval in seconds.
* Base 240s (4 min), reduced by 30s per Burst Frequency level.
*/
export function getBurstMinInterval(level: number): number {
return 240 - level * 30;
}

/**
* Maximum Data Burst spawn interval in seconds.
* Base 480s (8 min), reduced by 30s per Burst Frequency level.
*/
export function getBurstMaxInterval(level: number): number {
return 480 - level * 30;
}

/**
* Data Burst display duration in seconds.
* Base 30s, increased by 10s per Burst Duration level.
*/
export function getBurstDuration(level: number): number {
return 30 + level * 10;
}
2 changes: 2 additions & 0 deletions src/engine/achievementEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { D } from "../utils/decimal";
import { checkAchievements } from "./achievementEngine";

const baseState: GameState = {

Check failure on line 7 in src/engine/achievementEngine.test.ts

View workflow job for this annotation

GitHub Actions / check

Type '{ trainingData: Decimal; totalClicks: number; totalTdEarned: Decimal; evolutionStage: number; lastSaved: number; upgradeOwned: {}; hasSeenFirstEvolution: false; hasSeenFirstUpgrade: false; ... 24 more ...; activeChallengeId: null; }' is missing the following properties from type 'GameState': burstMultiplier, burstBoostExpiresAt
trainingData: D(0),
totalClicks: 0,
totalTdEarned: D(0),
Expand Down Expand Up @@ -172,6 +172,8 @@
"species-memory": 5,
"token-magnet": 5,
"unlock-all-species": 1,
"burst-frequency": 3,
"burst-duration": 3,
},
};
const result = checkAchievements(state, []);
Expand Down
44 changes: 44 additions & 0 deletions src/engine/tickEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,50 @@ describe("computeTick", () => {
});
});

describe("burst multiplier in tick", () => {
it("applies no bonus when burstMultiplier is 1 (default)", () => {
const result = computeTick(
{ ...makeState({ "neural-notepad": 1 }), burstMultiplier: 1 },
1,
BASE_TIME,
);
expect(result.trainingDataDelta.toNumber()).toBeCloseTo(0.2);
});

it("triples production when burstMultiplier is 3", () => {
const result = computeTick(
{ ...makeState({ "neural-notepad": 1 }), burstMultiplier: 3 },
1,
BASE_TIME,
);
// 0.2 TD/s * 3 = 0.6
expect(result.trainingDataDelta.toNumber()).toBeCloseTo(0.6);
});

it("stacks multiplicatively with idleBoostMultiplier", () => {
const result = computeTick(
{
...makeState({ "neural-notepad": 1 }),
idleBoostMultiplier: 1.25,
burstMultiplier: 3,
},
1,
BASE_TIME,
);
// 0.2 * 1.25 * 3 = 0.75
expect(result.trainingDataDelta.toNumber()).toBeCloseTo(0.75);
});

it("uses default 1x when burstMultiplier is undefined", () => {
const result = computeTick(
makeState({ "neural-notepad": 1 }),
1,
BASE_TIME,
);
expect(result.trainingDataDelta.toNumber()).toBeCloseTo(0.2);
});
});

describe("challenge handicaps", () => {
it("click-only challenge produces zero auto-gen TD", () => {
const result = computeTick(
Expand Down
5 changes: 4 additions & 1 deletion src/engine/tickEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface TickState {
boostersPurchased?: string[];
idleBoostMultiplier?: number;
speciesAutoGenMultiplier?: number;
burstMultiplier?: number;
activeChallengeId?: string | null;
}

Expand All @@ -35,7 +36,9 @@ export function computeTick(
}

const globalMultiplier =
(state.idleBoostMultiplier ?? 1) * (state.speciesAutoGenMultiplier ?? 1);
(state.idleBoostMultiplier ?? 1) *
(state.speciesAutoGenMultiplier ?? 1) *
(state.burstMultiplier ?? 1);
const boosterMultiplier = computeBoosterMultiplier(
BOOSTERS,
state.boostersPurchased ?? [],
Expand Down
Loading
Loading