Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<feature>/`, 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:
Expand Down
9 changes: 0 additions & 9 deletions bash.exe.stackdump

This file was deleted.

3 changes: 2 additions & 1 deletion frontend/src/components/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import clsx from 'clsx';
import { useNavigate } from 'react-router-dom';
import {
CHAT_REACTIONS,
sameAccount,
shortAddress,
useChainCapabilities,
useChatMessages,
useChatThreads,
Expand All @@ -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';
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/leaderboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import clsx from 'clsx';
import { useNavigate } from 'react-router-dom';
import {
getRarityColor,
sameAccount,
shortAddress,
useChainCapabilities,
useLeaderboard,
usePlayerLeaderboard,
Expand All @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import type { BattleOutcome } from './types';
import type { BattleOutcome } from '@shared/core';

type Props = { outcome: BattleOutcome };

Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,9 @@
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
* BattleResolved result (the on-chain result always wins; this is
* 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 };
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
getPetProperties,
getRarityColor,
getRarityName,
opponentKey,
shortAddress,
type OpponentPet,
type Pet,
type ReadyPet,
Expand All @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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 };
17 changes: 8 additions & 9 deletions frontend/src/hooks/battle/useBattlePanel.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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';
Expand Down
8 changes: 3 additions & 5 deletions frontend/src/hooks/battle/useResultDialogue.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/hooks/pet-gallery/usePetGallery.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
32 changes: 18 additions & 14 deletions frontend/tests/components/chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 19 additions & 15 deletions frontend/tests/components/leaderboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,6 @@ const cooldownStatus = {
trainOnCooldown: false,
trainLabel: '',
};
vi.mock('@hooks/usePetCooldowns', () => ({
usePetCooldowns: () => ({ statusFor: () => cooldownStatus }),
}));

const petList = {
pets: [] as Array<Record<string, unknown>>,
isLoading: false,
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading