diff --git a/AGENTS.md b/AGENTS.md index e7d40832..cff7ffb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Full per-package lint/test/build matrix and single-test syntax: see [CLAUDE.md]( Mechanical checks over prose, where they exist: -- ESLint per package (`frontend`, `shared`, `website`, `mobile`), plus a custom CSS-naming check in `frontend` (`lint:css`). +- ESLint per package (`frontend`, `shared`, `website`, `mobile`), plus a custom CSS-naming check in `frontend` (`lint:css`). `shared`'s config carries a `no-restricted-imports` boundary: nothing in `@shared/core` may import through a frontend path alias (`@components/*`, `@hooks/*`, …) or a platform-only module (`react-router-dom`, `react-native`, `next/*`), since the package is consumed by both web and React Native. - Golden test vectors (`contracts/test-vectors/{battle,xp,equipment}.json`), run by Anchor, `indexer-go`'s `combat_golden_test.go` and `equipment_golden_test.go`, and `@cryptopets/protocol`'s `tests/combat/goldenVectors.test.ts` and `equipmentVectors.test.ts` (Vitest), are the cross-language enforcement for combat-simulator parity. `equipment.json` is read by the two live ports only, since the frozen Solana port predates equipment and never applies it. Anchor's frozen suite proves the vectors still describe what really settled on Solana; the two live ports prove they have not drifted from it. Hardhat no longer checks `battle.json` — that leg went with `CombatSim.sol`. - CI coverage workflow (`.github/workflows/coverage.yml`) runs frontend/backend/shared vitest coverage on every PR and posts a combined comment. The verifier workflow (`.github/workflows/verifier.yml`) replays a committed receipt corpus through the standalone verifier, and asserts a tampered corpus is rejected. -- There is no repo-wide `agents:check` or module-boundary lint yet. Rely on the per-package commands above and the golden vectors until one exists. +- There is no repo-wide `agents:check` yet, and the module-boundary lint above covers only `shared`'s outbound imports. Nothing checks the reverse direction (an app keeping platform-neutral code to itself), which is what put four hooks and a formatter in `frontend` until they were moved out. Rely on the per-package commands above and the golden vectors until something broader exists. diff --git a/CLAUDE.md b/CLAUDE.md index 9d1eeb13..06a8fcbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,7 +119,7 @@ What that adapter does NOT unify: `frontend/src/chains/ethereum/` (wagmi client, - **It does** → a headless controller hook in `frontend/src/hooks//`, and the component consumes that single hook and holds **no** `useState`/`useEffect` of its own. `battle` (27-line view over `useBattlePanel`), `breed` (73 over `useBreedPanel`), `marriage` (94 over `useMarriagePanel`). Each of those flows has real intermediate states: request, entropy reveal, settle, result, and for battle a mismatch reconciliation. - **It doesn't** → the component composes the shared hooks directly and keeps its form state local. `rename`, `level-up`, `train`, `defense` all open with the same preamble (`useChainCapabilities`, `usePetList`, `useNotifyError`, plus the action hook, plus `useFees` where the action costs money). These are one action over one selected pet; there is no interim state worth modelling. -Do not "fix" `useBattlePanel` for being 555 lines. Its own doc comment records the reasoning: selection and validation are tightly coupled (random-match and battle-start both touch validation), so one controller is the honest seam, and the genuinely separable concerns were already extracted to `useBattleOutcome` and `useResultDialogue`. If it does get touched, the thing worth consolidating is its six `useEffect`s, whose ordering is implicit, not its line count. +Do not "fix" `useBattlePanel` for being 511 lines. Its own doc comment records the reasoning: selection and validation are tightly coupled (random-match and battle-start both touch validation), so one controller is the honest seam, and the genuinely separable concerns were already extracted to `useResultDialogue` (still in `frontend/src/hooks/battle/`, since it maps dialogue turns onto view props) and `useBattleOutcome` (now `@shared/core`, along with `useLiveBattleAnimation` — both are platform-neutral, so mobile's battle scene gets them for free). If it does get touched, the thing worth consolidating is its six `useEffect`s, whose ordering is implicit, not its line count. ### Combat simulator: one frozen port, two live ones, and the vectors that hold them together The battle/combat logic began as four independent implementations. As of §L Phase 6 they are no longer peers, and the Solidity one is gone: diff --git a/bash.exe.stackdump b/bash.exe.stackdump deleted file mode 100644 index 4feb3613..00000000 --- a/bash.exe.stackdump +++ /dev/null @@ -1,9 +0,0 @@ -Stack trace: -Frame Function Args -000FFFFA380 00210062B0E (00210297158, 00210275E3E, 000FFFFA380, 000FFFF9280) -000FFFFA380 0021004846A (00000000000, 00000000000, 00000000000, 00000000004) -000FFFFA380 002100484A2 (00210297209, 000FFFFA238, 000FFFFA380, 00000000000) -000FFFFA380 002100D2FFE (00000000000, 00000000000, 00000000000, 00000000000) -000FFFFA380 002100D3125 (000FFFFA390, 00000000000, 00000000000, 00000000000) -001004F84B7 002100D46E5 (000FFFFA390, 00000000000, 00000000000, 00000000000) -End of stack trace diff --git a/frontend/src/components/chat/index.tsx b/frontend/src/components/chat/index.tsx index eed88f9f..056f080d 100644 --- a/frontend/src/components/chat/index.tsx +++ b/frontend/src/components/chat/index.tsx @@ -3,6 +3,8 @@ import clsx from 'clsx'; import { useNavigate } from 'react-router-dom'; import { CHAT_REACTIONS, + sameAccount, + shortAddress, useChainCapabilities, useChatMessages, useChatThreads, @@ -17,7 +19,6 @@ import PetArt from '@components/pet/pet-art'; import SessionGate from '@components/common/session-gate'; import Icon, { MarriageIcon } from '@components/ui/icon'; import { CHAT_WS_URL } from '../../config'; -import { sameAccount, shortAddress } from '@utils/address'; import { DASHBOARD_HOME } from '@constants/interactionRoutes'; import { Tones } from '@constants/tones'; import styles from './index.module.css'; diff --git a/frontend/src/components/leaderboard/index.tsx b/frontend/src/components/leaderboard/index.tsx index 6f080bcf..2c3a9a39 100644 --- a/frontend/src/components/leaderboard/index.tsx +++ b/frontend/src/components/leaderboard/index.tsx @@ -3,6 +3,8 @@ import clsx from 'clsx'; import { useNavigate } from 'react-router-dom'; import { getRarityColor, + sameAccount, + shortAddress, useChainCapabilities, useLeaderboard, usePlayerLeaderboard, @@ -14,7 +16,6 @@ import PetArt from '@components/pet/pet-art'; import Icon, { TrophyIcon } from '@components/ui/icon'; import { DASHBOARD_HOME } from '@constants/interactionRoutes'; import { Tones } from '@constants/tones'; -import { sameAccount, shortAddress } from '@utils/address'; import styles from './index.module.css'; /** Which ranking is showing. Pets is the default: it is the one with a pet in it. */ diff --git a/frontend/src/components/pet/collection/pet-gallery/parts/pet-card.tsx b/frontend/src/components/pet/collection/pet-gallery/parts/pet-card.tsx index 7442fe0a..83811d53 100644 --- a/frontend/src/components/pet/collection/pet-gallery/parts/pet-card.tsx +++ b/frontend/src/components/pet/collection/pet-gallery/parts/pet-card.tsx @@ -12,12 +12,12 @@ import { getXpPercent, type Pet, type EquippedItem, + type PetCooldownStatus, } from '@shared/core'; import { Tones } from '@constants/tones'; import Icon, { BattleIcon, SendIcon } from '@components/ui/icon'; import PetArt from '@components/pet/pet-art'; import EquippedBadges from '@components/pet/equipped-badges'; -import type { PetCooldownStatus } from '@hooks/usePetCooldowns'; import styles from '../index.module.css'; /** Four stat tiles derived from the pet's DNA properties. AGI has no backing in diff --git a/frontend/src/components/pet/interactions/panels/battle/battle-result-art.tsx b/frontend/src/components/pet/interactions/panels/battle/battle-result-art.tsx index ae3d0c90..4df733f4 100644 --- a/frontend/src/components/pet/interactions/panels/battle/battle-result-art.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/battle-result-art.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type { BattleOutcome } from './types'; +import type { BattleOutcome } from '@shared/core'; type Props = { outcome: BattleOutcome }; diff --git a/frontend/src/components/pet/interactions/panels/battle/battle-utils.ts b/frontend/src/components/pet/interactions/panels/battle/battle-utils.ts index 65f9efaa..c2b5b4ed 100644 --- a/frontend/src/components/pet/interactions/panels/battle/battle-utils.ts +++ b/frontend/src/components/pet/interactions/panels/battle/battle-utils.ts @@ -1,19 +1,3 @@ -import type { DialoguePetInput, OpponentPet, Pet } from '@shared/core'; - -/** Map a pet/opponent to the persona input the dialogue endpoint expects. */ -export const toDialoguePet = (pet: Pet | OpponentPet): DialoguePetInput => ({ - petId: pet.id, - name: pet.name, - level: pet.level, - rarity: pet.rarity, - dna: pet.dna.toString(), - winCount: pet.winCount, - lossCount: pet.lossCount, -}); - -/** Stable select value for an opponent (pet ids are not globally unique on Solana). */ -export const opponentKey = (owner: string, id: string) => `${owner}::${id}`; - export const VALIDATION_MESSAGE = 'Please select your pet and an opponent'; export const BATTLE_FAIL_MESSAGE = 'Failed to start battle. Please try again.'; /** Shown briefly when the client-side live-replay disagrees with the on-chain @@ -21,8 +5,5 @@ export const BATTLE_FAIL_MESSAGE = 'Failed to start battle. Please try again.'; * presentational, not a real error). */ export const MISMATCH_NOTICE_MESSAGE = 'The on-chain referee ruled differently — syncing the true result…'; -/** Personas captured at battle start, reused for the settle dialogue read. */ -export type BattlePersonas = { attacker: DialoguePetInput; defender: DialoguePetInput }; - /** win/loss/levelUp snapshot taken just before calling battle.mutate. */ export type PreBattleStats = { winCount: number; lossCount: number; level: number }; diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/battle-overlay.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/battle-overlay.tsx index 8ec57b79..1de0d68a 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/battle-overlay.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/battle-overlay.tsx @@ -7,13 +7,14 @@ import { getRarityColor, getRarityName, type Attrs, + type BattleOutcome, type DialogueTurn, type OpponentPet, type Pet, } from '@shared/core'; import BattleResultArt from '../battle-result-art'; import BattleDialogue from '../battle-dialogue'; -import type { BattleOutcome, MechanicalLogLine } from '../types'; +import type { MechanicalLogLine } from '../types'; import styles from '../index.module.css'; import vsClashImage from '@assets/images/background/vs.png'; import PetArt from '@components/pet/pet-art'; diff --git a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx index c43fe914..997e9886 100644 --- a/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx +++ b/frontend/src/components/pet/interactions/panels/battle/parts/battle-setup.tsx @@ -6,6 +6,8 @@ import { getPetProperties, getRarityColor, getRarityName, + opponentKey, + shortAddress, type OpponentPet, type Pet, type ReadyPet, @@ -17,8 +19,6 @@ import { import { Tones } from '@constants/tones'; import { AuthActionButton } from '@components/common'; import Icon, { BattleIcon } from '@components/ui/icon'; -import { opponentKey } from '../battle-utils'; -import { shortAddress } from '@utils/address'; import styles from '../index.module.css'; import PetArt from '@components/pet/pet-art'; import EquippedBadges from '@components/pet/equipped-badges'; diff --git a/frontend/src/components/pet/interactions/panels/battle/types.ts b/frontend/src/components/pet/interactions/panels/battle/types.ts index 5d978966..d65b7f20 100644 --- a/frontend/src/components/pet/interactions/panels/battle/types.ts +++ b/frontend/src/components/pet/interactions/panels/battle/types.ts @@ -1,4 +1,2 @@ -export type BattleOutcome = { result: 'victory' | 'defeat'; leveledUp: boolean } | null; - /** One formatted line in the mechanical (round-by-round) battle log. */ export type MechanicalLogLine = { text: string; isFighter: boolean }; diff --git a/frontend/src/hooks/battle/useBattlePanel.ts b/frontend/src/hooks/battle/useBattlePanel.ts index eea6f033..7638f7f2 100644 --- a/frontend/src/hooks/battle/useBattlePanel.ts +++ b/frontend/src/hooks/battle/useBattlePanel.ts @@ -1,16 +1,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { + describeMechanicalLogEntry, getReadyPetsUnified, isBattleRejection, isConsentFailure, + opponentKey, + pickRandomOpponent, + sortOpponentsByMatch, + toDialoguePet, useChainCapabilities, + useBattleOutcome, useBattlePets, useBattleTaunts, useCreateBattleRoom, + useLiveBattleAnimation, useOpponents, usePetList, useWinEstimate, + type BattlePersonas, type TxLifecycle, type BattleResolvedResult, type SimOutcome, @@ -19,20 +27,11 @@ import { BATTLE_ROOM_WS_URL } from '../../config'; import { BATTLE_PATH, DASHBOARD_HOME } from '@constants/interactionRoutes'; import { formatTxHashHint } from '@hooks/usePetError'; import { usePetErrorToast } from '@hooks/usePetErrorToast'; -import { - pickRandomOpponent, - sortOpponentsByMatch, -} from '@components/pet/interactions/panels/battle/battle-matchmaking'; -import { useBattleOutcome } from './useBattleOutcome'; import { useResultDialogue } from './useResultDialogue'; -import { useLiveBattleAnimation, describeMechanicalLogEntry } from './useLiveBattleAnimation'; import { BATTLE_FAIL_MESSAGE, MISMATCH_NOTICE_MESSAGE, VALIDATION_MESSAGE, - opponentKey, - toDialoguePet, - type BattlePersonas, } from '@components/pet/interactions/panels/battle/battle-utils'; import type { BattleOverlayProps } from '@components/pet/interactions/panels/battle/parts/battle-overlay'; import type { BattleSetupProps } from '@components/pet/interactions/panels/battle/parts/battle-setup'; diff --git a/frontend/src/hooks/battle/useResultDialogue.ts b/frontend/src/hooks/battle/useResultDialogue.ts index c223f390..635f2c3e 100644 --- a/frontend/src/hooks/battle/useResultDialogue.ts +++ b/frontend/src/hooks/battle/useResultDialogue.ts @@ -1,17 +1,15 @@ import type React from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { + toDialoguePet, useBattleDialogue, + type BattleOutcome, + type BattlePersonas, type DialogueTurn, type OpponentPet, type Pet, type PetChain, } from '@shared/core'; -import type { BattleOutcome } from '@components/pet/interactions/panels/battle/types'; -import { - toDialoguePet, - type BattlePersonas, -} from '@components/pet/interactions/panels/battle/battle-utils'; interface UseResultDialogueArgs { activeChainKind: PetChain | null; diff --git a/frontend/src/hooks/pet-gallery/usePetGallery.ts b/frontend/src/hooks/pet-gallery/usePetGallery.ts index c1541a53..58208923 100644 --- a/frontend/src/hooks/pet-gallery/usePetGallery.ts +++ b/frontend/src/hooks/pet-gallery/usePetGallery.ts @@ -1,9 +1,14 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useChainCapabilities, usePetList, type Pet } from '@shared/core'; +import { + useChainCapabilities, + usePetCooldowns, + usePetList, + type Pet, + type PetCooldownStatus, +} from '@shared/core'; import { BATTLE_PATH } from '@constants/interactionRoutes'; import { useNotifyError } from '@hooks/useNotifyError'; -import { usePetCooldowns, type PetCooldownStatus } from '@hooks/usePetCooldowns'; export interface UsePetGallery { isConnected: boolean; diff --git a/frontend/tests/components/chat.test.tsx b/frontend/tests/components/chat.test.tsx index f78eb656..c14ca3cd 100644 --- a/frontend/tests/components/chat.test.tsx +++ b/frontend/tests/components/chat.test.tsx @@ -9,20 +9,24 @@ const useChatMessages = vi.fn(); const useAuth = vi.fn(); -vi.mock('@shared/core', () => ({ - useAuth: () => useAuth(), - // `@utils/address` normalizes through the protocol helper; the real one, since the - // EVM-folds/base58-doesn't rule is what several of these assertions are about. - normalizeAccount: (value: string) => (/^0x[0-9a-fA-F]{40}$/.test(value) ? value.toLowerCase() : value), - useChainCapabilities: () => useChainCapabilities(), - useChatThreads: () => useChatThreads(), - useChatMessages: (opts: unknown) => useChatMessages(opts), - // A short stand-in for the real list; the picker only maps over whatever it is given. - CHAT_REACTIONS: ['👍', '❤️', '😂', '😮', '😢', '🙏', '🔥', '🐾'], - getPetAvatar: () => '🐉', - // No art service in these tests: PetArt renders the emoji alone. - petArtUrl: () => null, -})); +vi.mock('@shared/core', async () => { + // sameAccount/shortAddress stay real: the EVM-folds/base58-doesn't rule is what several + // of these assertions are about. Imported from their own module rather than the barrel, + // which would pull in wagmi and the rest of what this factory exists to replace. + const address = await import('../../../shared/src/utils/common/address'); + return { + ...address, + useAuth: () => useAuth(), + useChainCapabilities: () => useChainCapabilities(), + useChatThreads: () => useChatThreads(), + useChatMessages: (opts: unknown) => useChatMessages(opts), + // A short stand-in for the real list; the picker only maps over whatever it is given. + CHAT_REACTIONS: ['👍', '❤️', '😂', '😮', '😢', '🙏', '🔥', '🐾'], + getPetAvatar: () => '🐉', + // No art service in these tests: PetArt renders the emoji alone. + petArtUrl: () => null, + }; +}); // `config.ts` registers the storage adapter with @shared/core at module scope, which the // mock above does not provide. Only the socket URL is needed here, so stub the module diff --git a/frontend/tests/components/leaderboard.test.tsx b/frontend/tests/components/leaderboard.test.tsx index 0613b453..32b0ce83 100644 --- a/frontend/tests/components/leaderboard.test.tsx +++ b/frontend/tests/components/leaderboard.test.tsx @@ -9,21 +9,25 @@ const usePlayerLeaderboard = vi.fn(); const useAuth = vi.fn(); -vi.mock('@shared/core', () => ({ - useAuth: () => useAuth(), - // `@utils/address` normalizes through the protocol helper; the real one, since the - // EVM-folds/base58-doesn't rule is what several of these assertions are about. - normalizeAccount: (value: string) => (/^0x[0-9a-fA-F]{40}$/.test(value) ? value.toLowerCase() : value), - useChainCapabilities: () => useChainCapabilities(), - useLeaderboard: (opts: unknown) => useLeaderboard(opts), - usePlayerLeaderboard: (opts: unknown) => usePlayerLeaderboard(opts), - // PetArt reads these; the leaderboard only ever renders the emoji fallback here, - // since VITE_IMAGE_SERVICE_URL is unset in tests. - getPetAvatar: () => '🐾', - petArtUrl: () => null, - // Tints the podium card behind the pet; any colour will do here. - getRarityColor: () => '#b58cff', -})); +vi.mock('@shared/core', async () => { + // sameAccount/shortAddress stay real: the EVM-folds/base58-doesn't rule is what several + // of these assertions are about. Imported from their own module rather than the barrel, + // which would pull in wagmi and the rest of what this factory exists to replace. + const address = await import('../../../shared/src/utils/common/address'); + return { + ...address, + useAuth: () => useAuth(), + useChainCapabilities: () => useChainCapabilities(), + useLeaderboard: (opts: unknown) => useLeaderboard(opts), + usePlayerLeaderboard: (opts: unknown) => usePlayerLeaderboard(opts), + // PetArt reads these; the leaderboard only ever renders the emoji fallback here, + // since VITE_IMAGE_SERVICE_URL is unset in tests. + getPetAvatar: () => '🐾', + petArtUrl: () => null, + // Tints the podium card behind the pet; any colour will do here. + getRarityColor: () => '#b58cff', + }; +}); import Leaderboard from '@components/leaderboard'; diff --git a/frontend/tests/components/pet/collection/pet-gallery.test.tsx b/frontend/tests/components/pet/collection/pet-gallery.test.tsx index 5b7b0171..b6048bc8 100644 --- a/frontend/tests/components/pet/collection/pet-gallery.test.tsx +++ b/frontend/tests/components/pet/collection/pet-gallery.test.tsx @@ -30,10 +30,6 @@ const cooldownStatus = { trainOnCooldown: false, trainLabel: '', }; -vi.mock('@hooks/usePetCooldowns', () => ({ - usePetCooldowns: () => ({ statusFor: () => cooldownStatus }), -})); - const petList = { pets: [] as Array>, isLoading: false, @@ -56,6 +52,7 @@ vi.mock('@shared/core', () => ({ getRarityColor: () => 'rgb(1, 2, 3)', getRarityName: () => 'Rare', useChainCapabilities: () => capabilities, + usePetCooldowns: () => ({ statusFor: () => cooldownStatus }), usePetList: () => petList, // No gear in these cases: an empty map is what a bare roster looks like, and the badges // have their own suite. diff --git a/frontend/tests/components/pet/interactions/panels/battle/battle-setup.test.tsx b/frontend/tests/components/pet/interactions/panels/battle/battle-setup.test.tsx index 9c7ee7cd..6aff0d3e 100644 --- a/frontend/tests/components/pet/interactions/panels/battle/battle-setup.test.tsx +++ b/frontend/tests/components/pet/interactions/panels/battle/battle-setup.test.tsx @@ -42,7 +42,7 @@ vi.mock('@components/pet/interactions/panels/battle/parts/open-to-challenges-tog import BattleSetup, { type BattleSetupProps, } from '@components/pet/interactions/panels/battle/parts/battle-setup'; -import { opponentKey } from '@components/pet/interactions/panels/battle/battle-utils'; +import { opponentKey } from '@shared/core'; // Pets carry a real DNA + rarity so the shared DNA-derived helpers (stats, avatar, // class, rarity) render the CombatantCard without stubbing @shared/core. diff --git a/frontend/tests/hooks/battle/useBattlePanel.test.ts b/frontend/tests/hooks/battle/useBattlePanel.test.ts index ee3b76cf..07c937b2 100644 --- a/frontend/tests/hooks/battle/useBattlePanel.test.ts +++ b/frontend/tests/hooks/battle/useBattlePanel.test.ts @@ -15,20 +15,13 @@ vi.mock('react-router-dom', () => ({ vi.mock('@constants/interactionRoutes', () => ({ DASHBOARD_HOME: '/dashboard', BATTLE_PATH: '/battle' })); vi.mock('@hooks/usePetError', () => ({ formatTxHashHint: vi.fn(() => null) })); vi.mock('@hooks/usePetErrorToast', () => ({ usePetErrorToast: vi.fn() })); -vi.mock('@components/pet/interactions/panels/battle/battle-matchmaking', () => ({ - pickRandomOpponent: vi.fn(() => null), - sortOpponentsByMatch: vi.fn((ops: unknown[]) => ops), -})); vi.mock('@components/pet/interactions/panels/battle/battle-utils', () => ({ BATTLE_FAIL_MESSAGE: 'Battle failed', MISMATCH_NOTICE_MESSAGE: 'Mismatch notice', VALIDATION_MESSAGE: 'Select a fighter and opponent', - opponentKey: (owner: string, id: string) => `${owner}:${id}`, - toDialoguePet: (p: { id: string; name: string }) => ({ petId: p.id, name: p.name }), })); const battleOutcome = { battleOutcome: null as null | object, applyResolvedOutcome: vi.fn(), resetOutcome: vi.fn() }; -vi.mock('@hooks/battle/useBattleOutcome', () => ({ useBattleOutcome: () => battleOutcome })); const resultDialogue = { resultTurns: [], dialogueLoading: false, attackerName: '', defenderName: '', markResultDialogueDone: vi.fn(), resultDialogueDone: false, resetResultDialogue: vi.fn() }; vi.mock('@hooks/battle/useResultDialogue', () => ({ useResultDialogue: () => resultDialogue })); @@ -44,34 +37,48 @@ let capturedBattleOptions: { roomId?: string | null; roomSocketUrl?: string } | const pets = [{ id: 'p1', name: 'Rex', level: 3, winCount: 1, lossCount: 0, chain: 'evm', readyAt: 0n }]; const opponents = [{ id: 'opp1', name: 'Blaze', owner: '0xopp', level: 2 }]; -vi.mock('@shared/core', () => ({ - // `src/config.ts` calls both of these at import time, and the hook now imports it - // for BATTLE_ROOM_WS_URL. Stubs, not behaviour under test. - setStorageAdapter: vi.fn(), - setTokenSuccessCallback: vi.fn(), - isBattleRejection: (e: unknown) => - typeof e === 'object' && e !== null && (e as { isBattleRejection?: unknown }).isBattleRejection === true, - // Mirrors the real predicate: the three refusals that mean the defender's owner - // is not willing, as opposed to a band/cap refusal about this attacker or today. - isConsentFailure: (e: unknown) => { - const code = (e as { code?: string } | null)?.code; - return code === 'no-authorization' || code === 'pet-not-covered' || code === 'revoked'; - }, - getReadyPetsUnified: (p: { id: string }[]) => p.map((x) => ({ id: x.id, pet: x })), - useChainCapabilities: () => ({ activeKind: 'evm', randomness: { provider: 'vrf' } }), - usePetList: () => ({ pets, refetch: vi.fn(), isLoading: false }), - useBattlePets: (opts: { onSuccess?: (r: unknown) => void; roomId?: string | null; roomSocketUrl?: string }) => { - capturedOnSuccess = opts?.onSuccess; - capturedBattleOptions = opts; - return battle; - }, - useBattleTaunts: () => taunts, - useCreateBattleRoom: () => ({ createRoom, isLoading: false }), - // Stable identity, matching react-query's own refetch — and so the consent-failure - // effect below can be asserted on across renders. - useOpponents: () => ({ opponents, isLoading: false, isFetching: false, refetch: refetchOpponents }), - useWinEstimate: () => ({ winProbability: null, isLoading: false, samples: null }), -})); +vi.mock('@shared/core', async () => { + // The strike playback is real, not stubbed: the result-card gating cases below assert on + // its timing. Imported from its own module rather than the barrel, which would pull in + // wagmi and the rest of what this factory exists to replace. + const animation = await import('../../../../shared/src/hooks/battle/useLiveBattleAnimation'); + return { + ...animation, + // `src/config.ts` calls both of these at import time, and the hook now imports it + // for BATTLE_ROOM_WS_URL. Stubs, not behaviour under test. + setStorageAdapter: vi.fn(), + setTokenSuccessCallback: vi.fn(), + isBattleRejection: (e: unknown) => + typeof e === 'object' && e !== null && (e as { isBattleRejection?: unknown }).isBattleRejection === true, + // Mirrors the real predicate: the three refusals that mean the defender's owner + // is not willing, as opposed to a band/cap refusal about this attacker or today. + isConsentFailure: (e: unknown) => { + const code = (e as { code?: string } | null)?.code; + return code === 'no-authorization' || code === 'pet-not-covered' || code === 'revoked'; + }, + getReadyPetsUnified: (p: { id: string }[]) => p.map((x) => ({ id: x.id, pet: x })), + // Matchmaking is not what these cases are about: selection is driven by explicit ids, + // so the ordering passes through and the random pick is inert. + pickRandomOpponent: vi.fn(() => null), + sortOpponentsByMatch: vi.fn((ops: unknown[]) => ops), + opponentKey: (owner: string, id: string) => `${owner}:${id}`, + toDialoguePet: (p: { id: string; name: string }) => ({ petId: p.id, name: p.name }), + useChainCapabilities: () => ({ activeKind: 'evm', randomness: { provider: 'vrf' } }), + usePetList: () => ({ pets, refetch: vi.fn(), isLoading: false }), + useBattlePets: (opts: { onSuccess?: (r: unknown) => void; roomId?: string | null; roomSocketUrl?: string }) => { + capturedOnSuccess = opts?.onSuccess; + capturedBattleOptions = opts; + return battle; + }, + useBattleOutcome: () => battleOutcome, + useBattleTaunts: () => taunts, + useCreateBattleRoom: () => ({ createRoom, isLoading: false }), + // Stable identity, matching react-query's own refetch — and so the consent-failure + // effect below can be asserted on across renders. + useOpponents: () => ({ opponents, isLoading: false, isFetching: false, refetch: refetchOpponents }), + useWinEstimate: () => ({ winProbability: null, isLoading: false, samples: null }), + }; +}); import { useBattlePanel } from '@hooks/battle/useBattlePanel'; diff --git a/frontend/tests/hooks/battle/useResultDialogue.test.ts b/frontend/tests/hooks/battle/useResultDialogue.test.ts index 1ca397ea..11b2bfc2 100644 --- a/frontend/tests/hooks/battle/useResultDialogue.test.ts +++ b/frontend/tests/hooks/battle/useResultDialogue.test.ts @@ -1,15 +1,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { act, renderHook } from '@testing-library/react'; import type React from 'react'; -import type { OpponentPet, Pet } from '@shared/core'; +import type { BattlePersonas, OpponentPet, Pet } from '@shared/core'; const useBattleDialogue = vi.fn(); -vi.mock('@shared/core', () => ({ - useBattleDialogue: (...args: unknown[]) => useBattleDialogue(...args), -})); +vi.mock('@shared/core', async () => { + // toDialoguePet stays real: several cases assert on the persona the hook builds. + // Imported from its own module rather than the barrel, which would pull in wagmi and + // the rest of what this factory exists to replace. + const persona = await import('../../../../shared/src/utils/battleDialoguePet'); + return { + ...persona, + useBattleDialogue: (...args: unknown[]) => useBattleDialogue(...args), + }; +}); import { useResultDialogue } from '@hooks/battle/useResultDialogue'; -import type { BattlePersonas } from '@components/pet/interactions/panels/battle/battle-utils'; const pet = (name: string, over: Partial = {}): Pet => ({ id: name, name, level: 1, rarity: 'common', dna: 1, winCount: 0, lossCount: 0, ...over }) as Pet; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index cf67d2b5..13fd250b 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -27,8 +27,7 @@ "@router": ["./src/router/index.ts"], "@router/*": ["./src/router/*"], "@shared/core": ["../shared/src/index.ts"], - "@styles/*": ["./src/styles/*"], - "@utils/*": ["./src/utils/*"] + "@styles/*": ["./src/styles/*"] }, /* Linting */ diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 564bf4fd..9144c9c4 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -14,7 +14,6 @@ const aliases = { '@pages': 'src/pages', '@router': 'src/router', '@styles': 'src/styles', - '@utils': 'src/utils', } as const; // https://vite.dev/config/ diff --git a/mobile/src/components/ConnectButton.tsx b/mobile/src/components/ConnectButton.tsx index 91ca2fe7..df0afe96 100644 --- a/mobile/src/components/ConnectButton.tsx +++ b/mobile/src/components/ConnectButton.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, Platform } from 'react-native'; import { useAppKit } from '@reown/appkit-react-native'; import { useAccount } from 'wagmi'; -import { useAuth } from '@shared/core'; +import { shortAddress, useAuth } from '@shared/core'; import { neon, neonGlow } from '../theme/neon'; interface ConnectButtonProps { @@ -36,7 +36,7 @@ export default function ConnectButton({ compact = false }: ConnectButtonProps = return ( open()}> - {address ? `${address.slice(0, 6)}...${address.slice(-4)}` : 'Connected'} + {address ? shortAddress(address) : 'Connected'} ); diff --git a/shared/eslint.config.js b/shared/eslint.config.js index 56aece37..a134ab56 100644 --- a/shared/eslint.config.js +++ b/shared/eslint.config.js @@ -36,6 +36,27 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-empty-object-type': 'off', 'prefer-const': 'error', + // Nothing here may reach into an app. Two ways that happens: an import through + // one of the frontend's path aliases silently pulls frontend source into a + // package mobile also consumes, and a platform-only router/primitive ties the + // package to one client. Both are how usePetCooldowns, useBattleOutcome, + // useLiveBattleAnimation and shortAddress came to sit in the frontend at all. + 'no-restricted-imports': ['error', { + patterns: [ + { + group: [ + '@assets/**', '@chains/**', '@components/**', '@constants/**', + '@contexts/**', '@hooks/**', '@pages/**', '@router', '@router/**', + '@styles/**', '@utils/**', + ], + message: "That is a frontend path alias, and @shared/core is consumed by mobile too. Use a relative import within shared, or leave the code in the app.", + }, + { + group: ['react-router-dom', 'react-native', 'next/**'], + message: '@shared/core has to run on both web and React Native. Platform-specific routing and primitives belong in the app.', + }, + ], + }], // Trailing semicolons on statements (incl. arrow-const declarations), // and exactly one space around => (catches the `): T =>` double-space). // Both auto-fixable; neither touches intentional colon alignment. diff --git a/frontend/src/hooks/battle/useBattleOutcome.ts b/shared/src/hooks/battle/useBattleOutcome.ts similarity index 95% rename from frontend/src/hooks/battle/useBattleOutcome.ts rename to shared/src/hooks/battle/useBattleOutcome.ts index db1ac630..b745508e 100644 --- a/frontend/src/hooks/battle/useBattleOutcome.ts +++ b/shared/src/hooks/battle/useBattleOutcome.ts @@ -1,5 +1,5 @@ import { useCallback, useState } from 'react'; -import type { BattleOutcome } from '@components/pet/interactions/panels/battle/types'; +import type { BattleOutcome } from '../../types/battle'; export interface UseBattleOutcome { /** Resolved victory/defeat, or null until the receipt has verified. */ diff --git a/frontend/src/hooks/battle/useLiveBattleAnimation.ts b/shared/src/hooks/battle/useLiveBattleAnimation.ts similarity index 93% rename from frontend/src/hooks/battle/useLiveBattleAnimation.ts rename to shared/src/hooks/battle/useLiveBattleAnimation.ts index 71a90223..df4fd1b2 100644 --- a/frontend/src/hooks/battle/useLiveBattleAnimation.ts +++ b/shared/src/hooks/battle/useLiveBattleAnimation.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import type { StrikeLogEntry } from '@shared/core'; +import type { StrikeLogEntry } from '@cryptopets/protocol'; /** Time each strike stays on screen before the next one plays. */ const STRIKE_INTERVAL_MS = 700; @@ -24,8 +24,9 @@ export interface LiveBattleAnimationState { /** * Plays a combat-sim log (@cryptopets/protocol's combat engine, via the verified receipt's * `liveReplay`) one strike at a time, exposing HP percentages and a flavor - * line for the fighting scene. Presentation only — see useBattlePanel.ts for - * the gate that keeps the result card off this animation and the + * line for the fighting scene. Presentation only, and renderer-agnostic: it returns + * numbers and strings, so the web and mobile battle scenes share it. See the frontend's + * useBattlePanel.ts for the gate that keeps the result card off this animation and the * reconciliation check against the authoritative on-chain result. */ export function useLiveBattleAnimation( diff --git a/shared/src/hooks/index.ts b/shared/src/hooks/index.ts index 67dd6ba7..8b8442fb 100644 --- a/shared/src/hooks/index.ts +++ b/shared/src/hooks/index.ts @@ -24,6 +24,9 @@ export { useActiveChain, type ActiveChain } from './session/useActiveChain'; export { useChainCapabilities, type ChainContext } from './session/useChainCapabilities'; export type { TxLifecycle, TxPhase, ChainCapabilities } from './adapters/types'; export { usePetList, type PetListResult } from './pets/usePetList'; +// Per-pet cooldown readiness + live countdown labels. Platform-neutral: React state and +// the shared readiness helpers only, so the mobile pet list can use it unchanged. +export { usePetCooldowns, type PetCooldowns, type PetCooldownStatus } from './pets/usePetCooldowns'; // Backend battle progression. usePetList already applies it to a player's own pets; // exported for anything reading pets from the chain by another route. export { useBattleProgress, mergeBattleProgress } from './battle/useBattleProgress'; @@ -104,6 +107,15 @@ export { type DialoguePhase, } from './battle/useBattleDialogue'; export { useBattleTaunts, type GenerateTauntsVars } from './battle/useBattleTaunts'; +// Holds the verdict a resolved receipt reports, for whatever renders the result screen. +export { useBattleOutcome, type UseBattleOutcome } from './battle/useBattleOutcome'; +// Strike-by-strike playback of a verified receipt's replay log. Returns percentages and +// strings rather than anything drawable, so the web and mobile battle scenes share it. +export { + useLiveBattleAnimation, + describeMechanicalLogEntry, + type LiveBattleAnimationState, +} from './battle/useLiveBattleAnimation'; export { useCreateBattleRoom, type CreateRoomVars } from './battle/useCreateBattleRoom'; // Backend-authoritative battles (docs/battle-protocol.md §D, §E, §J). export { BATTLE_CONFIG_QUERY_KEY, useBattleConfig, type BattleConfig } from './battle/useBattleConfig'; diff --git a/frontend/src/hooks/usePetCooldowns.ts b/shared/src/hooks/pets/usePetCooldowns.ts similarity index 94% rename from frontend/src/hooks/usePetCooldowns.ts rename to shared/src/hooks/pets/usePetCooldowns.ts index b152c741..d8626db4 100644 --- a/frontend/src/hooks/usePetCooldowns.ts +++ b/shared/src/hooks/pets/usePetCooldowns.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; -import { getTimeUntilReady, isPetReady, type Pet } from '@shared/core'; +import { getTimeUntilReady, isPetReady } from '../../utils/ethereum/petReadyTime'; +import type { Pet } from '../../types/pet'; export interface PetCooldownStatus { /** True when any of the three cooldowns is still active. */ diff --git a/shared/src/index.ts b/shared/src/index.ts index 72894d7f..7a0e63a6 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -34,5 +34,5 @@ export { type EvmContractRef, } from './contexts/PetsConfigContext'; export type { Pet, PetChain, PetAction, OpponentPet } from './types/pet'; -export type { BattleResolvedResult } from './types/battle'; +export type { BattleOutcome, BattleResolvedResult } from './types/battle'; export { queryClient } from './queryClient'; diff --git a/shared/src/types/battle.ts b/shared/src/types/battle.ts index 95823e07..c4433a58 100644 --- a/shared/src/types/battle.ts +++ b/shared/src/types/battle.ts @@ -1,3 +1,11 @@ +/** + * A battle's result once it has resolved, or null before then. + * + * Deliberately not `BattleResolvedResult`: that is the whole receipt, while this is the + * verdict a result screen renders. Both clients show the same two things. + */ +export type BattleOutcome = { result: 'victory' | 'defeat'; leveledUp: boolean } | null; + /** * A resolved battle, as the UI renders it. * diff --git a/shared/src/utils/battleDialoguePet.ts b/shared/src/utils/battleDialoguePet.ts new file mode 100644 index 00000000..4290ce42 --- /dev/null +++ b/shared/src/utils/battleDialoguePet.ts @@ -0,0 +1,16 @@ +import type { DialoguePetInput } from '../hooks/battle/useBattleDialogue'; +import type { OpponentPet, Pet } from '../types/pet'; + +/** Map a pet/opponent to the persona input the dialogue endpoint expects. */ +export const toDialoguePet = (pet: Pet | OpponentPet): DialoguePetInput => ({ + petId: pet.id, + name: pet.name, + level: pet.level, + rarity: pet.rarity, + dna: pet.dna.toString(), + winCount: pet.winCount, + lossCount: pet.lossCount, +}); + +/** Personas captured at battle start, reused for the settle dialogue read. */ +export type BattlePersonas = { attacker: DialoguePetInput; defender: DialoguePetInput }; diff --git a/frontend/src/components/pet/interactions/panels/battle/battle-matchmaking.ts b/shared/src/utils/battleMatchmaking.ts similarity index 89% rename from frontend/src/components/pet/interactions/panels/battle/battle-matchmaking.ts rename to shared/src/utils/battleMatchmaking.ts index 4f12dc82..c8d6010b 100644 --- a/frontend/src/components/pet/interactions/panels/battle/battle-matchmaking.ts +++ b/shared/src/utils/battleMatchmaking.ts @@ -1,4 +1,4 @@ -import type { OpponentPet } from '@shared/core'; +import type { OpponentPet } from '../types/pet'; export type MatchTier = 'even' | 'easy' | 'risky' | 'danger' | 'unknown'; @@ -26,6 +26,9 @@ export const getMatchLabel = (tier: MatchTier, delta: number | null): string | n return `${delta} lv`; }; +/** Stable select value for an opponent (pet ids are not globally unique on Solana). */ +export const opponentKey = (owner: string, id: string) => `${owner}::${id}`; + /** Pick a random opponent whose level is closest to the fighter's. */ export const pickRandomOpponent = ( opponents: OpponentPet[], diff --git a/frontend/src/utils/address.ts b/shared/src/utils/common/address.ts similarity index 94% rename from frontend/src/utils/address.ts rename to shared/src/utils/common/address.ts index 3d4a38be..57ec9765 100644 --- a/frontend/src/utils/address.ts +++ b/shared/src/utils/common/address.ts @@ -1,4 +1,4 @@ -import { normalizeAccount } from '@shared/core'; +import { normalizeAccount } from '@cryptopets/protocol'; /** * `0x1234…abcd` — a full address does not fit the columns it is displayed in. diff --git a/shared/src/utils/common/index.ts b/shared/src/utils/common/index.ts index 23d938b4..ee2f0921 100644 --- a/shared/src/utils/common/index.ts +++ b/shared/src/utils/common/index.ts @@ -1,2 +1,3 @@ +export { sameAccount, shortAddress } from './address'; export { sleep } from './sleep'; export { formatExpiry } from './time'; diff --git a/shared/src/utils/index.ts b/shared/src/utils/index.ts index 99ac4ace..7ab3e518 100644 --- a/shared/src/utils/index.ts +++ b/shared/src/utils/index.ts @@ -1,5 +1,7 @@ +export * from './battleDialoguePet'; export * from './battleEvidence'; export * from './battleFailureMessage'; +export * from './battleMatchmaking'; export * from './common'; export * from './ethereum'; export * from './solana'; diff --git a/frontend/tests/hooks/battle/useBattleOutcome.test.ts b/shared/tests/hooks/useBattleOutcome.test.ts similarity index 95% rename from frontend/tests/hooks/battle/useBattleOutcome.test.ts rename to shared/tests/hooks/useBattleOutcome.test.ts index 0ba44982..c8d392e8 100644 --- a/frontend/tests/hooks/battle/useBattleOutcome.test.ts +++ b/shared/tests/hooks/useBattleOutcome.test.ts @@ -1,7 +1,8 @@ +// @vitest-environment jsdom import { act, renderHook } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { useBattleOutcome } from '@hooks/battle/useBattleOutcome'; +import { useBattleOutcome } from '../../src/hooks/battle/useBattleOutcome'; /** * The outcome now comes entirely from the verified receipt's progression delta. The old diff --git a/frontend/tests/hooks/battle/useLiveBattleAnimation.test.ts b/shared/tests/hooks/useLiveBattleAnimation.test.ts similarity index 97% rename from frontend/tests/hooks/battle/useLiveBattleAnimation.test.ts rename to shared/tests/hooks/useLiveBattleAnimation.test.ts index a5f365bf..66a4241f 100644 --- a/frontend/tests/hooks/battle/useLiveBattleAnimation.test.ts +++ b/shared/tests/hooks/useLiveBattleAnimation.test.ts @@ -1,7 +1,11 @@ +// @vitest-environment jsdom import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { act, renderHook } from '@testing-library/react'; -import type { StrikeLogEntry } from '@shared/core'; -import { useLiveBattleAnimation, describeMechanicalLogEntry } from '@hooks/battle/useLiveBattleAnimation'; +import type { StrikeLogEntry } from '@cryptopets/protocol'; +import { + useLiveBattleAnimation, + describeMechanicalLogEntry, +} from '../../src/hooks/battle/useLiveBattleAnimation'; function entry(overrides: Partial): StrikeLogEntry { return { diff --git a/frontend/tests/components/pet/interactions/panels/battle/battle-utils.test.ts b/shared/tests/utils/battleDialoguePet.test.ts similarity index 75% rename from frontend/tests/components/pet/interactions/panels/battle/battle-utils.test.ts rename to shared/tests/utils/battleDialoguePet.test.ts index f8e74456..714aea13 100644 --- a/frontend/tests/components/pet/interactions/panels/battle/battle-utils.test.ts +++ b/shared/tests/utils/battleDialoguePet.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { OpponentPet, Pet } from '@shared/core'; +import type { OpponentPet, Pet } from '../../src/types/pet'; -import { - opponentKey, - toDialoguePet, -} from '@components/pet/interactions/panels/battle/battle-utils'; +import { toDialoguePet } from '../../src/utils/battleDialoguePet'; describe('toDialoguePet', () => { it('maps a pet to the dialogue persona shape and stringifies dna', () => { @@ -44,10 +41,3 @@ describe('toDialoguePet', () => { expect(toDialoguePet(opp).dna).toBe('7'); }); }); - -describe('opponentKey', () => { - it('joins owner and id with a stable separator', () => { - expect(opponentKey('0xowner', 'id5')).toBe('0xowner::id5'); - }); -}); - diff --git a/frontend/tests/components/pet/interactions/panels/battle/battle-matchmaking.test.ts b/shared/tests/utils/battleMatchmaking.test.ts similarity index 91% rename from frontend/tests/components/pet/interactions/panels/battle/battle-matchmaking.test.ts rename to shared/tests/utils/battleMatchmaking.test.ts index b658f8aa..1b3a1e24 100644 --- a/frontend/tests/components/pet/interactions/panels/battle/battle-matchmaking.test.ts +++ b/shared/tests/utils/battleMatchmaking.test.ts @@ -1,13 +1,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { OpponentPet } from '@shared/core'; +import type { OpponentPet } from '../../src/types/pet'; import { getLevelDelta, getMatchLabel, getMatchTier, + opponentKey, pickRandomOpponent, sortOpponentsByMatch, -} from '@components/pet/interactions/panels/battle/battle-matchmaking'; +} from '../../src/utils/battleMatchmaking'; const opp = (id: string, level: number): OpponentPet => ({ id, level }) as unknown as OpponentPet; @@ -57,6 +58,12 @@ describe('getMatchLabel', () => { }); }); +describe('opponentKey', () => { + it('joins owner and id with a stable separator', () => { + expect(opponentKey('0xowner', 'id5')).toBe('0xowner::id5'); + }); +}); + describe('pickRandomOpponent', () => { afterEach(() => vi.restoreAllMocks()); diff --git a/frontend/tests/utils/address.test.ts b/shared/tests/utils/common/address.test.ts similarity index 95% rename from frontend/tests/utils/address.test.ts rename to shared/tests/utils/common/address.test.ts index 5d3ec0e0..fc628828 100644 --- a/frontend/tests/utils/address.test.ts +++ b/shared/tests/utils/common/address.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { sameAccount, shortAddress } from '@utils/address'; +import { sameAccount, shortAddress } from '../../../src/utils/common/address'; describe('shortAddress', () => { it('truncates long addresses to head…tail', () => {