diff --git a/web/src/components/MetroGame.tsx b/web/src/components/MetroGame.tsx index a92f26d..b00fedc 100644 --- a/web/src/components/MetroGame.tsx +++ b/web/src/components/MetroGame.tsx @@ -6,6 +6,13 @@ import { type HealthResponse, type WorldResponse, } from "../lib/api"; +import { + applyOfflineDecay, + initialPetState, + reducePet, + type PetState, +} from "../lib/pet-state"; +import { loadPet, savePet } from "../lib/storage"; type LoadState = "loading" | "ready" | "error"; @@ -26,6 +33,8 @@ export default function MetroGame() { const [health, setHealth] = useState(null); const [world, setWorld] = useState(null); const [selectedStation, setSelectedStation] = useState("A01"); + const [companionMode, setCompanionMode] = useState(false); + const [pet, setPet] = useState(initialPetState); const [error, setError] = useState(null); useEffect(() => { @@ -79,6 +88,22 @@ export default function MetroGame() { }; }, [selectedStation]); + useEffect(() => { + void loadPet() + .then((saved) => { + if (saved) setPet(applyOfflineDecay(saved)); + }) + .catch(() => undefined); + }, []); + + function care(action: Parameters[1]) { + setPet((current) => { + const next = reducePet(current, action); + void savePet(next); + return next; + }); + } + function retry() { window.location.reload(); } @@ -151,6 +176,52 @@ export default function MetroGame() { Something small is tucked away near {selectedStation}. It will wake when the first station has a name.

+ + {companionMode && ( +
+ +

+ {pet.name} is near {pet.homeStation}. +

+
+ Hunger + +
+
+ Energy + +
+
+ Happy + +
+
+ + + + + +
+
+ )}
{state === "loading" &&

Checking the local signal...

} {state === "ready" && ( diff --git a/web/src/lib/pet-state.test.ts b/web/src/lib/pet-state.test.ts new file mode 100644 index 0000000..f9e6f05 --- /dev/null +++ b/web/src/lib/pet-state.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { + applyOfflineDecay, + initialPetState, + reducePet, + type PetState, +} from "./pet-state"; + +describe("pet state", () => { + it("hatches and responds to care", () => { + const hatched = reducePet(initialPetState, { type: "hatch" }); + const fed = reducePet(hatched, { type: "feed" }); + expect(fed.growthStage).toBe("baby"); + expect(fed.hunger).toBe(7); + }); + + it("keeps needs within the seven-point range", () => { + const full = reducePet(initialPetState, { type: "feed" }); + const empty = Array.from({ length: 20 }).reduce( + (state) => reducePet(state, { type: "play" }), + full, + ); + expect(empty.energy).toBe(0); + expect(empty.curiosity).toBe(7); + }); + + it("caps offline decay", () => { + const lastCareAt = new Date("2026-08-01T00:00:00Z").toISOString(); + const state = { ...initialPetState, lastCareAt }; + const decayed = applyOfflineDecay( + state, + Date.parse("2026-08-20T00:00:00Z"), + ); + expect(decayed.hunger).toBe(2); + expect(decayed.energy).toBe(2); + }); +}); diff --git a/web/src/lib/pet-state.ts b/web/src/lib/pet-state.ts new file mode 100644 index 0000000..f7764b1 --- /dev/null +++ b/web/src/lib/pet-state.ts @@ -0,0 +1,79 @@ +export interface PetState { + version: 1; + species: "trackling"; + name: string; + growthStage: "egg" | "baby"; + homeStation: string; + hunger: number; + energy: number; + happiness: number; + curiosity: number; + lastCareAt: string; +} + +export type PetAction = + | { type: "hatch" } + | { type: "feed" } + | { type: "pet" } + | { type: "rest" } + | { type: "play" } + | { type: "setHomeStation"; station: string }; + +export const initialPetState: PetState = { + version: 1, + species: "trackling", + name: "Pip", + growthStage: "egg", + homeStation: "A01", + hunger: 5, + energy: 5, + happiness: 4, + curiosity: 1, + lastCareAt: new Date(0).toISOString(), +}; + +function change(value: number, amount: number) { + return Math.max(0, Math.min(7, value + amount)); +} + +export function reducePet(state: PetState, action: PetAction): PetState { + const lastCareAt = new Date().toISOString(); + switch (action.type) { + case "hatch": + return { ...state, growthStage: "baby", lastCareAt }; + case "feed": + return { + ...state, + hunger: change(state.hunger, 2), + energy: change(state.energy, 1), + lastCareAt, + }; + case "pet": + return { ...state, happiness: change(state.happiness, 2), lastCareAt }; + case "rest": + return { ...state, energy: change(state.energy, 3), lastCareAt }; + case "play": + return { + ...state, + energy: change(state.energy, -1), + happiness: change(state.happiness, 1), + curiosity: change(state.curiosity, 1), + lastCareAt, + }; + case "setHomeStation": + return { ...state, homeStation: action.station, lastCareAt }; + } +} + +export function applyOfflineDecay(state: PetState, now = Date.now()): PetState { + const elapsedHours = + Math.max(0, now - Date.parse(state.lastCareAt)) / 3_600_000; + const ticks = Math.min(3, Math.floor(elapsedHours)); + if (ticks === 0) return state; + return { + ...state, + hunger: change(state.hunger, -ticks), + energy: change(state.energy, -ticks), + happiness: change(state.happiness, -Math.floor(ticks / 2)), + }; +} diff --git a/web/src/lib/storage.ts b/web/src/lib/storage.ts new file mode 100644 index 0000000..8d921a5 --- /dev/null +++ b/web/src/lib/storage.ts @@ -0,0 +1,42 @@ +import type { PetState } from "./pet-state"; + +const databaseName = "trackling"; +const storeName = "state"; +const stateKey = "pet"; + +function openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(databaseName, 1); + request.onupgradeneeded = () => request.result.createObjectStore(storeName); + request.onsuccess = () => resolve(request.result); + request.onerror = () => + reject(request.error ?? new Error("Unable to open pet storage")); + }); +} + +export async function loadPet(): Promise { + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const request = database + .transaction(storeName, "readonly") + .objectStore(storeName) + .get(stateKey); + request.onsuccess = () => + resolve((request.result as PetState | undefined) ?? null); + request.onerror = () => + reject(request.error ?? new Error("Unable to read pet storage")); + }); +} + +export async function savePet(state: PetState): Promise { + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const request = database + .transaction(storeName, "readwrite") + .objectStore(storeName) + .put(state, stateKey); + request.onsuccess = () => resolve(); + request.onerror = () => + reject(request.error ?? new Error("Unable to save pet storage")); + }); +} diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 4ee5db4..da59683 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -280,6 +280,66 @@ h2 { background: #f0a35b; color: #101827; } +.companion-toggle { + width: 100%; + padding: 11px 14px; + border: 1px solid #f0a35b; + color: #101827; + background: #f0a35b; + cursor: pointer; +} +.companion-box { + margin-top: 18px; + padding: 16px; + border: 1px solid #3c5261; + background: #0d1724; +} +.companion-face { + margin-bottom: 12px; + color: #f0a35b; + font: + 1.5rem ui-monospace, + monospace; + text-align: center; +} +.companion-box p { + margin-bottom: 14px; + color: #c6d0d3; + font-size: 0.9rem; +} +.need-row { + display: grid; + grid-template-columns: 62px 1fr; + gap: 10px; + align-items: center; + margin: 7px 0; + color: #9eafb7; + font: + 0.7rem ui-monospace, + monospace; + text-transform: uppercase; +} +.need-row meter { + width: 100%; + height: 8px; + accent-color: #f0a35b; +} +.care-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 16px; +} +.care-actions button { + padding: 6px 8px; + border: 1px solid #3c5261; + color: #f5ebd7; + background: transparent; + cursor: pointer; +} +.care-actions button:hover { + border-color: #f0a35b; +} .fine-print { margin-bottom: 0; font-size: 0.75rem;