diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9f4a31a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index 99253be..7f8e118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,25 +10,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Planned + - Push notifications for focus reminders and streak alerts -- - Widget support (iOS/Android home screen) - - - Social leaderboard with friends - - - Multiple monster companions to collect - - - Export focus history to CSV - - - --- - - ## [1.0.0] - 2026-05-01 - - ### Added - - Initial release of FocusMon - - - Pomodoro-style focus sessions with completion and give-up outcomes - - - XP, levels, focus coins, streaks, and total focus-hour tracking - - - Monster companion with level-based evolution art - - - Quest, journal, profile, creature, vault, and leaderboard screens - - - Offline persistence with AsyncStorage - - - Optional Supabase Auth and Postgres sync - - - Animated timer, XP, and level-up moments with Reanimated - - - Built with React Native + Expo 56 + TypeScript - - - NativeWind + Tailwind CSS for styling - - - Zustand state stores for user, creature, and session data +- Widget support (iOS/Android home screen) +- Social leaderboard with friends +- Multiple monster companions to collect +- Export focus history to CSV + +--- + +## [1.0.0] - 2026-05-01 + +### Added + +- Initial release of FocusMon +- Pomodoro-style focus sessions with completion and give-up outcomes +- XP, levels, focus coins, streaks, and total focus-hour tracking +- Monster companion with level-based evolution art +- Quest, journal, profile, creature, vault, and leaderboard screens +- Offline persistence with AsyncStorage +- Optional Supabase Auth and Postgres sync +- Animated timer, XP, and level-up moments with Reanimated +- Built with React Native + Expo 56 + TypeScript +- NativeWind + Tailwind CSS for styling +- Zustand state stores for user, creature, and session data diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b2308cc..e44a35d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,43 +5,46 @@ Thanks for your interest in contributing to FocusMon! 🎯 ## Getting Started 1. **Fork** the repo and clone it locally -2. 2. **Install dependencies**: - 3. ```bash - npm install - cp .env.example .env - ``` - 3. **Run the app**: - 4. ```bash - npm start # Expo Go (scan QR) - npm run ios # iOS simulator - npm run android # Android emulator - ``` - - > Supabase is optional — the app works fully offline with AsyncStorage. To enable sync, create a Supabase project, run `supabase/schema.sql`, and add your URL + anon key to `.env`. - > - > ## Good First Contributions - > - > - **New monster evolutions** — add art and leveling thresholds - > - - **Push notifications** — focus reminders and streak alerts - > - - **Bug fixes** — check open [issues](https://github.com/sonnymay/focusomon/issues) - > - - **Tests** — unit tests for XP calculation, level logic, streak tracking - > - - **Docs** — improve README setup steps or add screenshots - > - > - ## Pull Request Guidelines - > - > - 1. Branch off `main`: `git checkout -b feat/your-feature` - > 2. 2. Keep PRs focused — one feature or fix per PR - > 3. 3. Follow existing patterns: functional components, Zustand stores, NativeWind styling - > 4. 4. Run `npx tsc` to check for TypeScript errors before submitting - > 5. 5. Describe what you changed and why in the PR description - > - > 6. ## Code Style - > - > 7. - **TypeScript** strict mode — no `any` without justification - > - - **NativeWind** for styling — avoid inline StyleSheet where possible - > - - **Zustand** for state — keep stores small and focused - > - - **Commits**: Conventional commits (`feat:`, `fix:`, `chore:`, `docs:`) - > - > - ## Questions? - > - > - Open an issue or reach out at sonnymaywi@gmail.com. +2. **Install dependencies**: + + ```bash + npm install + cp .env.example .env + ``` + +3. **Run the app**: + + ```bash + npm start # Expo Go (scan QR) + npm run ios # iOS simulator + npm run android # Android emulator + ``` + +> Supabase is optional — the app works fully offline with AsyncStorage. To enable sync, create a Supabase project, run `supabase/schema.sql`, and add your URL + anon key to `.env`. + +## Good First Contributions + +- **New monster evolutions** — add art and leveling thresholds +- **Push notifications** — focus reminders and streak alerts +- **Bug fixes** — check open [issues](https://github.com/sonnymay/focusomon/issues) +- **Tests** — unit tests for XP calculation, level logic, streak tracking +- **Docs** — improve README setup steps or add screenshots + +## Pull Request Guidelines + +1. Branch off `main`: `git checkout -b feat/your-feature` +2. Keep PRs focused — one feature or fix per PR +3. Follow existing patterns: functional components, Zustand stores, NativeWind styling +4. Run `npm run typecheck` and `npm test` before submitting +5. Describe what you changed and why in the PR description + +## Code Style + +- **TypeScript** strict mode — no `any` without justification +- **NativeWind** for styling — avoid inline StyleSheet where possible +- **Zustand** for state — keep stores small and focused +- **Commits**: Conventional commits (`feat:`, `fix:`, `chore:`, `docs:`) + +## Questions? + +Open an issue or reach out at sonnymaywi@gmail.com. diff --git a/README.md b/README.md index 1ac6325..509e959 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # FocusMon +[![CI](https://github.com/sonnymay/focusomon/actions/workflows/ci.yml/badge.svg)](https://github.com/sonnymay/focusomon/actions/workflows/ci.yml) ![React Native](https://img.shields.io/badge/React_Native-0.85-61DAFB?style=flat-square&logo=react&logoColor=black) ![Expo](https://img.shields.io/badge/Expo-56-000020?style=flat-square&logo=expo&logoColor=white) ![Supabase](https://img.shields.io/badge/Supabase-optional-3ECF8E?style=flat-square&logo=supabase&logoColor=white) @@ -34,7 +35,7 @@ FocusMon is an offline-first focus timer with RPG progression. Complete Pomodoro - Mobile app structure with Expo Router and typed React Native screens - Offline-first state design with an optional backend sync path -- Small, testable game logic for XP, levels, streaks, and creature stats +- Small, unit-tested game logic for XP, levels, streaks, and creature stats - Clean separation between UI components, stores, and shared logic - Practical product thinking around retention loops, rewards, and progress feedback @@ -58,3 +59,14 @@ npm run web Supabase is optional for local use. With blank `EXPO_PUBLIC_SUPABASE_URL` and `EXPO_PUBLIC_SUPABASE_ANON_KEY`, the app stores progress locally with AsyncStorage. To enable sync, create a Supabase project, run `supabase/schema.sql`, enable anonymous auth, and add the project URL and anon key to `.env`. +## Testing + +The game logic in `lib/` (XP curves, level thresholds, streak rules, egg pulls, creature stats) is pure TypeScript with no React Native dependencies, so it runs under Node's built-in test runner with zero extra test dependencies: + +```bash +npm test # unit tests (node:test) +npm run typecheck # tsc --noEmit +``` + +Both run in CI on every push and pull request. + diff --git a/lib/gameLogic.test.ts b/lib/gameLogic.test.ts new file mode 100644 index 0000000..fa11932 --- /dev/null +++ b/lib/gameLogic.test.ts @@ -0,0 +1,146 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + XP_TABLE, + MAX_LEVEL, + xpForDuration, + applyDailyGoalBonus, + penaltyXp, + levelFromXp, + pullEgg, + computeStreak, + streakBroken, + statsForLevel, + LINEAGES, + EGG_COSTS, +} from './gameLogic.ts'; + +describe('xpForDuration', () => { + it('rewards scale with session length', () => { + assert.deepEqual(xpForDuration(15), { xp: 100, coins: 10 }); + assert.deepEqual(xpForDuration(60), { xp: 600, coins: 60 }); + assert.ok(xpForDuration(30).xp < xpForDuration(45).xp); + }); +}); + +describe('applyDailyGoalBonus', () => { + it('adds 25% when the daily goal is hit', () => { + assert.equal(applyDailyGoalBonus(100, true), 125); + assert.equal(applyDailyGoalBonus(100, false), 100); + }); + + it('rounds to the nearest whole XP', () => { + assert.equal(applyDailyGoalBonus(250, true), 313); + }); +}); + +describe('penaltyXp', () => { + it('is 20% of the session XP, floored', () => { + assert.equal(penaltyXp(100), 20); + assert.equal(penaltyXp(101), 20); + assert.equal(penaltyXp(0), 0); + }); +}); + +describe('levelFromXp', () => { + it('starts at level 1 with zero XP', () => { + const r = levelFromXp(0); + assert.equal(r.level, 1); + assert.equal(r.xpIntoLevel, 0); + assert.equal(r.progress, 0); + }); + + it('levels up exactly at each XP threshold', () => { + assert.equal(levelFromXp(299).level, 1); + assert.equal(levelFromXp(300).level, 2); + assert.equal(levelFromXp(700).level, 3); + }); + + it('tracks progress within a level', () => { + // Level 1 spans 0..300 + const r = levelFromXp(150); + assert.equal(r.level, 1); + assert.equal(r.xpIntoLevel, 150); + assert.equal(r.xpToNext, 300); + assert.equal(r.progress, 0.5); + }); + + it('caps at MAX_LEVEL with full progress', () => { + const top = XP_TABLE[XP_TABLE.length - 1]; + const r = levelFromXp(top + 999999); + assert.equal(r.level, MAX_LEVEL); + assert.equal(r.progress, 1); + }); +}); + +describe('pullEgg', () => { + it('always returns a valid rarity for every tier', () => { + const rarities = new Set(['Common', 'Rare', 'Epic', 'Legendary']); + for (const tier of Object.keys(EGG_COSTS) as Array) { + for (let i = 0; i < 500; i++) { + assert.ok(rarities.has(pullEgg(tier))); + } + } + }); +}); + +describe('computeStreak', () => { + const now = new Date(2026, 5, 15, 12, 0, 0); + + it('starts a streak on the first ever session', () => { + assert.equal(computeStreak(null, 0, now), 1); + }); + + it('keeps the streak for a second session on the same day', () => { + const sameDay = new Date(2026, 5, 15, 8, 0, 0).toISOString(); + assert.equal(computeStreak(sameDay, 3, now), 3); + }); + + it('increments the streak on consecutive days', () => { + const yesterday = new Date(2026, 5, 14, 23, 0, 0).toISOString(); + assert.equal(computeStreak(yesterday, 3, now), 4); + }); + + it('resets the streak after a missed day', () => { + const twoDaysAgo = new Date(2026, 5, 13, 12, 0, 0).toISOString(); + assert.equal(computeStreak(twoDaysAgo, 7, now), 1); + }); +}); + +describe('streakBroken', () => { + const now = new Date(2026, 5, 15, 12, 0, 0); + + it('is not broken with no prior session', () => { + assert.equal(streakBroken(null, now), false); + }); + + it('is not broken after yesterday, broken after a gap', () => { + const yesterday = new Date(2026, 5, 14, 1, 0, 0).toISOString(); + const threeDaysAgo = new Date(2026, 5, 12, 1, 0, 0).toISOString(); + assert.equal(streakBroken(yesterday, now), false); + assert.equal(streakBroken(threeDaysAgo, now), true); + }); +}); + +describe('statsForLevel', () => { + it('grows combat stats linearly with level', () => { + for (const lineage of LINEAGES) { + const l1 = statsForLevel(1, lineage); + const l5 = statsForLevel(5, lineage); + assert.equal(l5.atk, l1.atk + 12); + assert.equal(l5.def, l1.def + 8); + assert.equal(l5.hp, l1.hp + 32); + } + }); + + it('never lets focus stats exceed 100', () => { + for (let level = 1; level <= MAX_LEVEL; level++) { + const r = statsForLevel(level, 'Fire'); + assert.ok(r.focusPower <= 100); + assert.ok(r.discipline <= 100); + assert.ok(r.intensity <= 100); + } + // Intensity starts highest, so it is the first stat to hit the cap + assert.equal(statsForLevel(MAX_LEVEL, 'Fire').intensity, 100); + }); +}); diff --git a/lib/utils.test.ts b/lib/utils.test.ts new file mode 100644 index 0000000..f12968d --- /dev/null +++ b/lib/utils.test.ts @@ -0,0 +1,46 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatTime, clamp, randomName } from './utils.ts'; +import { generateTrainerId } from './trainerId.ts'; + +describe('formatTime', () => { + it('formats seconds as MM:SS', () => { + assert.equal(formatTime(0), '00:00'); + assert.equal(formatTime(59), '00:59'); + assert.equal(formatTime(60), '01:00'); + assert.equal(formatTime(1500), '25:00'); + }); + + it('clamps negative values to zero', () => { + assert.equal(formatTime(-5), '00:00'); + }); + + it('floors fractional seconds', () => { + assert.equal(formatTime(89.9), '01:29'); + }); +}); + +describe('clamp', () => { + it('keeps values inside the range', () => { + assert.equal(clamp(5, 0, 10), 5); + assert.equal(clamp(-1, 0, 10), 0); + assert.equal(clamp(11, 0, 10), 10); + }); +}); + +describe('randomName', () => { + it('builds a name from a prefix and suffix', () => { + for (let i = 0; i < 50; i++) { + const name = randomName('Fire'); + assert.match(name, /^[A-Z][a-z]{2}(mon|rax|gax|shi|kor|lyn|vex)$/); + } + }); +}); + +describe('generateTrainerId', () => { + it('matches the #RKM-XXXX format without ambiguous characters', () => { + for (let i = 0; i < 50; i++) { + assert.match(generateTrainerId(), /^#RKM-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{4}$/); + } + }); +}); diff --git a/package-lock.json b/package-lock.json index 816a6e3..5027d30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "expo-status-bar": "~56.0.4", "nativewind": "^4.2.4", "react": "19.2.3", + "react-dom": "19.2.3", "react-native": "0.85.3", "react-native-gesture-handler": "~2.31.1", "react-native-reanimated": "4.3.1", @@ -32,6 +33,7 @@ "zustand": "^5.0.13" }, "devDependencies": { + "@types/node": "^22.20.0", "@types/react": "~19.2.2", "typescript": "~6.0.3" } @@ -573,6 +575,22 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-async-generator-functions": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", @@ -946,6 +964,38 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-react-pure-annotations": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz", @@ -962,6 +1012,22 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz", + "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", @@ -991,6 +1057,38 @@ "semver": "bin/semver.js" } }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-typescript": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", @@ -2192,6 +2290,54 @@ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, + "node_modules/@react-native/babel-preset": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.85.3.tgz", + "integrity": "sha512-fD7fxEhkJB/aF57tWoXjaAWpklfrExYZS3k6aXPP3BQ77DZY7gvf/b7dbirwjID6NVnP1JDRJyTuPBGr0K/vlw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@react-native/babel-plugin-codegen": "0.85.3", + "babel-plugin-syntax-hermes-parser": "0.33.3", + "babel-plugin-transform-flow-enums": "^0.0.2", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, "node_modules/@react-native/codegen": { "version": "0.85.3", "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.3.tgz", @@ -2322,6 +2468,58 @@ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, + "node_modules/@react-native/metro-babel-transformer": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.85.3.tgz", + "integrity": "sha512-omuKq+r7jM4XvCMIlNMPP7Up3SyB8o5EAdZtF7YXniKyq7UOMBqhYHFqgsdOXr0lT+3ADf7VCJG3sb82jlBrrQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@react-native/babel-preset": "0.85.3", + "hermes-parser": "0.33.3", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz", + "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==", + "license": "MIT", + "peer": true + }, + "node_modules/@react-native/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz", + "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==", + "license": "MIT", + "peer": true, + "dependencies": { + "hermes-estree": "0.33.3" + } + }, + "node_modules/@react-native/metro-config": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.85.3.tgz", + "integrity": "sha512-sVo6HepUmCcpdfozEf91lA0FjpLNNZYu/Zi9FiYiAQTK8pzATXDVTqhvdxpFrQn435p5eUTSbllvbH/KN+bnyA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@react-native/js-polyfills": "0.85.3", + "@react-native/metro-babel-transformer": "0.85.3", + "metro-config": "^0.84.3", + "metro-runtime": "^0.84.3" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, "node_modules/@react-native/normalize-colors": { "version": "0.85.3", "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.85.3.tgz", @@ -2441,6 +2639,61 @@ "node": ">=20.0.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "license": "MIT", + "peer": true + }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -2479,6 +2732,13 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "license": "MIT", + "peer": true + }, "node_modules/@types/hammerjs": { "version": "2.0.46", "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", @@ -2510,12 +2770,12 @@ } }, "node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~6.21.0" } }, "node_modules/@types/react": { @@ -3586,6 +3846,13 @@ "integrity": "sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "license": "MIT", + "peer": true + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -5669,6 +5936,16 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -6897,6 +7174,18 @@ "ws": "^7" } }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -7372,6 +7661,32 @@ "react-native": "*" } }, + "node_modules/react-native-worklets": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.3.tgz", + "integrity": "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-classes": "^7.28.4", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/preset-typescript": "^7.27.1", + "convert-source-map": "^2.0.0", + "semver": "^7.7.3" + }, + "peerDependencies": { + "@babel/core": "*", + "@react-native/metro-config": "*", + "react": "*", + "react-native": "0.81 - 0.85" + } + }, "node_modules/react-native/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -8312,7 +8627,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -8323,9 +8638,9 @@ } }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { diff --git a/package.json b/package.json index 8b1e031..153f0fa 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "expo-status-bar": "~56.0.4", "nativewind": "^4.2.4", "react": "19.2.3", + "react-dom": "19.2.3", "react-native": "0.85.3", "react-native-gesture-handler": "~2.31.1", "react-native-reanimated": "4.3.1", @@ -27,6 +28,7 @@ "zustand": "^5.0.13" }, "devDependencies": { + "@types/node": "^22.20.0", "@types/react": "~19.2.2", "typescript": "~6.0.3" }, @@ -34,7 +36,9 @@ "start": "expo start", "android": "expo start --android", "ios": "expo start --ios", - "web": "expo start --web" + "web": "expo start --web", + "test": "node --test lib/gameLogic.test.ts lib/utils.test.ts", + "typecheck": "tsc --noEmit" }, "private": true } diff --git a/tsconfig.json b/tsconfig.json index c417704..76f79a5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,9 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "types": ["node"], "paths": { "@/*": ["./*"] }