diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index 671f480..0000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,160 +0,0 @@ -name: CD - -on: - workflow_dispatch: - inputs: - bump: - description: Stable semantic version increment - required: true - type: choice - options: - - patch - - minor - - major - -concurrency: - group: npm-release-main - cancel-in-progress: false - -jobs: - release: - name: Publish release - runs-on: ubuntu-latest - permissions: - contents: write - id-token: write - steps: - - name: Check out main - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - fetch-tags: true - - - name: Validate dispatch from current main - id: base - env: - DISPATCH_REF: ${{ github.ref }} - run: | - set -euo pipefail - - test "$DISPATCH_REF" = "refs/heads/main" - - git fetch --no-tags origin '+refs/heads/main:refs/remotes/origin/main' - - base_sha="$(git rev-parse HEAD)" - remote_main_sha="$(git rev-parse refs/remotes/origin/main)" - - test "$base_sha" = "$remote_main_sha" - - printf 'base_sha=%s\n' "$base_sha" >> "$GITHUB_OUTPUT" - - - name: Set up Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version: 24 - registry-url: https://registry.npmjs.org/ - - - name: Set up Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.5 - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Prepare release metadata - id: metadata - env: - BUMP: ${{ inputs.bump }} - run: | - set -euo pipefail - release_date="$(date -u +%F)" - git tag --list | bun scripts/release-metadata.ts \ - --package-json package.json \ - --changelog CHANGELOG.md \ - --bump "$BUMP" \ - --date "$release_date" - - - name: Run release checks - run: bun run release:check - - - name: Pack release - id: pack - run: | - set -euo pipefail - package_directory="$RUNNER_TEMP/opencode-vpet-package" - mkdir -p "$package_directory" - - tarball_name="$(npm pack --loglevel silent --ignore-scripts \ - --pack-destination "$package_directory")" - - test -f "$package_directory/$tarball_name" - - printf 'tarball_name=%s\n' "$tarball_name" >> "$GITHUB_OUTPUT" - - - name: Set up Git - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Commit and tag release - env: - RELEASE_VERSION: ${{ steps.metadata.outputs.release_version }} - run: | - set -euo pipefail - - git add package.json CHANGELOG.md - - git commit -m "chore(release): v$RELEASE_VERSION" - - git tag -a "v$RELEASE_VERSION" -m "v$RELEASE_VERSION" - - - name: Commit next version - env: - NEXT_DEV_VERSION: ${{ steps.metadata.outputs.next_dev_version }} - run: | - set -euo pipefail - - bun scripts/release-metadata.ts set-package-version package.json "$NEXT_DEV_VERSION" - - git add package.json - - git commit -m "chore: bump v$NEXT_DEV_VERSION" - - - name: Push release - env: - RELEASE_VERSION: ${{ steps.metadata.outputs.release_version }} - run: git push --atomic origin HEAD:refs/heads/main "refs/tags/v$RELEASE_VERSION" - - - name: Publish release (npm) - env: - TARBALL: ${{ runner.temp }}/opencode-vpet-package/${{ steps.pack.outputs.tarball_name }} - run: | - set -euo pipefail - - npm_version="$(npm --version)" - - NPM_VERSION="$npm_version" node --input-type=module <<'NODE' - const [major, minor, patch] = process.env.NPM_VERSION.split(".").map(Number); - if ( - major < 11 || - (major === 11 && (minor < 5 || (minor === 5 && patch < 1))) - ) { - throw new Error(`npm ${process.env.NPM_VERSION} is below required version 11.5.1`); - } - NODE - - npm publish "$TARBALL" \ - --access public --provenance --ignore-scripts - - - name: Publish release (GitHub) - env: - GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.metadata.outputs.release_version }} - TARBALL: ${{ runner.temp }}/opencode-vpet-package/${{ steps.pack.outputs.tarball_name }} - run: | - gh release create "v$RELEASE_VERSION" \ - "$TARBALL" \ - --title "v$RELEASE_VERSION" \ - --generate-notes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8457600..c532abd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI +name: Validate monorepo on: pull_request: @@ -15,35 +15,38 @@ concurrency: jobs: validate: + name: Check, test and package runs-on: ubuntu-latest steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Check out source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: - node-version: 24 + node-version: 24.11.0 + cache: npm - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.5 - name: Install dependencies - run: bun install --frozen-lockfile + run: npm ci - - name: Format - run: bun run format:check + - name: Check formatting, lint and types + run: npm run check - - name: Lint - run: bun run lint + - name: Run unit tests + run: npm run test - - name: CI checks - run: bun run check + - name: Run persistence tests + run: npm run test:persistence - - name: Tests - run: bun test + - name: Inspect npm package + run: npm pack --workspace @jcendal/opencode-digital-pet --dry-run --json - - name: Prepack - run: bun run prepack - - - name: Pack - run: npm pack --dry-run --json + - name: Package Cursor VSIX + working-directory: packages/cursor-digital-pet + run: npx @vscode/vsce package --no-dependencies diff --git a/.github/workflows/release-cursor.yml b/.github/workflows/release-cursor.yml new file mode 100644 index 0000000..008294b --- /dev/null +++ b/.github/workflows/release-cursor.yml @@ -0,0 +1,202 @@ +name: Release Cursor Digital Pet + +on: + workflow_dispatch: + inputs: + bump: + description: Version increment for a new release (ignored when retry_tag is set) + required: false + type: choice + options: + - patch + - minor + - major + retry_tag: + description: Existing Cursor release tag to retry (for example cursor-digital-pet-v0.2.0) + required: false + type: string + retry_target: + description: Destination to retry when retry_tag is set + required: false + type: choice + options: + - github + - openvsx + bootstrap_openvsx: + description: Use the first-release Open VSX PAT when retrying its tag + required: false + type: boolean + default: false + +concurrency: + group: digital-pet-release-main + cancel-in-progress: false + +jobs: + release: + name: Publish Cursor Digital Pet + if: github.repository == 'jcendal/digital-pet' + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - name: Check out release source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + fetch-tags: true + + - name: Validate new release or retry + id: mode + env: + DISPATCH_REF: ${{ github.ref }} + BUMP: ${{ inputs.bump }} + RETRY_TAG: ${{ inputs.retry_tag }} + RETRY_TARGET: ${{ inputs.retry_target }} + BOOTSTRAP_INPUT: ${{ inputs.bootstrap_openvsx }} + run: | + set -euo pipefail + test "$DISPATCH_REF" = "refs/heads/main" + git fetch --no-tags origin '+refs/heads/main:refs/remotes/origin/main' + if [[ -n "$RETRY_TAG" ]]; then + [[ "$RETRY_TAG" =~ ^cursor-digital-pet-v[0-9]+\.[0-9]+\.[0-9]+$ ]] + case "$RETRY_TARGET" in github|openvsx) ;; *) exit 1 ;; esac + git show-ref --verify --quiet "refs/tags/$RETRY_TAG" + git merge-base --is-ancestor "$RETRY_TAG^{commit}" refs/remotes/origin/main + git checkout --detach "$RETRY_TAG" + release_version="${RETRY_TAG#cursor-digital-pet-v}" + test "$(node -p 'require("./packages/cursor-digital-pet/package.json").version')" = "$release_version" + printf 'retry=true\nrelease_version=%s\n' "$release_version" >> "$GITHUB_OUTPUT" + bootstrap_openvsx="$BOOTSTRAP_INPUT" + else + [[ "$BUMP" =~ ^(patch|minor|major)$ ]] + test "$(git rev-parse HEAD)" = "$(git rev-parse refs/remotes/origin/main)" + printf 'retry=false\n' >> "$GITHUB_OUTPUT" + if [ -z "$(git tag --list 'cursor-digital-pet-v*')" ]; then + bootstrap_openvsx=true + else + bootstrap_openvsx=false + fi + fi + printf 'bootstrap_openvsx=%s\n' "$bootstrap_openvsx" >> "$GITHUB_OUTPUT" + + - name: Verify first-release Open VSX credential + if: steps.mode.outputs.bootstrap_openvsx == 'true' && (steps.mode.outputs.retry != 'true' || inputs.retry_target == 'openvsx') + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: | + if [ -z "$OVSX_PAT" ]; then + echo 'Configure the temporary OVSX_PAT repository secret for the first Open VSX release.' >&2 + exit 1 + fi + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24.11.0 + package-manager-cache: false + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.5 + + - name: Install dependencies + run: npm ci + + - name: Prepare release metadata + if: steps.mode.outputs.retry != 'true' + id: metadata + env: + BUMP: ${{ inputs.bump }} + run: | + set -euo pipefail + release_date="$(date -u +%F)" + git tag --list | node --experimental-strip-types scripts/release-metadata.ts \ + --package-json packages/cursor-digital-pet/package.json \ + --changelog packages/cursor-digital-pet/CHANGELOG.md \ + --tag-prefix cursor-digital-pet-v \ + --bump "$BUMP" \ + --date "$release_date" + + - name: Update lockfile + if: steps.mode.outputs.retry != 'true' + run: npm install --package-lock-only --ignore-scripts + + - name: Run release checks + run: | + npm run check + npm run test --workspace cursor-digital-pet + + - name: Build cursor-digital-pet + run: npm run build --workspace cursor-digital-pet + + - name: Pack VSIX + id: vsix + run: | + set -euo pipefail + vsix_path="$RUNNER_TEMP/cursor-digital-pet.vsix" + cd packages/cursor-digital-pet + npx @vscode/vsce package --no-dependencies -o "$vsix_path" + printf 'vsix_path=%s\n' "$vsix_path" >> "$GITHUB_OUTPUT" + + - name: Set up Git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Commit and tag release + if: steps.mode.outputs.retry != 'true' + env: + RELEASE_VERSION: ${{ steps.metadata.outputs.release_version }} + run: | + set -euo pipefail + git add packages/cursor-digital-pet/package.json packages/cursor-digital-pet/CHANGELOG.md package-lock.json + git commit -m "chore(cursor release): v$RELEASE_VERSION" + git tag -a "cursor-digital-pet-v$RELEASE_VERSION" -m "Cursor Digital Pet v$RELEASE_VERSION" + + - name: Commit next version + if: steps.mode.outputs.retry != 'true' + env: + NEXT_DEV_VERSION: ${{ steps.metadata.outputs.next_dev_version }} + run: | + set -euo pipefail + node --experimental-strip-types scripts/release-metadata.ts set-package-version packages/cursor-digital-pet/package.json "$NEXT_DEV_VERSION" + npm install --package-lock-only --ignore-scripts + git add packages/cursor-digital-pet/package.json package-lock.json + git commit -m "chore(cursor): bump v$NEXT_DEV_VERSION" + + - name: Push release + if: steps.mode.outputs.retry != 'true' + env: + RELEASE_VERSION: ${{ steps.metadata.outputs.release_version }} + run: git push --atomic origin HEAD:refs/heads/main "refs/tags/cursor-digital-pet-v$RELEASE_VERSION" + + - name: Publish release (GitHub) + if: steps.mode.outputs.retry != 'true' || inputs.retry_target == 'github' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ steps.metadata.outputs.release_version || steps.mode.outputs.release_version }} + run: | + node --experimental-strip-types scripts/release-metadata.ts release-notes packages/cursor-digital-pet/CHANGELOG.md "$RELEASE_VERSION" > "$RUNNER_TEMP/cursor-release-notes.md" + if gh release view "cursor-digital-pet-v$RELEASE_VERSION" >/dev/null 2>&1; then + gh release upload "cursor-digital-pet-v$RELEASE_VERSION" \ + "${{ steps.vsix.outputs.vsix_path }}" --clobber + else + gh release create "cursor-digital-pet-v$RELEASE_VERSION" \ + "${{ steps.vsix.outputs.vsix_path }}" \ + --title "Cursor Digital Pet v$RELEASE_VERSION" \ + --notes-file "$RUNNER_TEMP/cursor-release-notes.md" + fi + + - name: Publish release (Open VSX via OIDC) + if: steps.mode.outputs.bootstrap_openvsx != 'true' && (steps.mode.outputs.retry != 'true' || inputs.retry_target == 'openvsx') + run: npx ovsx publish "${{ steps.vsix.outputs.vsix_path }}" --trusted-publishing + + - name: Publish first release (Open VSX via PAT) + if: steps.mode.outputs.bootstrap_openvsx == 'true' && (steps.mode.outputs.retry != 'true' || inputs.retry_target == 'openvsx') + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: npx ovsx publish "${{ steps.vsix.outputs.vsix_path }}" diff --git a/.github/workflows/release-opencode.yml b/.github/workflows/release-opencode.yml new file mode 100644 index 0000000..2d4c05f --- /dev/null +++ b/.github/workflows/release-opencode.yml @@ -0,0 +1,200 @@ +name: Release OpenCode Digital Pet + +on: + workflow_dispatch: + inputs: + bump: + description: Version increment for a new release (ignored when retry_tag is set) + required: false + type: choice + options: + - patch + - minor + - major + retry_tag: + description: Existing OpenCode release tag to retry (for example opencode-digital-pet-v0.2.0) + required: false + type: string + retry_target: + description: Registry to retry when retry_tag is set + required: false + type: choice + options: + - npm + - github + +concurrency: + group: digital-pet-release-main + cancel-in-progress: false + +jobs: + release: + name: Publish OpenCode Digital Pet + if: github.repository == 'jcendal/digital-pet' + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - name: Check out release source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + fetch-tags: true + + - name: Validate new release or retry + id: mode + env: + DISPATCH_REF: ${{ github.ref }} + BUMP: ${{ inputs.bump }} + RETRY_TAG: ${{ inputs.retry_tag }} + RETRY_TARGET: ${{ inputs.retry_target }} + run: | + set -euo pipefail + test "$DISPATCH_REF" = "refs/heads/main" + git fetch --no-tags origin '+refs/heads/main:refs/remotes/origin/main' + if [[ -n "$RETRY_TAG" ]]; then + [[ "$RETRY_TAG" =~ ^opencode-digital-pet-v[0-9]+\.[0-9]+\.[0-9]+$ ]] + test "$RETRY_TARGET" = "npm" || test "$RETRY_TARGET" = "github" + git show-ref --verify --quiet "refs/tags/$RETRY_TAG" + git merge-base --is-ancestor "$RETRY_TAG^{commit}" refs/remotes/origin/main + git checkout --detach "$RETRY_TAG" + release_version="${RETRY_TAG#opencode-digital-pet-v}" + test "$(node -p 'require("./packages/opencode-digital-pet/package.json").version')" = "$release_version" + printf 'retry=true\nrelease_version=%s\n' "$release_version" >> "$GITHUB_OUTPUT" + else + [[ "$BUMP" =~ ^(patch|minor|major)$ ]] + test "$(git rev-parse HEAD)" = "$(git rev-parse refs/remotes/origin/main)" + printf 'retry=false\n' >> "$GITHUB_OUTPUT" + fi + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24.11.0 + package-manager-cache: false + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.5 + + - name: Install dependencies + run: npm ci + + - name: Prepare release metadata + if: steps.mode.outputs.retry != 'true' + id: metadata + env: + BUMP: ${{ inputs.bump }} + run: | + set -euo pipefail + release_date="$(date -u +%F)" + git tag --list | node --experimental-strip-types scripts/release-metadata.ts \ + --package-json packages/opencode-digital-pet/package.json \ + --changelog packages/opencode-digital-pet/CHANGELOG.md \ + --tag-prefix opencode-digital-pet-v \ + --legacy-tag-prefix v \ + --bump "$BUMP" \ + --date "$release_date" + + - name: Update lockfile + if: steps.mode.outputs.retry != 'true' + run: | + npm install --package-lock-only --ignore-scripts + + - name: Run release checks + run: npm run release:check + + - name: Pack release + id: pack + run: | + set -euo pipefail + package_directory="$RUNNER_TEMP/opencode-digital-pet-package" + mkdir -p "$package_directory" + + tarball_name="$(npm pack --loglevel silent \ + --pack-destination "$package_directory" \ + --workspace @jcendal/opencode-digital-pet)" + + test -f "$package_directory/$tarball_name" + + printf 'tarball_name=%s\n' "$tarball_name" >> "$GITHUB_OUTPUT" + + - name: Set up Git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Commit and tag release + if: steps.mode.outputs.retry != 'true' + env: + RELEASE_VERSION: ${{ steps.metadata.outputs.release_version }} + run: | + set -euo pipefail + + git add packages/opencode-digital-pet/package.json packages/opencode-digital-pet/CHANGELOG.md package-lock.json + + git commit -m "chore(opencode release): v$RELEASE_VERSION" + + git tag -a "opencode-digital-pet-v$RELEASE_VERSION" -m "OpenCode Digital Pet v$RELEASE_VERSION" + + - name: Commit next version + if: steps.mode.outputs.retry != 'true' + env: + NEXT_DEV_VERSION: ${{ steps.metadata.outputs.next_dev_version }} + run: | + set -euo pipefail + + node --experimental-strip-types scripts/release-metadata.ts set-package-version packages/opencode-digital-pet/package.json "$NEXT_DEV_VERSION" + npm install --package-lock-only --ignore-scripts + + git add packages/opencode-digital-pet/package.json package-lock.json + + git commit -m "chore(opencode): bump v$NEXT_DEV_VERSION" + + - name: Push release + if: steps.mode.outputs.retry != 'true' + env: + RELEASE_VERSION: ${{ steps.metadata.outputs.release_version }} + run: git push --atomic origin HEAD:refs/heads/main "refs/tags/opencode-digital-pet-v$RELEASE_VERSION" + + - name: Publish release (npm) + if: steps.mode.outputs.retry != 'true' || inputs.retry_target == 'npm' + env: + TARBALL: ${{ runner.temp }}/opencode-digital-pet-package/${{ steps.pack.outputs.tarball_name }} + run: | + set -euo pipefail + + npm_version="$(npm --version)" + + NPM_VERSION="$npm_version" node --input-type=module <<'NODE' + const [major, minor, patch] = process.env.NPM_VERSION.split(".").map(Number); + if ( + major < 11 || + (major === 11 && (minor < 5 || (minor === 5 && patch < 1))) + ) { + throw new Error(`npm ${process.env.NPM_VERSION} is below required version 11.5.1`); + } + NODE + + npm publish "$TARBALL" \ + --access public --provenance --ignore-scripts + + - name: Publish release (GitHub) + if: steps.mode.outputs.retry != 'true' || inputs.retry_target == 'github' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ steps.metadata.outputs.release_version || steps.mode.outputs.release_version }} + TARBALL: ${{ runner.temp }}/opencode-digital-pet-package/${{ steps.pack.outputs.tarball_name }} + run: | + node --experimental-strip-types scripts/release-metadata.ts release-notes packages/opencode-digital-pet/CHANGELOG.md "$RELEASE_VERSION" > "$RUNNER_TEMP/opencode-release-notes.md" + if gh release view "opencode-digital-pet-v$RELEASE_VERSION" >/dev/null 2>&1; then + gh release upload "opencode-digital-pet-v$RELEASE_VERSION" "$TARBALL" --clobber + else + gh release create "opencode-digital-pet-v$RELEASE_VERSION" \ + "$TARBALL" \ + --title "OpenCode Digital Pet v$RELEASE_VERSION" \ + --notes-file "$RUNNER_TEMP/opencode-release-notes.md" + fi diff --git a/.gitignore b/.gitignore index 4af8649..d7be5bb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # Build and test output dist/ +packages/opencode-digital-pet/build/ coverage/ .turbo/ *.tsbuildinfo @@ -15,6 +16,29 @@ Thumbs.db # Coding agents .omo/ +# Local configuration and credentials +.env* +!.env.example +.npmrc +.pypirc +.netrc +*.pem +*.key +*.p12 + +# Local user data and logs +.cursor/ +.opencode/ +*.db +*.db-* +*.sqlite +*.sqlite3 +*.jsonl +*.log + # Release workflow artifacts +release-artifacts/ *.tgz +*.vsix npm-pack.json +.package.json.publish.bak diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..2438910 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,6 @@ +# Digital Pet + +Maintainer: Jorge Cendal Prego (https://github.com/jcendal) +Original author and contributor: Sergio Bugallo Enjamio (https://github.com/sbugallo) + +The original Apache 2.0 copyright notice remains in LICENSE. diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d12c6..4d8e1ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,39 +1,6 @@ -# Changelog +# Changelogs -All notable changes to this project will be documented in this file. +The published products have independent release histories: -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -## [0.1.1] - 2026-08-30 - -### Fixed - -- Exported the OpenCode server plugin entrypoint so VPet commands and the sidebar load correctly. -- Updated `init` and `update` to delegate to OpenCode's native global plugin installer with the - installed exact package version, avoiding stale bare or `@latest` cache reuse. - -## [0.1.0] - 2026-08-30 - -### Added - -- Server and TUI plugins that add a Digimon virtual pet to the OpenCode sidebar. -- Progression from completed assistant-message token usage, including reconciliation of completed - usage from concurrent sessions. -- Toast notifications for spawning, progression controls, and Digimon evolutions. -- A 650-Digimon catalog spanning eight evolution stages, with evolution paths and Japanese and - English names. -- Partner generations that can be spawned, evolved, frozen, unfrozen, or set to a catalog ID. -- Sidebar progress and status display, including frozen and manually set partner states, with - animated walking, action, and sleep behavior. -- Dex and History dialogs for browsing discovered Digimon and current or retired partner - generations, with links to external encyclopedia entries. -- `/vpet-spawn`, `/vpet-freeze`, `/vpet-unfreeze`, `/vpet-set `, `/vpet-dex`, and - `/vpet-history` commands. -- SQLite persistence for partners, progression events, usage receipts, trainer totals, and control - state. -- Global settings for language, evolution thresholds, and notifications. -- `npx @sbugallo/opencode-vpet init` and `npx @sbugallo/opencode-vpet update` commands that register - server and TUI plugins while preserving unrelated JSON and JSONC configuration. +- [OpenCode Digital Pet](packages/opencode-digital-pet/CHANGELOG.md) +- [Cursor Digital Pet](packages/cursor-digital-pet/CHANGELOG.md) diff --git a/README.md b/README.md index 8632212..1941aa0 100644 --- a/README.md +++ b/README.md @@ -1,832 +1,69 @@
- OpenCode VPet logo -

opencode-vpet

-

- A Digimon virtual pet for OpenCode that evolves with usage. -

-

- About | - Evolution | - Quick start | - Commands | - Settings | - Storage | - Partner list | - License | - Attributions - -

+ Digital Pet logo +

Digital Pet

+

A Digimon virtual pet that evolves with AI token usage in Cursor and OpenCode.

---- - -## About - -
- OpenCode VPet Overview -
- -`@sbugallo/opencode-vpet` is an OpenCode v1 plugin that adds a Digimon virtual pet to your OpenCode TUI -sidebar. Your Digimon evolves as you use OpenCode, and you can browse your discoveries and -partner history. - -The plugin support multiple concurrent sessions, so every token counts to evolve your partner. - -There are more than 600 Digimon to discover, and the plugin is designed to be extensible so that new Digimon can be added in future releases. - -## Evolution +Digital Pet has two apps in one monorepo: a Cursor extension and an OpenCode plugin. Both use the same local SQLite database, so your partner, Dex discoveries, and generation history carry across the two tools. You can use either app on its own. -Your active partner gains experience from the tokens reported for completed assistant -messages in OpenCode. This includes input, output, reasoning, and cache tokens. -Progress is shared across concurrent OpenCode sessions, so completed work in each -session contributes to the same partner. +## Choose your app -Each stage has a configurable experience threshold. When your partner reaches the -threshold for its current stage, it evolves and its experience gauge resets for the -next stage. Use `/vpet-freeze` to pause partner progression; usage while frozen is -not applied to your partner. - -The bundled evolution lines were extracted from the original V-Pets. When a Digimon -reaches an evolution threshold, the plugin randomly chooses one of the evolution -options in that Digimon's line. - -## Quick start - -Requirements: -- OpenCode `>=1.18.10` -- Node `>=20` to run `npx` -- Bun `>=1.3.5` only for local development - - -1. Install both plugins: +| App | What it does | Get started | +| --- | --- | --- | +| [Cursor Digital Pet](packages/cursor-digital-pet/README.md) | Shows your partner in the Explorer sidebar and tracks Cursor Agent usage through hooks, with an API fallback. | [Install from Open VSX](https://open-vsx.org/extension/jcendal/cursor-digital-pet) or use a VSIX from [GitHub Releases](https://github.com/jcendal/digital-pet/releases). Follow the [Cursor setup guide](packages/cursor-digital-pet/README.md#-usage) to install hooks and hatch a partner. | +| [OpenCode Digital Pet](packages/opencode-digital-pet/README.md) | Adds a TUI sidebar and `/digital-pet-*` commands; completed assistant messages provide experience. | Run `npx @jcendal/opencode-digital-pet init`, restart OpenCode, then run `/digital-pet-spawn`. See the [OpenCode guide](packages/opencode-digital-pet/README.md#quick-start). | - ```sh - npx @sbugallo/opencode-vpet init - ``` +The [illustrative OpenCode preview](_images/digital-pet-overview.png) shows the partner, Dex, and history views. Each app's README describes its own commands, requirements, settings, and storage behavior. - The CLI delegates to `opencode plugin @sbugallo/opencode-vpet@ --global --force`. - It uses the package's installed exact version so OpenCode uses a version-specific cache key rather - than reusing a stale bare or `@latest` cache entry. `--force` replaces matching plugin - configuration registrations. +## Shared progress and migration -2. Restart OpenCode when the command finishes. +Both apps resolve the same `pet.db` location by default: -3. In OpenCode, run `/vpet-spawn` to hatch a partner. +| Platform | Database | +| --- | --- | +| macOS | `~/Library/Application Support/opencode-digital-pet/pet.db` | +| Linux / WSL | `~/.local/share/opencode-digital-pet/pet.db` | +| Windows | `%APPDATA%\opencode-digital-pet\pet.db` | -After that, use `/vpet-dex` to browse discoveries and `/vpet-history` to browse current and retired generations. +If the new database does not exist but an older `opencode-vpet/pet.db` does, both apps continue using that older database. OpenCode also continues reading an older `opencode-vpet.json` settings file until a new `opencode-digital-pet.json` is created. ---- +If you previously installed `@sbugallo/opencode-vpet` or unscoped `opencode-vpet`, remove its OpenCode registration to avoid loading both plugins. The [OpenCode guide](packages/opencode-digital-pet/README.md#update) has the update command; the [Cursor guide](packages/cursor-digital-pet/README.md#-storage) covers its database override. -## Commands +## Repository layout -| Command | Description | +| Workspace | Role | | --- | --- | -| `/vpet-spawn` | Spawns a new egg. | -| `/vpet-freeze` | Pauses partner progression. | -| `/vpet-unfreeze` | Resumes partner progression. | -| `/vpet-set ` | Overrides the partner with the specified catalog ID. | -| `/vpet-dex` | Opens the partner Dex. | -| `/vpet-history` | Opens the partner History. | +| [`packages/cursor-digital-pet`](packages/cursor-digital-pet) | Cursor extension, packaged as a VSIX | +| [`packages/opencode-digital-pet`](packages/opencode-digital-pet) | `@jcendal/opencode-digital-pet`, npm plugin and CLI | +| [`packages/digital-pet-core`](packages/digital-pet-core) | Shared catalog, evolution rules, use cases, and SQLite schema; private workspace | +| [`packages/digital-pet-animation`](packages/digital-pet-animation) | Shared animation logic; private workspace | -> [!NOTE] -> Using `/vpet-set` does not count for dex completion. +The root workspace is private. The two shared packages are bundled into the products and are not published separately. Cursor and OpenCode have [separate changelogs](CHANGELOG.md) and release versions. See [monorepo documentation](docs/monorepo.md) for development tools and release details. -## Update +## Development -To update the plugin, run: +Use Node `24.11.0`, npm `11.6.1`, and Bun `1.3.5` (the versions declared by the workspace). npm installs dependencies and runs workspace scripts; Bun runs the tests and the OpenCode plugin at runtime. Builds use Node and esbuild. ```sh -npx @sbugallo/opencode-vpet update +npm ci +npm run check +npm run test +npm run test:persistence +npm run build ``` -Like `init`, `update` delegates to OpenCode's native global plugin installer with the installed exact -package version. If a legacy unscoped `opencode-vpet` registration is present, remove it manually. - -## Settings - -Settings are stored in `opencode-vpet.json` and control naming, notifications, and evolution -thresholds. +To create local distributables: -The location of the settings file depends on your operating system: - -- Linux, WSL, and macOS: `~/.config/opencode-vpet.json` -- Windows: `%APPDATA%\opencode-vpet.json`, or `%USERPROFILE%\AppData\Roaming\opencode-vpet.json` - when `APPDATA` isn't set. - - -Available settings are: - -| Setting | Description | -| --- | --- | -| `notifications` | Enables or disables notifications. | -| `language` | Sets the naming convention. Valid values are `jp` (Japanese, e.g., Omegamon, Child, Adult...) and `en` (English, e.g., Omnimon, Rookie, Champion...). | -| `stageThresholds` | Defines the experience thresholds for each evolution stage. | - -Default values are: - -```json -{ - "notifications": true, - "language": "jp", - "stageThresholds": { - "egg": 500000, - "babyI": 1000000, - "babyII": 2000000, - "child": 4000000, - "adult": 7500000, - "perfect": 12500000, - "ultimate": 20000000, - "superUltimate": 30000000 - } -} +```sh +npm run pack:opencode +npm run package --workspace cursor-digital-pet ``` -## Storage - -To persist your partner, history, and control state, VPet uses a SQLite database named `pet.db`. The location of the database depends on your operating system: - -- Linux, WSL: `~/.local/share/opencode-vpet/pet.db` -- macOS: `~/Library/Application Support/opencode-vpet/pet.db` -- Windows: `%APPDATA%\opencode-vpet\pet.db`, or `%USERPROFILE%\AppData\Roaming\opencode-vpet\pet.db` - when `APPDATA` isn't set. - ---- - -## Partner list - -| ID | Japanese name | English name | -| --- | --- | --- | -| 0-001 | Digitama | Digiegg | -| 1-001 | Algomon | Argomon | -| 1-002 | Botamon | Botamon | -| 1-003 | Chicomon | Chibomon | -| 1-004 | Choromon | Choromon | -| 1-005 | Cocomon | Conomon | -| 1-006 | Cotsucomon | Cotsucomon | -| 1-007 | Dodomon | Dodomon | -| 1-008 | Fufumon | Fufumon | -| 1-009 | Kiimon | Keemon | -| 1-010 | Ketomon | Ketomon | -| 1-011 | Kuramon | Kuramon | -| 1-012 | Mokumon | Mokumon | -| 1-013 | Nyokimon | Nyokimon | -| 1-014 | Bubbmon | Pabumon | -| 1-015 | Petitmon | Petitmon | -| 1-016 | Pitchmon | Pichimon | -| 1-017 | Poyomon | Poyomon | -| 1-018 | Punimon | Punimon | -| 1-019 | Pururumon | Pururumon | -| 1-020 | Puttimon | Puttimon | -| 1-021 | Sakumon | Sakumon | -| 1-022 | Tsubumon | Tsubumon | -| 1-023 | Yukimibotamon | YukimiBotamon | -| 1-024 | Yuramon | Yuramon | -| 1-025 | Zerimon | Zerimon | -| 1-026 | Zurumon | Zurumon | -| 1-027 | Chibickmon | Chibickmon | -| 1-028 | Dokimon | Dokimon | -| 1-029 | Jyarimon | Jyarimon | -| 1-030 | Relemon | Relemon | -| 1-031 | Sunamon | Sandmon | -| 2-001 | Algomon | Argomon | -| 2-002 | Babydmon | Bebydomon | -| 2-003 | Budmon | Budmon | -| 2-004 | Pukamon | Bukamon | -| 2-005 | PetiMeramon | DemiMeramon | -| 2-006 | Chibimon | DemiVeemon | -| 2-007 | Dorimon | Dorimon | -| 2-008 | Gummymon | Gummymon | -| 2-009 | Hopmon | Hopmon | -| 2-010 | Kakkinmon | Kakkinmon | -| 2-011 | Capromon | Kapurimon | -| 2-012 | Chocomon | Kokomon | -| 2-013 | Koromon | Koromon | -| 2-014 | Kyokyomon | Kyokyomon | -| 2-015 | Mochimon | Motimon | -| 2-016 | Nyaromon | Nyaromon | -| 2-017 | Pagumon | Pagumon | -| 2-018 | Poromon | Poromon | -| 2-019 | Sakuttomon | Sakuttomon | -| 2-020 | Tanemon | Tanemon | -| 2-021 | Tokomon | Tokomon | -| 2-022 | Tokomon X | Tokomon X | -| 2-023 | Tsumemon | Tsumemon | -| 2-024 | Tunomon | Tsunomon | -| 2-025 | Upamon | Upamon | -| 2-026 | Wanyamon | Wanyamon | -| 2-027 | Yarmon | Yaamon | -| 2-028 | Pyocomon | Yokomon | -| 2-029 | Bibimon | Bibimon | -| 2-030 | Gigimon | Gigimon | -| 2-031 | Goromon | Tumblemon | -| 2-032 | Hiyarimon | Hiyarimon | -| 2-033 | Minomon | Minomon | -| 2-034 | Moonmon | Moonmon | -| 2-035 | Pickmon | Pickmon | -| 2-036 | Pokomon | Viximon | -| 2-037 | Sunmon | Sunmon | -| 3-001 | Agumon | Agumon | -| 3-002 | Agumon (Black) X | Agumon (Black) X | -| 3-003 | Agumon Hakase | Agumon Expert | -| 3-004 | Agumon X | Agumon X | -| 3-005 | Algomon | Argomon | -| 3-006 | Armadimon | Armadillomon | -| 3-007 | Betamon | Betamon | -| 3-008 | Piyomon | Biyomon | -| 3-009 | Blucomon | Bulucomon | -| 3-010 | BushiAgumon | BushiAgumon | -| 3-011 | Candmon | Candlemon | -| 3-012 | Coronamon | Coronamon | -| 3-013 | Ganimon | Crabmon | -| 3-014 | PicoDevimon | DemiDevimon | -| 3-015 | DORUmon | Dorumon | -| 3-016 | Dracomon | Dracomon | -| 3-017 | Dracomon X | Dracomon X | -| 3-018 | Duskmon | Duskmon | -| 3-019 | Elecmon | Elecmon | -| 3-020 | Falcomon | Falcomon | -| 3-021 | Floramon | Floramon | -| 3-022 | Gabumon | Gabumon | -| 3-023 | Gabumon X | Gabumon X | -| 3-024 | Gaomon | Gaomon | -| 3-025 | Tailmon | Gatomon | -| 3-026 | Gazimon | Gazimon | -| 3-027 | Ghostmon | Ghostmon | -| 3-028 | Gizamon | Gizamon | -| 3-029 | Gomamon | Gomamon | -| 3-030 | Gomamon X | Gomamon X | -| 3-031 | Gottsumon | Gotsumon | -| 3-032 | Guilmon | Guilmon | -| 3-033 | Hagurumon | Hagurumon | -| 3-034 | Hawkmon | Hawkmon | -| 3-035 | Herissmon | Herissmon | -| 3-036 | Hackmon | Huckmon | -| 3-037 | Impmon | Impmon | -| 3-038 | Impmon X | Impmon X | -| 3-039 | Jazamon | Jazamon | -| 3-040 | Junkmon | Junkmon | -| 3-041 | Keramon X | Keramon X | -| 3-042 | Kokuwamon | Kokuwamon | -| 3-043 | Kokuwamon X | Kokuwamon X | -| 3-044 | Kunemon | Kunemon | -| 3-045 | Labramon | Labramon | -| 3-046 | Lalamon | Lalamon | -| 3-047 | Lopmon | Lopmon | -| 3-048 | Lopmon X | Lopmon X | -| 3-049 | Ludomon | Ludomon | -| 3-050 | Lunamon | Lunamon | -| 3-051 | Monodramon | Monodramon | -| 3-052 | Morphomon | Morphomon | -| 3-053 | Mushmon | Mushroomon | -| 3-054 | Otamamon | Otamamon | -| 3-055 | Otamamon X | Otamamon X | -| 3-056 | Palmon | Palmon | -| 3-057 | Palmon X | Palmon X | -| 3-058 | Patamon | Patamon | -| 3-059 | Phascomon | Phascomon | -| 3-060 | Pomumon | Pomumon | -| 3-061 | Renamon X | Renamon X | -| 3-062 | Ryudamon | Ryudamon | -| 3-063 | Plotmon | Salamon | -| 3-064 | Plotmon X | Salamon X | -| 3-065 | Sangomon | Sangomon | -| 3-066 | Shoutmon | Shoutmon | -| 3-067 | Sistermon Blanc | Sistermon Blanc | -| 3-068 | Sunarizamon | Sunarizamon | -| 3-069 | Swimmon | Swimmon | -| 3-070 | Shakomon | Syakomon | -| 3-071 | Shakomon X | Syakomon X | -| 3-072 | Bakumon | Tapirmon | -| 3-073 | Tentomon | Tentomon | -| 3-074 | Terriermon | Terriermon | -| 3-075 | Terriermon X | Terriermon X | -| 3-076 | Tinkermon | Tinkermon | -| 3-077 | ToyAgumon | ToyAgumon | -| 3-078 | V-mon | Veemon | -| 3-079 | Vorvomon | Vorvomon | -| 3-080 | Wormmon | Wormmon | -| 3-081 | Zubamon | Zubamon | -| 3-082 | Agumon (Black) | Agumon (Black) | -| 3-083 | Dracumon | Dracmon | -| 3-084 | Pulsemon | Pulsemon | -| 3-085 | Renamon | Renamon | -| 4-001 | Agnimon | Agunimon | -| 4-002 | Airdramon | Airdramon | -| 4-003 | Allomon X | Allomon X | -| 4-004 | Angemon | Angemon | -| 4-005 | Ankylomon | Ankylomon | -| 4-006 | Hanumon | Apemon | -| 4-007 | Aquilamon | Aquilamon | -| 4-008 | Algomon | Argomon | -| 4-009 | Arresterdramon | Arresterdramon | -| 4-010 | Baboongamon | Baboongamon | -| 4-011 | Bakemon | Bakemon | -| 4-012 | Baluchimon | Baluchimon | -| 4-013 | BaoHuckmon | BaoHuckmon | -| 4-014 | Birdramon | Birdramon | -| 4-015 | Centalmon | Centarumon | -| 4-016 | Clockmon | Clockmon | -| 4-017 | Coelamon | Coelamon | -| 4-018 | Coredramon (Blue) | Coredramon (Blue) | -| 4-019 | Coredramon (Green) | Coredramon (Green) | -| 4-020 | Cyclomon | Cyclonemon | -| 4-021 | Damemon | Damemon | -| 4-022 | Darcmon | Darcmon | -| 4-023 | DarkTyranomon | DarkTyrannomon | -| 4-024 | DarkTyranomon X | DarkTyrannomon X | -| 4-025 | Deltamon | Deltamon | -| 4-026 | Revolmon | Deputymon | -| 4-027 | Devidramon | Devidramon | -| 4-028 | Devimon | Devimon | -| 4-029 | Diatrymon | Diatrymon | -| 4-030 | Dobermon | Dobermon | -| 4-031 | Dokugumon | Dokugumon | -| 4-032 | Rukamon | Dolphmon | -| 4-033 | DORUgamon | Dorugamon | -| 4-034 | Drimogemon | Drimogemon | -| 4-035 | Duskmon | Duskmon | -| 4-036 | Ebidramon | Ebidramon | -| 4-037 | Eosmon | Eosmon | -| 4-038 | XV-mon | ExVeemon | -| 4-039 | Filmon | Filmon | -| 4-040 | Firamon | Firamon | -| 4-041 | FlareLizamon | Flarerizamon | -| 4-042 | Flymon | Flymon | -| 4-043 | Yukidarumon | Frigimon | -| 4-044 | Gaogamon | Gaogamon | -| 4-045 | Galgomon | Gargomon | -| 4-046 | Garurumon | Garurumon | -| 4-047 | Tailmon | Gatomon | -| 4-048 | Tailmon X | Gatomon X | -| 4-049 | Gekomon | Gekomon | -| 4-050 | GeoGreymon | GeoGreymon | -| 4-051 | Gesomon | Gesomon | -| 4-052 | Ginryumon | Ginryumon | -| 4-053 | Golemon | Golemon | -| 4-054 | Greymon | Greymon | -| 4-055 | Growmon | Growlmon | -| 4-056 | Growmon X | Growlmon X | -| 4-057 | Guardromon | Guardromon | -| 4-058 | Hookmon | Hookmon | -| 4-059 | Hudiemon | Hudiemon | -| 4-060 | Ikkakumon | Ikkakumon | -| 4-061 | Jazardmon | Jazardmon | -| 4-062 | Kabuterimon | Kabuterimon | -| 4-063 | Kiwimon | Kiwimon | -| 4-064 | Cockatrimon | Kokatorimon | -| 4-065 | Komondomon | Komondomon | -| 4-066 | Kuwagamon | Kuwagamon | -| 4-067 | Kuwagamon X | Kuwagamon X | -| 4-068 | Lavorvomon | Lavorvomon | -| 4-069 | Lekismon | Lekismon | -| 4-070 | Leomon | Leomon | -| 4-071 | Leomon X | Leomon X | -| 4-072 | Wolfmon | Lobomon | -| 4-073 | Machmon | Machmon | -| 4-074 | MadLeomon | MadLeomon | -| 4-075 | Manbomon | Manbomon | -| 4-076 | Mantaraymon X | Mantaraymon X | -| 4-077 | Meicoomon | Meicoomon | -| 4-078 | Mechanorimon | Mekanorimon | -| 4-079 | Meramon | Meramon | -| 4-080 | Meramon X | Meramon X | -| 4-081 | Mimicmon | Mimicmon | -| 4-082 | Minotaurmon | Minotarumon | -| 4-083 | Mojyamon | Mojyamon | -| 4-084 | Monochromon | Monochromon | -| 4-085 | Nanimon | Nanimon | -| 4-086 | Nefertimon X | Nefertimon X | -| 4-087 | Igamon | Ninjamon | -| 4-088 | Numemon | Numemon | -| 4-089 | Numemon X | Numemon X | -| 4-090 | Octmon | Octomon | -| 4-091 | Orgemon | Ogremon | -| 4-092 | Orgemon X | Ogremon X | -| 4-093 | Omekamon | Omekamon | -| 4-094 | Paledramon | Paledramon | -| 4-095 | Parasaurmon | Parasaurmon | -| 4-096 | Peckmon | Peckmon | -| 4-097 | Pegasmon X | Pegasusmon X | -| 4-098 | Petermon | Petermon | -| 4-099 | Porcupamon | Porcupamon | -| 4-100 | Pteranomon X | Pteramon X | -| 4-101 | Raremon | Raremon | -| 4-102 | RedVegimon | RedVegiemon | -| 4-103 | Reppamon | Reppamon | -| 4-104 | Rhinomon X | Rhinomon X | -| 4-105 | Gokimon | Roachmon | -| 4-106 | Sangloupmon | Sangloupmon | -| 4-107 | Seadramon | Seadramon | -| 4-108 | Seadramon X | Seadramon X | -| 4-109 | Siesamon X | Seasarmon X | -| 4-110 | Shadramon | Shadramon | -| 4-111 | Shellmon | Shellmon | -| 4-112 | KaratukiNumemon | ShellNumemon | -| 4-113 | Shoutmon X3 | Shoutmon X3 | -| 4-114 | Sistermon Ciel | Sistermon Ciel | -| 4-115 | Starmon | Starmon | -| 4-116 | Stingmon | Stingmon | -| 4-117 | Strikedramon | Strikedramon | -| 4-118 | Submarimon | Submarimon | -| 4-119 | Scumon | Sukamon | -| 4-120 | Sunflowmon | Sunflowmon | -| 4-121 | Tankmon | Tankmon | -| 4-122 | Targetmon | Targetmon | -| 4-123 | Thunderballmon | Thundermon | -| 4-124 | TiaLudomon | TiaLudomon | -| 4-125 | Tobiumon | Tobiumon | -| 4-126 | Tobucatmon | TobuCatmon | -| 4-127 | Togemon | Togemon | -| 4-128 | Togemon X | Togemon X | -| 4-129 | Tortamon | Tortomon | -| 4-130 | Troopmon | Troopmon | -| 4-131 | Turuiemon | Turuiemon | -| 4-132 | Tuskmon | Tuskmon | -| 4-133 | Tylomon | Tylomon | -| 4-134 | Tylomon X | Tylomon X | -| 4-135 | Tyranomon | Tyrannomon | -| 4-136 | Tyranomon X | Tyrannomon X | -| 4-137 | Unimon | Unimon | -| 4-138 | V-dramon | Veedramon | -| 4-139 | Vegimon | Vegiemon | -| 4-140 | Velgrmon | Velgrmon | -| 4-141 | Whamon | Whamon | -| 4-142 | Witchmon | Witchmon | -| 4-143 | Wizarmon | Wizardmon | -| 4-144 | Wizarmon X | Wizardmon X | -| 4-145 | Woodmon | Woodmon | -| 4-146 | Zubaeagermon | ZubaEagermon | -| 4-147 | Bulkmon | Bulkmon | -| 4-148 | Fugamon | Fugamon | -| 4-149 | Hyougamon | Hyogamon | -| 4-150 | Pidmon | Piddomon | -| 4-151 | Rhinomon | Rhinomon | -| 4-152 | Saberdramon | Saberdramon | -| 5-001 | AeroV-dramon | AeroVeedramon | -| 5-002 | Aldamon | Aldamon | -| 5-003 | Andromon | Andromon | -| 5-004 | Angewomon | Angewomon | -| 5-005 | Angewomon X | Angewomon X | -| 5-006 | Andiramon | Antylamon | -| 5-007 | Algomon | Argomon | -| 5-008 | Archnemon | Arukenimon | -| 5-009 | Astamon | Astamon | -| 5-010 | Asuramon | Asuramon | -| 5-011 | Baalmon | Baalmon | -| 5-012 | Beowolfmon | Beowolfmon | -| 5-013 | BigMamemon | BigMamemon | -| 5-014 | Blossomon | Blossomon | -| 5-015 | Valvemon | Bulbmon | -| 5-016 | Vritramon | BurningGreymon | -| 5-017 | CannonBeemon | CannonBeemon | -| 5-018 | Cerberumon | Cerberusmon | -| 5-019 | Cerberumon X | Cerberusmon X | -| 5-020 | Jyureimon | Cherrymon | -| 5-021 | Cho-Hakkaimon | Cho-Hakkaimon | -| 5-022 | Crescemon | Crescemon | -| 5-023 | Yatagaramon | Crowmon | -| 5-024 | CrysPaledramon | CrysPaledramon | -| 5-025 | Cyberdramon | Cyberdramon | -| 5-026 | Cyberdramon X | Cyberdramon X | -| 5-027 | DarkKnightmon | DarkKnightmon | -| 5-028 | Nanomon | Datamon | -| 5-029 | Delumon | Deramon | -| 5-030 | Digitamamon | Digitamamon | -| 5-031 | Dinobeemon | Dinobeemon | -| 5-032 | Hangyomon | Divermon | -| 5-033 | NeoDevimon | DoneDevimon | -| 5-034 | DORUguremon | DoruGreymon | -| 5-035 | Dagomon | Dragomon | -| 5-036 | Duramon | Duramon | -| 5-037 | Entmon | Entmon | -| 5-038 | Eosmon | Eosmon | -| 5-039 | Etemon | Etemon | -| 5-040 | Ex-Tyranomon | ExTyrannomon | -| 5-041 | Flaremon | Flaremon | -| 5-042 | Gerbemon | Garbagemon | -| 5-043 | Garudamon | Garudamon | -| 5-044 | Garudamon X | Garudamon X | -| 5-045 | Giromon | Giromon | -| 5-046 | Gogmamon | Gogmamon | -| 5-047 | Grademon | Grademon | -| 5-048 | Groundramon | Groundramon | -| 5-049 | Gusokumon | Gusokumon | -| 5-050 | Hippogriffomon | HippoGryphonmon | -| 5-051 | Hisyaryumon | Hisyaryumon | -| 5-052 | Jyagamon | Jagamon | -| 5-053 | KaiserLeomon | JagerLoweemon | -| 5-054 | Jazarichmon | Jazarichmon | -| 5-055 | Jewelbeemon | JewelBeemon | -| 5-056 | Garummon | KendoGarurumon | -| 5-057 | Chimairamon | Kimeramon | -| 5-058 | Knightmon | Knightmon | -| 5-059 | LadyDevimon | LadyDevimon | -| 5-060 | LadyDevimon X | LadyDevimon X | -| 5-061 | Lavogaritamon | Lavogaritamon | -| 5-062 | Lilamon | Lilamon | -| 5-063 | Lilimon | Lillymon | -| 5-064 | Lilimon X | Lillymon X | -| 5-065 | LoaderLeomon | LoaderLeomon | -| 5-066 | Locomon | Locomon | -| 5-067 | Lucemon: Falldown Mode | Lucemon: Chaos Mode | -| 5-068 | MachGaogamon | MachGaogamon | -| 5-069 | HolyAngemon | MagnaAngemon | -| 5-070 | Mamemon | Mamemon | -| 5-071 | Mamemon X | Mamemon X | -| 5-072 | Mametyramon | Mametyramon | -| 5-073 | Mammon | Mammothmon | -| 5-074 | Mammon X | Mammothmon X | -| 5-075 | Manticoremon | Manticoremon | -| 5-076 | MarinChimairamon | MarineChimairamon | -| 5-077 | MarinDevimon | MarineDevimon | -| 5-078 | Meicrackmon | Maycrackmon | -| 5-079 | Meicrackmon: Vicious Mode | Maycrackmon: Vicious Mode | -| 5-080 | Megadramon | Megadramon | -| 5-081 | AtlurKabuterimon | MegaKabuterimon (Red) | -| 5-082 | MegaSeadramon | MegaSeadramon | -| 5-083 | MegaSeadramon X | MegaSeadramon X | -| 5-084 | Mephismon X | Mephistomon X | -| 5-085 | Mermaimon | Mermaimon | -| 5-086 | MetalGreymon | MetalGreymon | -| 5-087 | MetalGreymon (Vaccine) | MetalGreymon (Vaccine) | -| 5-088 | MetalGreymon (Virus) | MetalGreymon (Virus) | -| 5-089 | MetalGreymon (Virus) X | MetalGreymon (Virus) X | -| 5-090 | MetalGreymon X | MetalGreymon X | -| 5-091 | MetalMamemon | MetalMamemon | -| 5-092 | MetalFantomon | MetalPhantomon | -| 5-093 | MetalTyranomon | MetalTyrannomon | -| 5-094 | MetalTyranomon X | MetalTyrannomon X | -| 5-095 | Monzaemon | Monzaemon | -| 5-096 | Monzaemon X | Monzaemon X | -| 5-097 | Mummymon | Mummymon | -| 5-098 | Vamdemon | Myotismon | -| 5-099 | Vamdemon X | Myotismon X | -| 5-100 | NeoDevimon | NeoDevimon | -| 5-101 | Okuwamon | Okuwamon | -| 5-102 | Okuwamon X | Okuwamon X | -| 5-103 | OmegaShoutmon X | OmniShoutmon X | -| 5-104 | Orochimon | Orochimon | -| 5-105 | Paildramon | Paildramon | -| 5-106 | Parrotmon | Parrotmon | -| 5-107 | Fantomon | Phantomon | -| 5-108 | Piranimon | Piranimon | -| 5-109 | Piccolomon | Piximon | -| 5-110 | Pumpmon | Pumpkinmon | -| 5-111 | RaijiLudomon | RaijiLudomon | -| 5-112 | Rapidmon | Rapidmon | -| 5-113 | Rebellimon | Rebellimon | -| 5-114 | Raihimon | Rhihimon | -| 5-115 | RizeGreymon | RizeGreymon | -| 5-116 | RizeGreymon X | RizeGreymon X | -| 5-117 | SaviorHackmon | SaviorHuckmon | -| 5-118 | Anomalocarimon | Scorpiomon | -| 5-119 | Anomalocarimon X | Scorpiomon X | -| 5-120 | Shakkoumon | Shakkoumon | -| 5-121 | TonosamaGekomon | ShogunGekomon | -| 5-122 | Shoutmon X5 | Shoutmon X5 | -| 5-123 | Silphymon | Silphymon | -| 5-124 | Sirenmon | Sirenmon | -| 5-125 | SkullBaluchimon | SkullBaluchimon | -| 5-126 | SkullGreymon | SkullGreymon | -| 5-127 | DeathMeramon | SkullMeramon | -| 5-128 | Stiffilmon | Stefilmon | -| 5-129 | Tekkamon | Tekkamon | -| 5-130 | Toropiamon | Toropiamon | -| 5-131 | Triceramon | Triceramon | -| 5-132 | Triceramon X | Triceramon X | -| 5-133 | Vademon | Vademon | -| 5-134 | Velgrmon | Velgrmon | -| 5-135 | MegaloGrowmon | WarGrowlmon | -| 5-136 | MegaloGrowmon X | WarGrowlmon X | -| 5-137 | WaruMonzaemon | WaruMonzaemon | -| 5-138 | WaruSeadramon | WaruSeadramon | -| 5-139 | WereGarurumon | WereGarurumon | -| 5-140 | WereGarurumon X | WereGarurumon X | -| 5-141 | Whamon | Whamon | -| 5-142 | Wingdramon | Wingdramon | -| 5-143 | Zudomon | Zudomon | -| 5-144 | Boutmon | Boutmon | -| 5-145 | DarkSuperStarmon | DarkSuperStarmon | -| 5-146 | DORUguremon | Doruguremon | -| 5-147 | Pandamon | Pandamon | -| 5-148 | Scorpiomon | SkullScorpiomon | -| 5-149 | SuperStarmon | SuperStarmon | -| 6-001 | Aegisdramon | Aegisdramon | -| 6-002 | Alphamon | Alphamon | -| 6-003 | AncientBeatmon | AncientBeetlemon | -| 6-004 | AncientGarurumon | AncientGarurumon | -| 6-005 | AncientGreymon | AncientGreymon | -| 6-006 | AncientIrismon | AncientKazemon | -| 6-007 | AncientMegatheriumon | AncientMegatheriummon | -| 6-008 | AncientMermaimon | AncientMermaimon | -| 6-009 | AncientSphinxmon | AncientSphinxmon | -| 6-010 | AncientTroiamon | AncientTroymon | -| 6-011 | AncientVolcamon | AncientVolcanomon | -| 6-012 | AncientWisemon | AncientWisemon | -| 6-013 | Anubimon | Anubismon | -| 6-014 | Apoclymon | Apocalymon | -| 6-015 | Apollomon | Apollomon | -| 6-016 | Algomon | Argomon | -| 6-017 | Armagemon | Armageddemon | -| 6-018 | Qinglongmon | Azulongmon | -| 6-019 | Bagramon | Bagramon | -| 6-020 | Baihumon | Baihumon | -| 6-021 | BanchoLeomon | BanchoLeomon | -| 6-022 | BanchoMamemon | BanchoMamemon | -| 6-023 | Barbamon | Barbamon | -| 6-024 | BeelStarmon X | BeelStarmon X | -| 6-025 | Beelzebumon | Beelzemon | -| 6-026 | Belphemon: Rage Mode | Belphemon: Rage Mode | -| 6-027 | BlackWarGreymon X | BlackWarGreymon X | -| 6-028 | BlitzGreymon | BlitzGreymon | -| 6-029 | Boltmon | Boltmon | -| 6-030 | Breakdramon | Breakdramon | -| 6-031 | BryweLudramon | BryweLudramon | -| 6-032 | Chaosdramon X | Chaosdramon X | -| 6-033 | Cherubimon (Virtue) | Cherubimon (Virtue) | -| 6-034 | Cherubimon (Virtue) X | Cherubimon (Virtue) X | -| 6-035 | Cherubimon (Vice) X | Cherubimon (Vice) X | -| 6-036 | Craniummon | Craniamon | -| 6-037 | Demon | Creepymon | -| 6-038 | CresGarurumon | CresGarurumon | -| 6-039 | Darkdramon | Darkdramon | -| 6-040 | DarkKnightmon X | DarkKnightmon X | -| 6-041 | Dianamon | Dianamon | -| 6-042 | Dinorexmon | Dinorexmon | -| 6-043 | Dinotigermon | Dinotigermon | -| 6-044 | Durandamon | Durandamon | -| 6-045 | Dynasmon | Dynasmon | -| 6-046 | Ebemon X | Ebemon X | -| 6-047 | Xuanwumon | Ebonwumon | -| 6-048 | KaiserGreymon | EmperorGreymon | -| 6-049 | Eosmon | Eosmon | -| 6-050 | Huanglongmon | Fanglongmon | -| 6-051 | Gaioumon | Gaiomon | -| 6-052 | Dukemon | Gallantmon | -| 6-053 | Dukemon X | Gallantmon X | -| 6-054 | Gankoomon | Gankoomon | -| 6-055 | Deathmon | Ghoulmon | -| 6-056 | GigaSeadramon | GigaSeadramon | -| 6-057 | Goddramon | Goldramon | -| 6-058 | Goddramon X | Goldramon X | -| 6-059 | GrandisKuwagamon | GrandisKuwagamon | -| 6-060 | GrandDracumon | GranDracmon | -| 6-061 | GranKuwagamon | GranKuwagamon | -| 6-062 | GrandLocomon | GroundLocomon | -| 6-063 | Griffomon | Gryphonmon | -| 6-064 | Gundramon | Gundramon | -| 6-065 | HeavyLeomon | HeavyLeomon | -| 6-066 | HerakleKabuterimon | HerculesKabuterimon | -| 6-067 | Hexeblaumon | Hexeblaumon | -| 6-068 | HiAndromon | HiAndromon | -| 6-069 | Jesmon | Jesmon | -| 6-070 | Justimon X | Justimon X | -| 6-071 | Justimon: Blitz Arm | Justimon: Blitz Arm | -| 6-072 | Sleipmon | Kentaurosmon | -| 6-073 | KingEtemon | KingEtemon | -| 6-074 | Duftmon | Leopardmon | -| 6-075 | Leviamon | Leviamon | -| 6-076 | Lilithmon | Lilithmon | -| 6-077 | LordKnightmon | LordKnightmon | -| 6-078 | Lotusmon | Lotosmon | -| 6-079 | Lucemon: Satan Mode | Lucemon: Satan Mode | -| 6-080 | Mugendramon | Machinedramon | -| 6-081 | Holydramon | Magnadramon | -| 6-082 | Holydramon X | Magnadramon X | -| 6-083 | MagnaGarurumon | MagnaGarurumon | -| 6-084 | Magnamon | Magnamon | -| 6-085 | BelialVamdemon | MaloMyotismon | -| 6-086 | MarinAngemon | MarineAngemon | -| 6-087 | SaintGalgomon | MegaGargomon | -| 6-088 | Megidramon | Megidramon | -| 6-089 | Megidramon X | Megidramon X | -| 6-090 | MetalEtemon | MetalEtemon | -| 6-091 | MetalGarurumon | MetalGarurumon | -| 6-092 | MetalGarurumon X | MetalGarurumon X | -| 6-093 | Metallicdramon | Metallicdramon | -| 6-094 | MetalPiranimon | MetalPiranimon | -| 6-095 | MetalSeadramon | MetalSeadramon | -| 6-096 | MirageGaogamon | MirageGaogamon | -| 6-097 | Murmukusmon | Murmukusmon | -| 6-098 | Nidhoggmon | Nidhoggmon | -| 6-099 | NoblePumpmon | NoblePumpkinmon | -| 6-100 | Ofanimon | Ophanimon | -| 6-101 | Ofanimon X | Ophanimon X | -| 6-102 | Ofanimon: Falldown Mode X | Ophanimon: Falldown Mode X | -| 6-103 | Ornismon | Ornismon | -| 6-104 | Ouryumon | Ouryumon | -| 6-105 | Hououmon | Phoenixmon | -| 6-106 | Hououmon X | Phoenixmon X | -| 6-107 | Piemon | Piedmon | -| 6-108 | PlatinumNumemon | PlatinumNumemon | -| 6-109 | Plesiomon | Plesiomon | -| 6-110 | Plesiomon X | Plesiomon X | -| 6-111 | PrinceMamemon X | PrinceMamemon X | -| 6-112 | Pukumon | Pukumon | -| 6-113 | Pinochimon | Puppetmon | -| 6-114 | Rafflesimon | Rafflesimon | -| 6-115 | Raguelmon | Raguelmon | -| 6-116 | Rapidmon X | Rapidmon X | -| 6-117 | Rasenmon | Rasenmon | -| 6-118 | Rasenmon: Fury Mode | Rasenmon: Fury Mode | -| 6-119 | Rasielmon | Rasielmon | -| 6-120 | Ravmon | Ravemon | -| 6-121 | Regalecusmon | Regalecusmon | -| 6-122 | Raihimon | Rhihimon | -| 6-123 | Rosemon | Rosemon | -| 6-124 | Rosemon X | Rosemon X | -| 6-125 | RustTyranomon | RustTyrannomon | -| 6-126 | SaberLeomon | SaberLeomon | -| 6-127 | Sakuyamon X | Sakuyamon X | -| 6-128 | Seraphimon | Seraphimon | -| 6-129 | ShineGreymon | ShineGreymon | -| 6-130 | SkullMammon | SkullMammothmon | -| 6-131 | SkullMammon X | SkullMammothmon X | -| 6-132 | SlashAngemon | SlashAngemon | -| 6-133 | Slayerdramon | Slayerdramon | -| 6-134 | Susanoomon | Susanomon | -| 6-135 | TigerVespamon | TigerVespamon | -| 6-136 | Titamon | Titamon | -| 6-137 | UlforceV-dramon | UlforceVeedramon | -| 6-138 | UltimateBrachiomon | UltimateBrachiomon | -| 6-139 | Valkyrimon | Valkyrimon | -| 6-140 | Valdurmon | Varodurumon | -| 6-141 | VenomVamdemon | VenomMyotismon | -| 6-142 | Volcanicdramon | Volcanicdramon | -| 6-143 | WarGreymon | WarGreymon | -| 6-144 | WarGreymon X | WarGreymon X | -| 6-145 | Zhuqiaomon | Zhuqiaomon | -| 6-146 | Vikemon | Vikemon | -| 6-147 | ShinMonzaemon | ShinMonzaemon | -| 6-148 | Kazuchimon | Kazuchimon | -| 6-149 | BanchoLilimon | BanchoLillymon | -| 6-150 | DORUgoramon | Dorugoramon | -| 7-001 | Alphamon: Ouryuken | Alphamon: Ouryuken | -| 7-002 | Armagemon | Armageddemon | -| 7-003 | Barbamon X | Barbamon X | -| 7-004 | Beelzebumon X | Beelzemon X | -| 7-005 | Beelzebumon: Blast Mode | Beelzemon: Blast Mode | -| 7-006 | Belphemon X | Belphemon X | -| 7-007 | Voltobautamon | Boltboutamon | -| 7-008 | Chaosmon | Chaosmon | -| 7-009 | Craniummon X | Craniamon X | -| 7-010 | Demon X | Creepymon X | -| 7-011 | DarknessBagramon | DarknessBagramon | -| 7-012 | Diablomon X | Diaboromon X | -| 7-013 | Dynasmon X | Dynasmon X | -| 7-014 | Examon | Examon | -| 7-015 | Examon X | Examon X | -| 7-016 | Dukemon X | Gallantmon X | -| 7-017 | Gankoomon X | Gankoomon X | -| 7-018 | GraceNovamon | GraceNovamon | -| 7-019 | Imperialdramon: Fighter Mode | Imperialdramon: Fighter Mode | -| 7-020 | Imperialdramon: Paladin Mode | Imperialdramon: Paladin Mode | -| 7-021 | Jesmon X | Jesmon X | -| 7-022 | Sleipmon X | Kentaurosmon X | -| 7-023 | Duftmon X | Leopardmon X | -| 7-024 | Leviamon X | Leviamon X | -| 7-025 | Lilithmon X | Lilithmon X | -| 7-026 | LordKnightmon X | LordKnightmon X | -| 7-027 | Lucemon X | Lucemon X | -| 7-028 | Magnamon X | Magnamon X | -| 7-029 | Mastemon | Mastemon | -| 7-030 | Minervamon X | Minervamon X | -| 7-031 | Ogudomon | Ogudomon | -| 7-032 | Omegamon | Omnimon | -| 7-033 | Omegamon Alter-S | Omnimon Alter-S | -| 7-034 | Omegamon X | Omnimon X | -| 7-035 | Ordinemon | Ordinemon | -| 7-036 | Rafflesimon | Rafflesimon | -| 7-037 | RagnaLordmon | RagnaLoardmon | -| 7-038 | Rasenmon | Rasenmon | -| 7-039 | RustTyranomon | RustTyrannomon | -| 7-040 | UlforceV-dramon X | UlforceVeedramon X | -| 7-041 | Agumon (Bond of Bravery) | Agumon (Bond of Bravery) | -| 7-042 | Gabumon (Bond of Friendship) | Gabumon (Bond of Friendship) | -| 7-043 | Jesmon GX | Jesmon GX | -| 7-044 | Ogudomon X | Ogudomon X | -| 7-045 | Chaosdramon | Chaosdramon | - -Check out the [Digimon Data List](src/data/digimon-data.ts) for more information about evolution -lines. - -## License - -This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details. +Contributions and issues belong in the [repository](https://github.com/jcendal/digital-pet). The two product READMEs provide their user guides; [docs/monorepo.md](docs/monorepo.md) lists package-specific development commands. -## Attributions +## Maintainer and credits -The bundled sprites were derived from the following sources and were later edited and -processed for this project: +- [Jorge Cendal](https://github.com/jcendal), maintainer +- [Sergio Bugallo](https://github.com/sbugallo), original author and contributor -- [sundeth/omnipet](https://github.com/sundeth/omnipet/tree/main) -- [Tortoiseshel's Full Color Digimon Dot Sprites post](https://withthewill.net/threads/full-color-digimon-dot-sprites.25843/) on the [With the Will forum](https://withthewill.net/members/tortoiseshel.4767/) +The project is licensed under [Apache 2.0](LICENSE). The bundled sprites were derived from [sundeth/omnipet](https://github.com/sundeth/omnipet/tree/main) and [Tortoiseshel's Full Color Digimon Dot Sprites post](https://withthewill.net/threads/full-color-digimon-dot-sprites.25843/), then edited and processed for this project. diff --git a/_images/digital-pet-overview.png b/_images/digital-pet-overview.png new file mode 100644 index 0000000..6ba8318 Binary files /dev/null and b/_images/digital-pet-overview.png differ diff --git a/_images/vpet-overview.png b/_images/vpet-overview.png deleted file mode 100644 index 4d38303..0000000 Binary files a/_images/vpet-overview.png and /dev/null differ diff --git a/biome.json b/biome.json index 6d28508..5afb24b 100644 --- a/biome.json +++ b/biome.json @@ -2,15 +2,16 @@ "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "files": { "includes": [ - "src/**", - "tests/**", + "packages/**", "scripts/**", "package.json", "tsconfig*.json", - "!src/data/monster-frame-data.ts", + "!packages/**/dist/**", + "!packages/**/build", + "!packages/cursor-digital-pet/hook-bridge.js", + "!packages/digital-pet-core/src/data/monster-frame-data.ts", "!tests/**/*.jsonc", "!.omo/", - "!dist/", "!node_modules/", "!coverage/", "!**/generated/" @@ -44,7 +45,11 @@ }, "overrides": [ { - "includes": ["src/tui/sidebar-card.tsx", "src/tui/vpet-dex-dialog.tsx", "src/tui/vpet-history-dialog.tsx"], + "includes": [ + "packages/opencode-digital-pet/src/tui/sidebar-card.tsx", + "packages/opencode-digital-pet/src/tui/digital-pet-dex-dialog.tsx", + "packages/opencode-digital-pet/src/tui/digital-pet-history-dialog.tsx" + ], "linter": { "rules": { "a11y": { diff --git a/bun.lock b/bun.lock deleted file mode 100644 index 740fed9..0000000 --- a/bun.lock +++ /dev/null @@ -1,349 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "opencode-vpet", - "dependencies": { - "solid-js": "^1.9.14", - }, - "devDependencies": { - "@biomejs/biome": "2.5.10", - "@opencode-ai/plugin": "^1.18.10", - "@opencode-ai/sdk": "^1.18.10", - "@opentui/core": "^0.4.5", - "@opentui/keymap": "^0.4.5", - "@opentui/solid": "^0.4.5", - "@types/bun": "latest", - "@types/semver": "^7.7.1", - "semver": "^7.7.4", - "typescript": "^5.9.3", - }, - "peerDependencies": { - "@opencode-ai/plugin": ">=1.18.10", - "@opentui/core": ">=0.4.5", - "@opentui/keymap": ">=0.4.5", - "@opentui/solid": ">=0.4.5", - }, - }, - }, - "packages": { - "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], - - "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], - - "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - - "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - - "@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], - - "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - - "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], - - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], - - "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="], - - "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], - - "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], - - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], - - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], - - "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="], - - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], - - "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="], - - "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], - - "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - - "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - - "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], - - "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="], - - "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="], - - "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="], - - "@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="], - - "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - - "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], - - "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - - "@biomejs/biome": ["@biomejs/biome@2.5.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.10", "@biomejs/cli-darwin-x64": "2.5.10", "@biomejs/cli-linux-arm64": "2.5.10", "@biomejs/cli-linux-arm64-musl": "2.5.10", "@biomejs/cli-linux-x64": "2.5.10", "@biomejs/cli-linux-x64-musl": "2.5.10", "@biomejs/cli-win32-arm64": "2.5.10", "@biomejs/cli-win32-x64": "2.5.10" }, "bin": { "biome": "bin/biome" } }, "sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ=="], - - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q=="], - - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw=="], - - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg=="], - - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw=="], - - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg=="], - - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ=="], - - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A=="], - - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], - - "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], - - "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], - - "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], - - "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], - - "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], - - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.10", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.10", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-l4R1ulu5wtA0+cCinzdSPkEO8ZCdIgb/ZjltzstIWQqRqeH3H880OabNaeje/ZCkSoAT8c/o9oT3tdjyFP7o7g=="], - - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.10", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-K+glWbBp5pfGepZ/6EHvuXY3IpVG2qgsEqF4z9mOiUQUrs6KWN72Vdp15Qsl2TCDbemgHWXKQKgbX3b8PSK2hg=="], - - "@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="], - - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="], - - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="], - - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="], - - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="], - - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="], - - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="], - - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="], - - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="], - - "@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="], - - "@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="], - - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - - "@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="], - - "@types/semver": ["@types/semver@7.8.0", "", {}, "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ=="], - - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.7", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ=="], - - "babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="], - - "babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="], - - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "baseline-browser-mapping": ["baseline-browser-mapping@2.11.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ=="], - - "brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], - - "browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="], - - "bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="], - - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], - - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - - "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], - - "electron-to-chromium": ["electron-to-chromium@1.5.398", "", {}, "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ=="], - - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - - "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], - - "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], - - "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], - - "find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], - - "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - - "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], - - "glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], - - "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], - - "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], - - "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], - - "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - - "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], - - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - - "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], - - "locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], - - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - - "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], - - "minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], - - "minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "msgpackr": ["msgpackr@2.0.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="], - - "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], - - "multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="], - - "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], - - "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], - - "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], - - "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], - - "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], - - "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], - - "path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - - "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], - - "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], - - "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="], - - "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], - - "s-js": ["s-js@0.4.9", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="], - - "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - - "seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="], - - "seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "solid-js": ["solid-js@1.9.14", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ=="], - - "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - - "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - - "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - - "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], - - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - - "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], - - "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], - - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - - "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - - "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], - - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], - - "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - - "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - - "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - } -} diff --git a/bunfig.toml b/bunfig.toml index 0d8b3d7..08e479b 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,6 +1,3 @@ [jsx] runtime = "automatic" importSource = "@opentui/solid" - -[test] -preload = ["@opentui/solid/preload"] diff --git a/docs/monorepo.md b/docs/monorepo.md new file mode 100644 index 0000000..ad0d231 --- /dev/null +++ b/docs/monorepo.md @@ -0,0 +1,48 @@ +# Monorepo + +The root workspace is private and coordinates four packages: + +| Package | Purpose | +| --- | --- | +| `packages/digital-pet-core` | Shared catalog, evolution rules, and SQLite schema | +| `packages/digital-pet-animation` | Shared animation and artwork logic | +| `packages/opencode-digital-pet` | OpenCode plugin published on npm | +| `packages/cursor-digital-pet` | Cursor extension published on Open VSX | + +Cursor and OpenCode use the same local `pet.db` by default. The shared packages are bundled into the products and are not published separately. + +## Development + +Use Node `24.11.0`, npm `11.6.1`, and Bun `1.3.5`. From the repository root: + +```sh +npm ci +npm run check +npm run test +npm run test:persistence +npm run build +``` + +To create local distributables: + +```sh +npm run pack:opencode +npm run package --workspace cursor-digital-pet +``` + +The two products have independent versions and changelogs: + +| Product | Changelog | +| --- | --- | +| OpenCode | [`packages/opencode-digital-pet/CHANGELOG.md`](../packages/opencode-digital-pet/CHANGELOG.md) | +| Cursor | [`packages/cursor-digital-pet/CHANGELOG.md`](../packages/cursor-digital-pet/CHANGELOG.md) | + +The release workflows are in [`.github/workflows`](../.github/workflows). The Cursor workflow publishes to Open VSX and attaches the VSIX to a GitHub Release. + +## Development tools + +The development commands in each host preview feeding, activity, and evolution animations without waiting for live agent usage. + +For Cursor, run the extension in an Extension Development Host or enable the `digital-pet.devTools` setting. The command palette then offers **Cursor Digital Pet: Dev Tools**. + +For OpenCode, set `OPENCODE_DIGITAL_PET_DEV=1` before starting OpenCode. Its development commands appear under **Digital Pet Dev**. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..394eb01 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,7985 @@ +{ + "name": "@jcendal/digital-pet-workspace", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@jcendal/digital-pet-workspace", + "version": "0.0.0", + "license": "Apache-2.0", + "workspaces": [ + "packages/*" + ], + "devDependencies": { + "@biomejs/biome": "2.5.10", + "@types/bun": "1.3.14", + "@types/node": "24.10.1", + "@types/semver": "7.7.1", + "esbuild": "0.28.2", + "semver": "7.7.4", + "typescript": "5.9.3" + }, + "engines": { + "bun": "1.3.5", + "node": "24.11.0", + "npm": ">=11.5.1" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@azu/format-text": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", + "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@azu/style-format": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", + "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "@azu/format-text": "^1.0.1" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz", + "integrity": "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.11.0.tgz", + "integrity": "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.1.tgz", + "integrity": "sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-process": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@azure/core-process/-/core-process-1.0.0.tgz", + "integrity": "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", + "integrity": "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.4.0.tgz", + "integrity": "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.14.0.tgz", + "integrity": "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.3.tgz", + "integrity": "sha512-zGQPtqvXPgSA8yfV2CkIQ1qirqk0p9AIVpC5uEkdXQYcKl07QHvyaGYRnZOk0AsQUmxNb4wfkcwY5di8Z5xa9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-process": "^1.0.0", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^6.0.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.4.0.tgz", + "integrity": "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.24.0.tgz", + "integrity": "sha512-jS1stt81tslcVZi7fqVD+g6vr8w/IWqFl/vAB2dx/6EOroQDuCR4+z0o3C+d6a/GpLATvdoO+kwnDcYzsBXKQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.14.2" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.2.tgz", + "integrity": "sha512-HOBR0Gfo39TMrkxNG8k6cfjRkrRjXZc3JQs3ebMWQc9S4cihuknMYra3sgmzbZB752oQBqBN9+WF7c826p2vIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-6.0.1.tgz", + "integrity": "sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.14.1", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@azure/msal-node/node_modules/@azure/msal-common": { + "version": "16.14.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.1.tgz", + "integrity": "sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "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-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@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.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.9.tgz", + "integrity": "sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", + "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.10.tgz", + "integrity": "sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.10", + "@biomejs/cli-darwin-x64": "2.5.10", + "@biomejs/cli-linux-arm64": "2.5.10", + "@biomejs/cli-linux-arm64-musl": "2.5.10", + "@biomejs/cli-linux-x64": "2.5.10", + "@biomejs/cli-linux-x64-musl": "2.5.10", + "@biomejs/cli-win32-arm64": "2.5.10", + "@biomejs/cli-win32-x64": "2.5.10" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.10.tgz", + "integrity": "sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.10.tgz", + "integrity": "sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.10.tgz", + "integrity": "sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.10.tgz", + "integrity": "sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.10.tgz", + "integrity": "sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.10.tgz", + "integrity": "sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.10.tgz", + "integrity": "sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.10.tgz", + "integrity": "sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jcendal/digital-pet-animation": { + "resolved": "packages/digital-pet-animation", + "link": true + }, + "node_modules/@jcendal/digital-pet-core": { + "resolved": "packages/digital-pet-core", + "link": true + }, + "node_modules/@jcendal/opencode-digital-pet": { + "resolved": "packages/opencode-digital-pet", + "link": true + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32/-/crc32-1.10.8.tgz", + "integrity": "sha512-gOYKFwkojSdWrSmreCcGocRcAXSpRkNZYxv6nscmtddSWBKVGypdMbfLxu5qgmymdaCNApajuwZblYEfs7HswA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@node-rs/crc32-android-arm-eabi": "1.10.8", + "@node-rs/crc32-android-arm64": "1.10.8", + "@node-rs/crc32-darwin-arm64": "1.10.8", + "@node-rs/crc32-darwin-x64": "1.10.8", + "@node-rs/crc32-freebsd-x64": "1.10.8", + "@node-rs/crc32-linux-arm-gnueabihf": "1.10.8", + "@node-rs/crc32-linux-arm64-gnu": "1.10.8", + "@node-rs/crc32-linux-arm64-musl": "1.10.8", + "@node-rs/crc32-linux-x64-gnu": "1.10.8", + "@node-rs/crc32-linux-x64-musl": "1.10.8", + "@node-rs/crc32-win32-arm64-msvc": "1.10.8", + "@node-rs/crc32-win32-ia32-msvc": "1.10.8", + "@node-rs/crc32-win32-x64-msvc": "1.10.8" + } + }, + "node_modules/@node-rs/crc32-android-arm-eabi": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm-eabi/-/crc32-android-arm-eabi-1.10.8.tgz", + "integrity": "sha512-Ed2P9uQAOlOSveuPKSlmCctcZ73+9VIBTD84F8MjfKU26q33Y0hYlcl9vlzfc9UQX7LLMGEZhOuU1xxYtx49tg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-android-arm64": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm64/-/crc32-android-arm64-1.10.8.tgz", + "integrity": "sha512-gvEOdU8tkSwK2McdlMeXDoyqtvJFPYShNopJtGaGMNoltN5EzFOsM/7XKF3rk9JLQ9GnZHQrCxur0Cksonow2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-darwin-arm64": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-arm64/-/crc32-darwin-arm64-1.10.8.tgz", + "integrity": "sha512-ylDspj/s3i+FCdTGoLZtOBsbR2PvAsI5taJDE5Pl7kDgeKX7IywdAYQtkh2orvTs0RqCjcnIBxrCDI/O/r+z1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-darwin-x64": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-x64/-/crc32-darwin-x64-1.10.8.tgz", + "integrity": "sha512-DVTORLWomcx7F0CxQxlfBszAcMh8IkNGx/hgYHQ4iDnuf/uVjhs7l1hoVBghPymdFxP129+I/+b1WoQBbTJzaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-freebsd-x64": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-freebsd-x64/-/crc32-freebsd-x64-1.10.8.tgz", + "integrity": "sha512-ZzuW7RS9VFy7zD64YUFI2dK6tJrlAFfe1NBuNx/ouKZ6SK+NHW5F8hxZNrZCkv2XB7QyZk9wZ53oJ20PKylQTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-arm-gnueabihf": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm-gnueabihf/-/crc32-linux-arm-gnueabihf-1.10.8.tgz", + "integrity": "sha512-5/2kW7V4KMV3o37ApvcnF4Pk11luzLnd38kQZowVCw5D/xGlCByEQOODfJHX9pv0C+4hp+8Dosul06RODjPEUw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-arm64-gnu": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-gnu/-/crc32-linux-arm64-gnu-1.10.8.tgz", + "integrity": "sha512-Vr1P3+WF+RsUVI8CNLiunl1vuzdpgTR1sf9PxgQFr3o//hh2KdqJeU4zZsFVTn+GbGZtqvRoba0wi8nKMzRLeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-arm64-musl": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-musl/-/crc32-linux-arm64-musl-1.10.8.tgz", + "integrity": "sha512-eS8YXANJLBqdOzf5jCNVEXiDI4wjXVqQybaJextJJINvHhnhhOXPar8402GMagewOxGxYsy4C69G8rBIzqL4cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-x64-gnu": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-gnu/-/crc32-linux-x64-gnu-1.10.8.tgz", + "integrity": "sha512-DFd0eV47MAykz4PQDcBSlEvY3CtXqPt2gkBpvPngCpOUoIJgEk9tKtPjEgUwLatMkvsy4fEvAg7gckGickOyEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-x64-musl": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-musl/-/crc32-linux-x64-musl-1.10.8.tgz", + "integrity": "sha512-U7fP8FjDQuOb61YoT8EoWDIJ9x40ZrCzM6OcAH1wqYiu9Z94U14S+i8KwTC7YorotGtICdYZ/APn1eI8vNne5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-win32-arm64-msvc": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-arm64-msvc/-/crc32-win32-arm64-msvc-1.10.8.tgz", + "integrity": "sha512-VJezyT9OWnMPnArDR5ok0ARLemiL83LLNYh4MC++oHoLegqzyBllqNlO8fVp2PmczGkn+rfKkZbtG4w3MQ6BbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-win32-ia32-msvc": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-ia32-msvc/-/crc32-win32-ia32-msvc-1.10.8.tgz", + "integrity": "sha512-01HooQK7WiG07/vvp0XzRnqSC5cvXvQ1MsQwlH9wwMWZ9qqHRO1j08cSeSt7DM4eALNisfX83bjqqrHzSKI9Iw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-win32-x64-msvc": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-x64-msvc/-/crc32-win32-x64-msvc-1.10.8.tgz", + "integrity": "sha512-4O6G7yme7s98LUGwiqW7q2QqYmWD2IRQvV+q7+S4vjkUWiXUMbP8QEPYP9cF2WEQSdXCkyDEqDW10zeUdniPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.18.34", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.34.tgz", + "integrity": "sha512-bC1EBL/QJ6LpC7liKZXr5JA+oa9qDAwTzpEgPxPsx1pbIUdhKOqg3/yoPEQgoa4YdO3hsgCWNFmbDE/DeOl/Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.18.34", + "effect": "4.0.0-beta.83", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/keymap": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.18.34", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.34.tgz", + "integrity": "sha512-R9QWUpcngtdQ6FCixz7NC6n1WsSho6CEuRUzYHz/CzZcp3miSklDzXkm0oDwXVCnuEu+ANoeM0f6RDp51kOMzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@opentui/core": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core/-/core-0.4.5.tgz", + "integrity": "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bun-ffi-structs": "0.2.4", + "diff": "9.0.0", + "marked": "17.0.1", + "string-width": "7.2.0", + "strip-ansi": "7.1.2" + }, + "optionalDependencies": { + "@opentui/core-darwin-arm64": "0.4.5", + "@opentui/core-darwin-x64": "0.4.5", + "@opentui/core-linux-arm64": "0.4.5", + "@opentui/core-linux-arm64-musl": "0.4.5", + "@opentui/core-linux-x64": "0.4.5", + "@opentui/core-linux-x64-musl": "0.4.5", + "@opentui/core-win32-arm64": "0.4.5", + "@opentui/core-win32-x64": "0.4.5" + }, + "peerDependencies": { + "web-tree-sitter": "0.25.10" + } + }, + "node_modules/@opentui/core-darwin-arm64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-darwin-arm64/-/core-darwin-arm64-0.4.5.tgz", + "integrity": "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opentui/core-darwin-x64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-darwin-x64/-/core-darwin-x64-0.4.5.tgz", + "integrity": "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opentui/core-linux-arm64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-arm64/-/core-linux-arm64-0.4.5.tgz", + "integrity": "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-linux-arm64-musl": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-arm64-musl/-/core-linux-arm64-musl-0.4.5.tgz", + "integrity": "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-linux-x64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-x64/-/core-linux-x64-0.4.5.tgz", + "integrity": "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-linux-x64-musl": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-x64-musl/-/core-linux-x64-musl-0.4.5.tgz", + "integrity": "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-win32-arm64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-win32-arm64/-/core-win32-arm64-0.4.5.tgz", + "integrity": "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opentui/core-win32-x64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-win32-x64/-/core-win32-x64-0.4.5.tgz", + "integrity": "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opentui/keymap": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/keymap/-/keymap-0.4.5.tgz", + "integrity": "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@opentui/core": "0.4.5" + }, + "peerDependencies": { + "@opentui/react": "0.4.5", + "@opentui/solid": "0.4.5", + "react": ">=19.2.0", + "solid-js": "1.9.12" + }, + "peerDependenciesMeta": { + "@opentui/react": { + "optional": true + }, + "@opentui/solid": { + "optional": true + }, + "react": { + "optional": true + }, + "solid-js": { + "optional": true + } + } + }, + "node_modules/@opentui/solid": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/solid/-/solid-0.4.5.tgz", + "integrity": "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "7.28.0", + "@babel/preset-typescript": "7.27.1", + "@opentui/core": "0.4.5", + "babel-plugin-module-resolver": "5.0.2", + "babel-preset-solid": "1.9.12", + "entities": "7.0.1", + "s-js": "^0.4.9" + }, + "peerDependencies": { + "solid-js": "1.9.12" + } + }, + "node_modules/@secretlint/config-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", + "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/config-loader": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", + "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "ajv": "^8.17.1", + "debug": "^4.4.1", + "rc-config-loader": "^4.1.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/core": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", + "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "structured-source": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/formatter": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", + "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@textlint/linter-formatter": "^15.2.0", + "@textlint/module-interop": "^15.2.0", + "@textlint/types": "^15.2.0", + "chalk": "^5.4.1", + "debug": "^4.4.1", + "pluralize": "^8.0.0", + "strip-ansi": "^7.1.0", + "table": "^6.9.0", + "terminal-link": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@secretlint/node": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", + "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-loader": "^10.2.2", + "@secretlint/core": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "@secretlint/source-creator": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "p-map": "^7.0.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/profiler": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", + "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/resolver": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", + "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-formatter-sarif": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", + "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-sarif-builder": "^3.2.0" + } + }, + "node_modules/@secretlint/secretlint-rule-no-dotenv": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", + "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/secretlint-rule-preset-recommend": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz", + "integrity": "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/source-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.2.tgz", + "integrity": "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2", + "istextorbinary": "^9.5.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/types": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", + "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/ast-node-types": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.8.0.tgz", + "integrity": "sha512-5CiH9COYmovWmExQgs7763DzX6Gy9zjkjJ7JxCC95wyTcjwQn/8poNF6fv3qzRlmx8CRRde8DHr9FcgAAiPzgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.8.0.tgz", + "integrity": "sha512-+oU3A235NATv6Lzi4xa4kJ65PuNJlIxesaO4AvDhDWA9FWm7y4XKWaoQCW1esgaQQ6dwnUiFKArQ8TcJ86mC4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azu/format-text": "^1.0.2", + "@azu/style-format": "^1.0.1", + "@textlint/module-interop": "15.8.0", + "@textlint/resolver": "15.8.0", + "@textlint/types": "15.8.0", + "debug": "^4.4.3", + "js-yaml": "^4.3.0", + "lodash": "^4.18.1", + "pluralize": "^2.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "text-table": "^0.2.0" + }, + "engines": { + "node": ">=20.18.0" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter/node_modules/pluralize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", + "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/module-interop": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.8.0.tgz", + "integrity": "sha512-rt+OR1WYGoLOY8HkA/aBPrqufF6yUUEsKEAh7XohTsT3lp9IyZFT6zOIbjul9P4FAzsmSPkcrYjVx3Bz/IUfkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/resolver": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.8.0.tgz", + "integrity": "sha512-E88tzfX3K8Jykk+38aJ9cy8RquD8ABVOPTO2rFEESq0wcg8x6/ypdAS8ZgR7OKiGqlRF0hkO/m5PbQwVfKM3VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/types": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.8.0.tgz", + "integrity": "sha512-Anhc6y5736YIsvqae0U6k0YmB2M/QVHkEeOv2aydAn/WIkdI69dCOiDbe3/+RagS3qstFTSFWJzNRA2lUjv19w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.8.0" + } + }, + "node_modules/@types/bun": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.14.tgz", + "integrity": "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.14" + } + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/vscode": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.90.0.tgz", + "integrity": "sha512-oT+ZJL7qHS9Z8bs0+WKf/kQ27qWYR3trsXpq46YDjFqBsMLG4ygGGjPaJ2tyrH0wJzjOEmDyg9PDJBBhWg9pkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", + "integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@vscode/vsce": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.2.1.tgz", + "integrity": "sha512-AY9vBjwExakK1c0cI/3NN2Ey0EgiKLBye/fxl/ue+o4q6RZ7N+xzd1jAD6eI6eBeMVANi617+V2rxIAkDPco2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/identity": "^4.1.0", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^2.4.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^6.2.1", + "form-data": "^4.0.0", + "glob": "^11.0.0", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^14.1.0", + "mime": "^1.3.4", + "minimatch": "^3.0.3", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "semver": "^7.5.2", + "tmp": "^0.2.3", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^2.3.1", + "yazl": "^2.2.2" + }, + "bin": { + "vsce": "vsce" + }, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/@vscode/vsce-sign": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.1.0.tgz", + "integrity": "sha512-9AQrqazrBgTgRSuwleLVXUrIUphY02/SFCh2TKYoLV/xifJAdblhdmEmw5gUrYSPQ3sRwNs9iyCMD14sATEE6g==", + "dev": true, + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optionalDependencies": { + "@vscode/vsce-sign-alpine-arm64": "2.0.6", + "@vscode/vsce-sign-alpine-x64": "2.0.6", + "@vscode/vsce-sign-darwin-arm64": "2.0.6", + "@vscode/vsce-sign-darwin-x64": "2.0.6", + "@vscode/vsce-sign-linux-arm": "2.0.6", + "@vscode/vsce-sign-linux-arm64": "2.0.6", + "@vscode/vsce-sign-linux-x64": "2.0.6", + "@vscode/vsce-sign-win32-arm64": "2.0.6", + "@vscode/vsce-sign-win32-x64": "2.0.6" + } + }, + "node_modules/@vscode/vsce-sign-alpine-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz", + "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-alpine-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz", + "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz", + "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", + "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", + "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", + "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", + "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", + "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", + "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/azure-devops-node-api": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", + "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, + "node_modules/babel-plugin-jsx-dom-expressions": { + "version": "0.40.10", + "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.40.10.tgz", + "integrity": "sha512-lxve6Y02YiZTldB7efKpnbf1BH00XCFZNYYW235jSGsYaJNFtHrYlKV6/O+miHbjqpIr9FTe5+0no4hofAMbfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "7.18.6", + "@babel/plugin-syntax-jsx": "^7.18.6", + "@babel/types": "^7.20.7", + "html-entities": "2.3.3", + "parse5": "^7.1.2" + }, + "peerDependencies": { + "@babel/core": "^7.20.12" + } + }, + "node_modules/babel-plugin-jsx-dom-expressions/node_modules/@babel/helper-module-imports": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", + "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/babel-plugin-module-resolver": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-module-resolver/-/babel-plugin-module-resolver-5.0.2.tgz", + "integrity": "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-babel-config": "^2.1.1", + "glob": "^9.3.3", + "pkg-up": "^3.1.0", + "reselect": "^4.1.7", + "resolve": "^1.22.8" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/glob": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", + "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/babel-plugin-module-resolver/node_modules/minimatch": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", + "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/babel-preset-solid": { + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.9.12.tgz", + "integrity": "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jsx-dom-expressions": "^0.40.6" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "solid-js": "^1.9.12" + }, + "peerDependenciesMeta": { + "solid-js": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binaryextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", + "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/bun-ffi-structs": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/bun-ffi-structs/-/bun-ffi-structs-0.2.4.tgz", + "integrity": "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "^5" + } + }, + "node_modules/bun-types": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz", + "integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/bundle-name": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-keychain": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cross-keychain/-/cross-keychain-1.1.0.tgz", + "integrity": "sha512-244DWNdGepLKD5vEn3reZqwzZFiE/LD4U+XV9IaXQbtIXKvQkf0VkRaOj/9vPYauPdR12PSGB3U0cE7jJi3WTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^7.8.6", + "meow": "^14.0.0" + }, + "bin": { + "cross-keychain": "dist/cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@napi-rs/keyring": "^1.2.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/cursor-digital-pet": { + "resolved": "packages/cursor-digital-pet", + "link": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/editions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", + "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "version-range": "^4.15.0" + }, + "engines": { + "ecmascript": ">= es5", + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.8.0", + "find-my-way-ts": "^0.1.6", + "ini": "^7.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.1", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^14.0.0", + "yaml": "^2.9.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.445", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.445.tgz", + "integrity": "sha512-iX7o1nF+qNb3Bv5mLYgqfgF9Znh9e/8MKXdmbb8Gv2Q47OKIzTwEPD1oda1HrgEwYatTLJj0wQjHvIFt8Db53Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-check": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", + "integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-babel-config": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/find-babel-config/-/find-babel-config-2.1.2.tgz", + "integrity": "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.3" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/ini": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^2.0.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-it-type": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/is-it-type/-/is-it-type-5.1.3.tgz", + "integrity": "sha512-AX2uU0HW+TxagTgQXOJY7+2fbFHemC7YFBwN1XqD8qQMKdtfbOC8OC3fUb4s5NU59a3662Dzwto8tWDdZYRXxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "globalthis": "^1.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istextorbinary": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", + "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "binaryextensions": "^6.11.0", + "editions": "^6.21.0", + "textextensions": "^6.11.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/markdown-it": { + "version": "14.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", + "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/marked": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", + "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/meow": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", + "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", + "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/multipasta": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-abi": { + "version": "3.97.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.97.0.tgz", + "integrity": "sha512-6Pu8F0eXGZlyz0EepPJrWgWDcBI8rKt2Wj8exOJU1tjVbu/VyZy5ZaLYS5N+Jp/Y3IP3sIRKcIOElTu7uC6KKA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-sarif-builder": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", + "integrity": "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.7", + "fs-extra": "^11.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ovsx": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ovsx/-/ovsx-1.2.0.tgz", + "integrity": "sha512-12mauBqsLehlJ0cRiroQz4uKYVjblVS5OobgSJ278CdCtkDfRQD+SI3NQ00GNalDqzp5PDN11yx84miFUojeMw==", + "dev": true, + "license": "EPL-2.0", + "dependencies": { + "@inquirer/prompts": "^7.10.1", + "@vscode/vsce": "^3.7.1", + "commander": "^6.2.1", + "cross-keychain": "^1.1.0", + "follow-redirects": "^1.16.0", + "is-ci": "^2.0.0", + "leven": "^3.1.0", + "semver": "^7.6.0", + "tmp": "^0.2.3", + "yauzl-promise": "^4.0.0" + }, + "bin": { + "ovsx": "bin/ovsx" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/ovsx/node_modules/@vscode/vsce": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.2.tgz", + "integrity": "sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/identity": "^4.1.0", + "@secretlint/node": "^10.1.2", + "@secretlint/secretlint-formatter-sarif": "^10.1.2", + "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", + "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^4.1.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^12.1.0", + "form-data": "^4.0.0", + "glob": "^13.0.6", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^14.1.0", + "mime": "^1.3.4", + "minimatch": "^10.2.2", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "secretlint": "^10.1.2", + "semver": "^7.5.2", + "tmp": "^0.2.3", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^3.2.1", + "yazl": "^2.2.2" + }, + "bin": { + "vsce": "vsce" + }, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/ovsx/node_modules/@vscode/vsce/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/ovsx/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ovsx/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/ovsx/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/ovsx/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ovsx/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/ovsx/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ovsx/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ovsx/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ovsx/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ovsx/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ovsx/node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/p-map": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.8.tgz", + "integrity": "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-semver": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", + "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.1.0" + } + }, + "node_modules/parse-semver/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc-config-loader": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", + "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "json5": "^2.2.3", + "require-from-string": "^2.0.2" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", + "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/s-js": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/s-js/-/s-js-0.4.9.tgz", + "integrity": "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/secretlint": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", + "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-creator": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/node": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "debug": "^4.4.1", + "globby": "^14.1.0", + "read-pkg": "^9.0.1" + }, + "bin": { + "secretlint": "bin/secretlint.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz", + "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.6.tgz", + "integrity": "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-invariant": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/simple-invariant/-/simple-invariant-2.0.1.tgz", + "integrity": "sha512-1sbhsxqI+I2tqlmjbz99GXNmZtr6tKIyEgGGnJw/MKGblalqk/XoOYYFJlBzTKZCxx8kLaD3FD5s9BEEjx5Pyg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/slice-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/solid-js": { + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.12.tgz", + "integrity": "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.1.0", + "seroval": "~1.5.0", + "seroval-plugins": "~1.5.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz", + "integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/sql.js": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.2.tgz", + "integrity": "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/structured-source": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/supports-hyperlinks/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/table/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/terminal-link": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", + "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^3.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/textextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", + "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-rest-client": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", + "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "qs": "^6.9.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/version-range": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", + "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", + "dev": true, + "license": "Artistic-2.0", + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/web-tree-sitter": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz", + "integrity": "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/emscripten": "^1.40.0" + }, + "peerDependenciesMeta": { + "@types/emscripten": { + "optional": true + } + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yauzl-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yauzl-promise/-/yauzl-promise-4.0.0.tgz", + "integrity": "sha512-/HCXpyHXJQQHvFq9noqrjfa/WpQC2XYs3vI7tBiAi4QiIU1knvYhZGaO1QPjwIVMdqflxbmwgMXtYeaRiAE0CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@node-rs/crc32": "^1.7.0", + "is-it-type": "^5.1.2", + "simple-invariant": "^2.0.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/cursor-digital-pet": { + "version": "0.2.0-dev.0", + "license": "Apache-2.0", + "dependencies": { + "@jcendal/digital-pet-animation": "0.2.0-dev.0", + "@jcendal/digital-pet-core": "0.2.0-dev.0", + "sql.js": "^1.12.0" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "@types/node": "24.10.1", + "@types/vscode": "1.90.0", + "@vscode/vsce": "3.2.1", + "ovsx": "1.2.0", + "typescript": "5.9.3" + }, + "engines": { + "bun": "1.3.5", + "node": ">=22.13.0", + "vscode": "^1.101.0" + } + }, + "packages/digital-pet-animation": { + "name": "@jcendal/digital-pet-animation", + "version": "0.2.0-dev.0", + "dependencies": { + "@jcendal/digital-pet-core": "0.2.0-dev.0" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "typescript": "5.9.3" + }, + "engines": { + "bun": "1.3.5", + "node": "24.11.0" + } + }, + "packages/digital-pet-core": { + "name": "@jcendal/digital-pet-core", + "version": "0.2.0-dev.0", + "devDependencies": { + "@types/bun": "1.3.14", + "typescript": "5.9.3" + }, + "engines": { + "bun": "1.3.5", + "node": "24.11.0" + } + }, + "packages/opencode-digital-pet": { + "name": "@jcendal/opencode-digital-pet", + "version": "0.2.0-dev.0", + "license": "Apache-2.0", + "dependencies": { + "@jcendal/digital-pet-animation": "0.2.0-dev.0", + "@jcendal/digital-pet-core": "0.2.0-dev.0", + "solid-js": "1.9.12" + }, + "bin": { + "opencode-digital-pet": "dist/cli.js" + }, + "devDependencies": { + "@biomejs/biome": "2.5.10", + "@opencode-ai/plugin": "^1.18.10", + "@opencode-ai/sdk": "^1.18.10", + "@opentui/core": "^0.4.5", + "@opentui/keymap": "^0.4.5", + "@opentui/solid": "^0.4.5", + "@types/bun": "1.3.14", + "@types/node": "24.10.1", + "@types/semver": "7.7.1", + "semver": "7.7.4", + "typescript": "5.9.3" + }, + "engines": { + "bun": "1.3.5", + "node": "24.11.0", + "opencode": ">=1.18.10" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.18.10 <2", + "@opentui/core": "^0.4.5", + "@opentui/keymap": "^0.4.5", + "@opentui/solid": "^0.4.5" + } + } + } +} diff --git a/package.json b/package.json index 1d0e87b..8026414 100644 --- a/package.json +++ b/package.json @@ -1,99 +1,45 @@ { - "name": "@sbugallo/opencode-vpet", - "version": "0.2.0-dev.0", - "description": "An OpenCode virtual-pet plugin with /vpet-spawn, /vpet-freeze, /vpet-unfreeze, /vpet-set, /vpet-dex, and /vpet-history commands.", - "author": "Sergio Bugallo", - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "git+https://github.com/sbugallo/opencode-vpet.git" - }, - "homepage": "https://github.com/sbugallo/opencode-vpet#readme", - "bugs": { - "url": "https://github.com/sbugallo/opencode-vpet/issues" - }, - "keywords": [ - "opencode", - "plugin", - "virtual-pet", - "vpet", - "tui" + "name": "@jcendal/digital-pet-workspace", + "version": "0.0.0", + "type": "module", + "author": "Jorge Cendal", + "contributors": [ + "Sergio Bugallo" ], - "publishConfig": { - "access": "public" - }, - "oc-plugin": [ - "server", - "tui" + "license": "Apache-2.0", + "packageManager": "npm@11.6.1", + "workspaces": [ + "packages/*" ], - "packageManager": "bun@1.3.5", - "engines": { - "bun": ">=1.3.5", - "node": ">=20", - "opencode": ">=1.18.10" - }, - "main": "./dist/index.js", - "bin": { - "opencode-vpet": "./dist/cli.js" - }, - "dependencies": { - "solid-js": "^1.9.14" + "scripts": { + "test": "npm run test --workspace @jcendal/digital-pet-animation && npm run test --workspace @jcendal/opencode-digital-pet && npm run test --workspace cursor-digital-pet", + "test:persistence": "npm run test:persistence --workspace @jcendal/opencode-digital-pet", + "format": "biome format --write packages scripts package.json biome.json tsconfig*.json", + "format:check": "biome format packages scripts package.json biome.json tsconfig*.json", + "lint": "biome lint packages scripts package.json biome.json tsconfig*.json", + "check": "biome ci packages scripts package.json biome.json tsconfig*.json && node scripts/check-workspace-dependencies.mjs && npm run typecheck:release-metadata && npm run typecheck --workspace @jcendal/digital-pet-core && npm run typecheck --workspace @jcendal/digital-pet-animation && npm run check --workspace @jcendal/opencode-digital-pet && npm run check --workspace cursor-digital-pet", + "build": "npm run build --workspace @jcendal/opencode-digital-pet && npm run build --workspace cursor-digital-pet", + "pack:opencode": "npm pack --workspace @jcendal/opencode-digital-pet", + "release:check": "npm run release:check --workspace @jcendal/opencode-digital-pet", + "release-metadata": "node --experimental-strip-types scripts/release-metadata.ts", + "typecheck:release-metadata": "tsc -p tsconfig.release-metadata.json --noEmit" }, "devDependencies": { "@biomejs/biome": "2.5.10", - "@opencode-ai/plugin": "^1.18.10", - "@opencode-ai/sdk": "^1.18.10", - "@opentui/core": "^0.4.5", - "@opentui/keymap": "^0.4.5", - "@opentui/solid": "^0.4.5", - "@types/bun": "latest", - "@types/semver": "^7.7.1", - "semver": "^7.7.4", - "typescript": "^5.9.3" - }, - "peerDependencies": { - "@opencode-ai/plugin": ">=1.18.10", - "@opentui/core": ">=0.4.5", - "@opentui/keymap": ">=0.4.5", - "@opentui/solid": ">=0.4.5" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - }, - "./server": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - }, - "./tui": { - "types": "./dist/tui.d.ts", - "import": "./dist/tui.js", - "default": "./dist/tui.js" - } - }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], - "scripts": { - "test": "bun test", - "test:persistence": "bun test ./tests/persistence/sqlite-migrations.persistence.ts ./tests/persistence/sqlite-vpet-repository.persistence.ts", - "format": "biome format --write src tests scripts package.json biome.json tsconfig*.json", - "format:check": "biome format src tests scripts package.json biome.json tsconfig*.json", - "lint": "biome lint src tests scripts package.json biome.json tsconfig*.json", - "lint:fix": "biome lint --write src tests scripts package.json biome.json tsconfig*.json", - "check": "biome ci src tests scripts package.json biome.json tsconfig*.json && bun run typecheck && bun run typecheck:tests && bun run typecheck:release-metadata", - "typecheck": "tsc --noEmit", - "typecheck:tests": "tsc -p tsconfig.test.json --noEmit", - "typecheck:release-metadata": "tsc -p tsconfig.release-metadata.json --noEmit", - "build": "bun scripts/build.ts", - "prepack": "bun scripts/prepack.ts", - "release:check": "bun run check && bun test && bun run prepack && npm pack --dry-run --json" + "@types/bun": "1.3.14", + "@types/node": "24.10.1", + "@types/semver": "7.7.1", + "esbuild": "0.28.2", + "semver": "7.7.4", + "typescript": "5.9.3" }, - "type": "module", - "types": "./dist/index.d.ts" + "engines": { + "node": "24.11.0", + "npm": ">=11.5.1", + "bun": "1.3.5" + }, + "volta": { + "node": "24.11.0", + "bun": "1.3.5" + } } diff --git a/packages/cursor-digital-pet/.vscodeignore b/packages/cursor-digital-pet/.vscodeignore new file mode 100644 index 0000000..0921fdc --- /dev/null +++ b/packages/cursor-digital-pet/.vscodeignore @@ -0,0 +1,11 @@ +** +!package.json +!README.md +!LICENSE +!CHANGELOG.md +!icon.png +!images/ +!images/*.png +!hook-bridge.js +!dist/extension.js +!dist/sql-wasm.wasm diff --git a/packages/cursor-digital-pet/CHANGELOG.md b/packages/cursor-digital-pet/CHANGELOG.md new file mode 100644 index 0000000..127601d --- /dev/null +++ b/packages/cursor-digital-pet/CHANGELOG.md @@ -0,0 +1,10 @@ +# Cursor Digital Pet changelog + +Changes to the Cursor extension are recorded here. OpenCode releases have their own changelog. + +## [Unreleased] + +### Added + +- Cursor extension with an Explorer sidebar, partner controls, and Agent usage tracking. +- Shared pet database support, so Cursor and OpenCode can show the same partner. diff --git a/packages/cursor-digital-pet/LICENSE b/packages/cursor-digital-pet/LICENSE new file mode 100644 index 0000000..3d6428f --- /dev/null +++ b/packages/cursor-digital-pet/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object + form, made available under the License, as indicated by a copyright + notice that is included in or attached to the work (an example is + provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Sergio Bugallo Enjamio + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/cursor-digital-pet/README.md b/packages/cursor-digital-pet/README.md new file mode 100644 index 0000000..933d260 --- /dev/null +++ b/packages/cursor-digital-pet/README.md @@ -0,0 +1,293 @@ +
+
+

Cursor Digital Pet logo

+

Cursor Digital Pet

+

Digimon virtual pet for Cursor that evolves as you use Agent — shares progress with OpenCode Digital Pet

+

+ Version + Downloads + Rating + Apache 2.0 license +

+

+ Features • Installation • Usage • + Commands • Settings • Storage • + How It Works • Authors +

+

+ Install from Open VSX +    + Download VSIX from GitHub Releases +

+
+
+ +--- + +
+
+Cursor Digital Pet sidebar showing a partner and its evolution progress +Cursor Digital Pet partner animation in the sidebar +Cursor Digital Pet battle scene in the sidebar +
+
+ +--- + +## 📖 About + +**Cursor Agent** turns every prompt into real work — but what if that effort also fed a partner +that grows with you? **Cursor Digital Pet** adds a Digimon virtual pet to the Explorer sidebar. Your +partner gains experience from Agent token usage and evolves through classic Digimon stages. + +It shares the same `pet.db` database as +[@jcendal/opencode-digital-pet](https://www.npmjs.com/package/@jcendal/opencode-digital-pet), so progress +carries over between Cursor and OpenCode. + +### 🎯 Key Highlights + +- 🐣 **Evolves with Agent usage** — Input, output, and cache tokens from Agent turns count +- 🔄 **Shared with OpenCode** — Same `pet.db`, Dex, and generation history across tools +- 🎮 **650 Digimon** — Evolution lines extracted from the original V-Pets +- 📊 **Sidebar + panels** — Live partner view, Dex browser, and generation history +- 🪝 **Reliable tracking** — Cursor hooks with API watermark fallback +- 🔒 **Privacy-first** — Local SQLite storage, no telemetry +- ⏸️ **Freeze anytime** — Pause progression without losing your partner + +--- + +## ✨ Features + +| Feature | Description | +| --- | --- | +| **Sidebar webview** | Animated ASCII partner artwork with stage gauge and evolution progress | +| **Dex panel** | Browse discovered Digimon from your collection | +| **History panel** | Review current and retired partner generations | +| **Token tracking** | Cursor hooks (`beforeSubmitPrompt` + `stop`) with API watermark fallback | +| **Shared database** | Compatible `pet.db` with the OpenCode Digital Pet plugin | +| **Partner commands** | Spawn, freeze, unfreeze, and set Digimon by catalog ID | +| **Hook management** | Install or uninstall hooks from the Command Palette | + +--- + +## 📦 Installation + +### Open VSX (Cursor) + +Open Quick Open (`Ctrl+P` / `Cmd+P`) and run: + +``` +ext install jcendal.cursor-digital-pet +``` + +Or search for **Cursor Digital Pet** in the Extensions sidebar, or install from +[Open VSX](https://open-vsx.org/extension/jcendal/cursor-digital-pet). + +### VSIX (manual) + +Download the `.vsix` from +[GitHub Releases](https://github.com/jcendal/digital-pet/releases) and run +**Extensions → Install from VSIX…**. + +--- + +## 🚀 Usage + +### First run — install hooks + +On first activation, the extension prompts you to register hooks in `~/.cursor/hooks.json`. +Hooks are required for reliable Agent token tracking. + +You can also run **Cursor Digital Pet: Install Hooks** from the Command Palette at any time. + +### Sidebar + +Open the **Digital Pet** view in the Explorer sidebar to see your active partner, evolution gauge, +and animated artwork. + +### Hatch a partner + +1. Open the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`) +2. Run **Cursor Digital Pet: Spawn Partner** + +Your partner starts as an egg and evolves as you complete Agent turns. + +### Evolution battles + +Evolution sequences can show your partner in a battle scene inside the sidebar. + +### Other access methods + +- **Command Palette** — all `Cursor Digital Pet:` commands +- **Explorer sidebar** — **Digital Pet** webview panel + +--- + +## 🎮 Commands + +| Command | Description | +| --- | --- | +| **Cursor Digital Pet: Spawn Partner** | Hatches a new egg | +| **Cursor Digital Pet: Freeze** | Pauses partner progression | +| **Cursor Digital Pet: Unfreeze** | Resumes partner progression | +| **Cursor Digital Pet: Set Digimon** | Overrides the partner with a catalog ID (e.g. `3-001`) | +| **Cursor Digital Pet: Open Dex** | Opens the partner Dex panel | +| **Cursor Digital Pet: Open History** | Opens the generation history panel | +| **Cursor Digital Pet: Install Hooks** | Registers hooks in `~/.cursor/hooks.json` | +| **Cursor Digital Pet: Uninstall Hooks** | Removes Digital Pet hooks from `~/.cursor/hooks.json` | + +> **Note:** Using **Set Digimon** does not count toward Dex completion. + +--- + +## 📝 Examples + +### Shared database with OpenCode + +By default, Cursor Digital Pet and OpenCode Digital Pet resolve the same `pet.db` path. +If you override the path in Cursor, point it to the database used by OpenCode: + +```json +{ + "digital-pet.databasePath": "/path/to/opencode-digital-pet/pet.db" +} +``` + +### Slower API watermark fallback + +If token deltas are not captured immediately after Agent turns, increase the settle delay: + +```json +{ + "digital-pet.usage.settleDelayMs": 8000 +} +``` + +--- + +## ⚙️ Settings + +Configure via **File → Preferences → Settings** and search for `digital-pet`. + +| Setting | Default | Description | +| --- | --- | --- | +| `databasePath` | _(empty)_ | Override path to `pet.db` (default: shared opencode-digital-pet location) | +| `usage.settleDelayMs` | `4000` | Initial delay before API watermark fallback (ms) | + +> Changing `databasePath` requires reloading the Cursor window. + +Stage labels and evolution thresholds use the built-in defaults from `@jcendal/digital-pet-core` +(Japanese naming by default). The OpenCode plugin can override these via +[`opencode-digital-pet.json`](https://github.com/jcendal/digital-pet/blob/main/packages/opencode-digital-pet/README.md#settings); Cursor Digital Pet does +not read that file yet. + +--- + +## 🔧 Requirements + +| Requirement | Details | +| --- | --- | +| **Cursor** | Token tracking requires Cursor (`state.vscdb` must be present) | +| **Extension host** | Node.js 22.13 or newer for native SQLite access to the shared `pet.db` | +| **Node.js** | Required on the system path to run `hook-bridge.js` from Cursor hooks | +| **Hooks** | `beforeSubmitPrompt` and `stop` entries in `~/.cursor/hooks.json` | + +The extension shows a warning if it cannot detect the Cursor runtime. VS Code alone is not +enough for token tracking, though the sidebar and panels still work with an existing `pet.db`. + +--- + +## 💾 Storage + +Partner state, Dex discoveries, and generation history are stored in a SQLite database named +`pet.db`. By default it uses the same location as OpenCode Digital Pet: + +| Platform | Default path | +| --- | --- | +| **macOS** | `~/Library/Application Support/opencode-digital-pet/pet.db` | +| **Linux / WSL** | `~/.local/share/opencode-digital-pet/pet.db` | +| **Windows** | `%APPDATA%\opencode-digital-pet\pet.db` | + +Override with `digital-pet.databasePath` to use a custom file. +If the new database does not exist but an older `opencode-vpet/pet.db` does, the +extension continues using the older file, as does the OpenCode plugin. + +--- + +## 🏗️ How It Works + +```text +Cursor hooks → hook-bridge.js → digital-pet-hook-events.jsonl + ↓ + extension usage pipeline → pet.db + ↓ + Sidebar · Dex · History +``` + +1. Cursor hooks fire on `beforeSubmitPrompt` and `stop` for each Agent turn +2. `hook-bridge.js` appends structured events to `~/.cursor/digital-pet-hook-events.jsonl` +3. The extension watches the event log and maps stop payloads to completed usage +4. If stop tokens are missing, it falls back to Cursor's usage API (watermark delta) +5. Usage is applied through `@jcendal/digital-pet-core` evolution logic into `pet.db` +6. The sidebar webview refreshes with updated partner state and artwork + +--- + +## 🔒 Privacy + +- ✅ No extension telemetry or analytics +- ✅ Partner data stays in your local `pet.db` +- ✅ Hook events are written only to `~/.cursor/digital-pet-hook-events.jsonl` +- ⚠️ API watermark fallback reads your Cursor access token from local `state.vscdb` and + queries `cursor.com/api/dashboard/get-filtered-usage-events` to compute token deltas +- ⚠️ When stop hooks include token counts, no network request is needed for that turn + +--- + +## 🐛 Known Issues + +| Issue | Workaround | +| --- | --- | +| Tokens not recorded after Agent turns | Run **Cursor Digital Pet: Install Hooks** and retry | +| Missing token data in stop hook | Increase `digital-pet.usage.settleDelayMs` (e.g. `8000`) | +| Running in VS Code instead of Cursor | Token tracking is unavailable; use Cursor for Agent usage | +| Database path change not applied | Reload the Cursor window after changing settings | + +--- + +## 🤝 Contributing + +Contributions, issues, and feature requests are welcome in the monorepo! + +1. Fork the [repository](https://github.com/jcendal/digital-pet) +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'feat: add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +Package path: [`packages/cursor-digital-pet`](https://github.com/jcendal/digital-pet/tree/main/packages/cursor-digital-pet) + +--- + +## 👤 Authors + +| Role | Name | +| --- | --- | +| **Maintainer** | [Jorge Cendal](https://github.com/jcendal) | +| **Original author and contributor** | [Sergio Bugallo](https://github.com/sbugallo) | + +--- + +## 📜 License + +[Apache-2.0](LICENSE) + +Part of the [Digital Pet](https://github.com/jcendal/digital-pet) monorepo. Digimon +sprites were derived from community sources — see the +[root README credits](https://github.com/jcendal/digital-pet#maintainer-and-credits). + +--- + +

Built with ❤️ for the developer community

+ +

⬆ Back to Top

diff --git a/packages/cursor-digital-pet/esbuild.mjs b/packages/cursor-digital-pet/esbuild.mjs new file mode 100644 index 0000000..3cc6f5d --- /dev/null +++ b/packages/cursor-digital-pet/esbuild.mjs @@ -0,0 +1,36 @@ +import { copyFile, mkdir, rm } from "node:fs/promises" +import { createRequire } from "node:module" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +import { build, context } from "esbuild" + +const packageRoot = dirname(fileURLToPath(import.meta.url)) +const distDirectory = resolve(packageRoot, "dist") +const require = createRequire(import.meta.url) +const wasmSource = require.resolve("sql.js/dist/sql-wasm.wasm") +await rm(distDirectory, { recursive: true, force: true }) +await mkdir(distDirectory, { recursive: true }) +await copyFile(wasmSource, resolve(distDirectory, "sql-wasm.wasm")) + +const options = { + entryPoints: [resolve(packageRoot, "src/extension.ts")], + outfile: resolve(distDirectory, "extension.js"), + bundle: true, + platform: "node", + target: "node22.13", + format: "cjs", + external: ["vscode", "bun:sqlite"], + define: { __DIGITAL_PET_BUNDLE__: "true", "import.meta.url": "undefined" }, + minify: true, + sourcemap: false, + logLevel: "info", +} + +if (process.argv.includes("--watch")) { + const buildContext = await context(options) + await buildContext.watch() + console.log("Watching cursor-digital-pet") +} else { + await build(options) +} diff --git a/packages/cursor-digital-pet/hook-bridge.js b/packages/cursor-digital-pet/hook-bridge.js new file mode 100644 index 0000000..d7fb6e7 --- /dev/null +++ b/packages/cursor-digital-pet/hook-bridge.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/** + * Cursor hook bridge — reads hook JSON from stdin, appends to events file, responds {}. + * Fail-open: never blocks Cursor on errors. + */ +import { appendFileSync, mkdirSync } from "node:fs" +import { homedir } from "node:os" +import { dirname, join } from "node:path" +import { Readable } from "node:stream" + +const EVENTS_PATH = + process.env.DIGITAL_PET_HOOK_EVENTS_PATH ?? join(homedir(), ".cursor", "digital-pet-hook-events.jsonl") + +const readStdin = async () => { + const chunks = [] + for await (const chunk of Readable.toWeb(process.stdin)) { + chunks.push(Buffer.from(chunk)) + } + return Buffer.concat(chunks).toString("utf8") +} + +const main = async () => { + const raw = await readStdin() + if (raw.trim().length === 0) { + process.stdout.write("{}") + return + } + + let payload + try { + payload = JSON.parse(raw) + } catch { + process.stdout.write("{}") + return + } + + const hookEventName = + typeof payload.hook_event_name === "string" ? payload.hook_event_name : process.argv[2] ?? "unknown" + + const record = { + receivedAt: new Date().toISOString(), + hookEventName, + payload, + } + + try { + mkdirSync(dirname(EVENTS_PATH), { recursive: true }) + appendFileSync(EVENTS_PATH, `${JSON.stringify(record)}\n`, "utf8") + } catch (error) { + console.error("[digital-pet-hook-bridge]", error) + } + + process.stdout.write("{}") +} + +main().catch((error) => { + console.error("[digital-pet-hook-bridge]", error) + process.stdout.write("{}") +}) diff --git a/packages/cursor-digital-pet/icon.png b/packages/cursor-digital-pet/icon.png new file mode 100644 index 0000000..bd1aab4 Binary files /dev/null and b/packages/cursor-digital-pet/icon.png differ diff --git a/packages/cursor-digital-pet/images/sidebar-animation.png b/packages/cursor-digital-pet/images/sidebar-animation.png new file mode 100644 index 0000000..b89bdb6 Binary files /dev/null and b/packages/cursor-digital-pet/images/sidebar-animation.png differ diff --git a/packages/cursor-digital-pet/images/sidebar-battle.png b/packages/cursor-digital-pet/images/sidebar-battle.png new file mode 100644 index 0000000..b99a4a7 Binary files /dev/null and b/packages/cursor-digital-pet/images/sidebar-battle.png differ diff --git a/packages/cursor-digital-pet/images/sidebar-partner.png b/packages/cursor-digital-pet/images/sidebar-partner.png new file mode 100644 index 0000000..77c709c Binary files /dev/null and b/packages/cursor-digital-pet/images/sidebar-partner.png differ diff --git a/packages/cursor-digital-pet/package.json b/packages/cursor-digital-pet/package.json new file mode 100644 index 0000000..be30f63 --- /dev/null +++ b/packages/cursor-digital-pet/package.json @@ -0,0 +1,148 @@ +{ + "name": "cursor-digital-pet", + "displayName": "Cursor Digital Pet", + "description": "Digimon virtual pet for Cursor that evolves with Agent token usage.", + "version": "0.2.0-dev.0", + "publisher": "jcendal", + "author": "Jorge Cendal", + "contributors": [ + "Sergio Bugallo" + ], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/jcendal/digital-pet.git", + "directory": "packages/cursor-digital-pet" + }, + "homepage": "https://github.com/jcendal/digital-pet#readme", + "bugs": { + "url": "https://github.com/jcendal/digital-pet/issues" + }, + "keywords": [ + "cursor", + "digital-pet", + "virtual-pet", + "digimon", + "agent", + "ai", + "sidebar", + "opencode" + ], + "icon": "icon.png", + "galleryBanner": { + "color": "#1a1a2e", + "theme": "dark" + }, + "engines": { + "vscode": "^1.101.0", + "node": ">=22.13.0", + "bun": "1.3.5" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onStartupFinished" + ], + "main": "./dist/extension.js", + "contributes": { + "views": { + "explorer": [ + { + "id": "cursorDigitalPet.sidebar", + "name": "Digital Pet", + "type": "webview" + } + ] + }, + "commands": [ + { + "command": "cursorDigitalPet.spawn", + "title": "Cursor Digital Pet: Spawn Partner" + }, + { + "command": "cursorDigitalPet.freeze", + "title": "Cursor Digital Pet: Freeze" + }, + { + "command": "cursorDigitalPet.unfreeze", + "title": "Cursor Digital Pet: Unfreeze" + }, + { + "command": "cursorDigitalPet.set", + "title": "Cursor Digital Pet: Set Digimon" + }, + { + "command": "cursorDigitalPet.dex", + "title": "Cursor Digital Pet: Open Dex" + }, + { + "command": "cursorDigitalPet.history", + "title": "Cursor Digital Pet: Open History" + }, + { + "command": "cursorDigitalPet.installHooks", + "title": "Cursor Digital Pet: Install Hooks" + }, + { + "command": "cursorDigitalPet.uninstallHooks", + "title": "Cursor Digital Pet: Uninstall Hooks" + }, + { + "command": "cursorDigitalPet.dev.showMenu", + "title": "Cursor Digital Pet: Dev Tools" + } + ], + "menus": { + "commandPalette": [ + { + "command": "cursorDigitalPet.dev.showMenu", + "when": "cursorDigitalPet.devToolsEnabled" + } + ] + }, + "configuration": { + "title": "Cursor Digital Pet", + "properties": { + "digital-pet.databasePath": { + "type": "string", + "default": "", + "description": "Override path to pet.db (default: shared opencode-digital-pet/pet.db)" + }, + "digital-pet.usage.settleDelayMs": { + "type": "number", + "default": 4000, + "description": "Initial delay before API watermark fallback (ms)" + }, + "digital-pet.devTools": { + "type": "boolean", + "default": false, + "scope": "window", + "description": "Enable development tools for previewing feed, evolution reveal, and evolution battle animations." + } + } + } + }, + "scripts": { + "build": "node esbuild.mjs", + "watch": "node esbuild.mjs --watch", + "check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", + "test": "bun test", + "package": "npx @vscode/vsce package --no-dependencies", + "publish:openvsx": "npx ovsx publish *.vsix", + "vscode:prepublish": "npm run build" + }, + "dependencies": { + "@jcendal/digital-pet-animation": "0.2.0-dev.0", + "@jcendal/digital-pet-core": "0.2.0-dev.0", + "sql.js": "^1.12.0" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "@types/node": "24.10.1", + "@types/vscode": "1.90.0", + "@vscode/vsce": "3.2.1", + "ovsx": "1.2.0", + "typescript": "5.9.3" + } +} diff --git a/packages/cursor-digital-pet/src/adapters/cursor/cursor-api-watermark.ts b/packages/cursor-digital-pet/src/adapters/cursor/cursor-api-watermark.ts new file mode 100644 index 0000000..fc267cb --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/cursor/cursor-api-watermark.ts @@ -0,0 +1,142 @@ +import type { CompletedUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" + +import { CURSOR_USAGE_EVENTS_URL } from "../../shared/constants/cursor.ts" +import { sleep } from "../../shared/sleep.ts" +import { readCursorAccessToken } from "./cursor-auth.ts" +import type { UsageApiEvent, UsageApiResponse, UsageWatermark } from "./types.ts" + +export const fingerprintEvent = (event: UsageApiEvent | undefined): string => { + if (event === undefined) return "none" + const tokens = event.tokenUsage + return `${event.timestamp}:${event.model}:${tokens?.inputTokens ?? 0}:${tokens?.outputTokens ?? 0}` +} + +export const hasValidTokens = (event: UsageApiEvent): boolean => { + if (!event.isTokenBasedCall || event.tokenUsage === undefined) return false + const { inputTokens = 0, outputTokens = 0, cacheWriteTokens = 0, cacheReadTokens = 0 } = event.tokenUsage + return inputTokens + outputTokens + cacheWriteTokens + cacheReadTokens > 0 +} + +export const sumTokenDelta = (events: readonly UsageApiEvent[]): number => + events.reduce((sum, event) => { + const tokens = event.tokenUsage + if (tokens === undefined) return sum + return ( + sum + + (tokens.inputTokens ?? 0) + + (tokens.outputTokens ?? 0) + + (tokens.cacheWriteTokens ?? 0) + + (tokens.cacheReadTokens ?? 0) + ) + }, 0) + +export const isNewEvent = (event: UsageApiEvent, watermark: UsageWatermark): boolean => { + const timestampMs = Number(event.timestamp) + if (timestampMs < watermark.timestampMs) return false + if (timestampMs === watermark.timestampMs && fingerprintEvent(event) === watermark.fingerprint) return false + return hasValidTokens(event) +} + +const fetchUsagePage = async ( + accessToken: string, + body: Record, +): Promise => { + const response = await fetch(CURSOR_USAGE_EVENTS_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + Origin: "https://cursor.com", + }, + body: JSON.stringify(body), + }) + + if (!response.ok) { + throw new Error(`Usage API failed: ${response.status}`) + } + + return (await response.json()) as UsageApiResponse +} + +export const captureWatermark = async (): Promise => { + const accessToken = await readCursorAccessToken() + if (accessToken === null) { + return { timestampMs: Date.now(), fingerprint: "none" } + } + + const response = await fetchUsagePage(accessToken, { page: 1, pageSize: 1 }) + const latest = response.usageEventsDisplay?.find(hasValidTokens) + if (latest === undefined) { + return { timestampMs: Date.now(), fingerprint: "none" } + } + + return { + timestampMs: Number(latest.timestamp), + fingerprint: fingerprintEvent(latest), + } +} + +export const fetchNewEventsSince = async ( + watermark: UsageWatermark, + endDateMs: number, +): Promise => { + const accessToken = await readCursorAccessToken() + if (accessToken === null) return [] + + const collected: UsageApiEvent[] = [] + let page = 1 + const pageSize = 100 + + while (true) { + const response = await fetchUsagePage(accessToken, { + startDate: String(watermark.timestampMs), + endDate: String(endDateMs), + page, + pageSize, + }) + + const events = response.usageEventsDisplay ?? [] + for (const event of events) { + if (isNewEvent(event, watermark)) collected.push(event) + } + + const total = response.totalUsageEventsCount ?? 0 + if (page * pageSize >= total || events.length === 0) break + page += 1 + } + + return collected.sort((a, b) => Number(a.timestamp) - Number(b.timestamp)) +} + +export type SettleOptions = { + readonly initialDelayMs: number + readonly retryDelaysMs: readonly number[] +} + +export const settleTurnDelta = async ( + watermark: UsageWatermark, + generationId: string, + options: SettleOptions, +): Promise => { + const delays = [options.initialDelayMs, ...options.retryDelaysMs] + + for (const delayMs of delays) { + await sleep(delayMs) + const endDateMs = Date.now() + const newEvents = await fetchNewEventsSince(watermark, endDateMs) + if (newEvents.length === 0) continue + + const tokenDelta = sumTokenDelta(newEvents) + if (tokenDelta <= 0) continue + + return { + receiptKey: `cursor-turn:${generationId}`, + eventId: `cursor-api-delta:${generationId}`, + tokenDelta, + cost: null, + createdAt: new Date().toISOString(), + } + } + + return null +} diff --git a/packages/cursor-digital-pet/src/adapters/cursor/cursor-auth.ts b/packages/cursor-digital-pet/src/adapters/cursor/cursor-auth.ts new file mode 100644 index 0000000..eda88a8 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/cursor/cursor-auth.ts @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs" +import type { Database } from "sql.js" + +import { CURSOR_ACCESS_TOKEN_KEY } from "../../shared/constants/cursor.ts" +import { getSqlRuntime } from "../sqlite/sqljs-config.ts" +import { resolveStateVscdbPath } from "./paths.ts" + +const queryItemTable = (database: Database, key: string): string | null => { + const statement = database.prepare("SELECT value FROM ItemTable WHERE key = ?") + statement.bind([key]) + if (!statement.step()) { + statement.free() + return null + } + const row = statement.getAsObject() as { value?: unknown } + statement.free() + const cell = row.value + if (cell === null || cell === undefined) return null + return typeof cell === "string" ? cell : String(cell) +} + +export const readCursorAccessToken = async (): Promise => { + const statePath = resolveStateVscdbPath() + try { + const SQL = await getSqlRuntime() + const buffer = readFileSync(statePath) + const database = new SQL.Database(buffer) + try { + return queryItemTable(database, CURSOR_ACCESS_TOKEN_KEY) + } finally { + database.close() + } + } catch { + return null + } +} diff --git a/packages/cursor-digital-pet/src/adapters/cursor/cursor-stop-mapper.ts b/packages/cursor-digital-pet/src/adapters/cursor/cursor-stop-mapper.ts new file mode 100644 index 0000000..8380f78 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/cursor/cursor-stop-mapper.ts @@ -0,0 +1,26 @@ +import type { CompletedUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" + +import type { CursorHookPayload } from "./types.ts" + +export const hasStopTokens = (payload: CursorHookPayload): boolean => { + const input = payload.input_tokens ?? 0 + const output = payload.output_tokens ?? 0 + return input + output > 0 +} + +export const toCompletedUsageFromStop = (payload: CursorHookPayload, receivedAt: string): CompletedUsage | null => { + if (!hasStopTokens(payload)) return null + + const generationId = payload.generation_id ?? payload.conversation_id + if (generationId === undefined || generationId.length === 0) return null + + const tokenDelta = (payload.input_tokens ?? 0) + (payload.output_tokens ?? 0) + + return { + receiptKey: `cursor-turn:${generationId}`, + eventId: `cursor-stop:${generationId}`, + tokenDelta, + cost: null, + createdAt: receivedAt, + } +} diff --git a/packages/cursor-digital-pet/src/adapters/cursor/cursor-usage-event-source.ts b/packages/cursor-digital-pet/src/adapters/cursor/cursor-usage-event-source.ts new file mode 100644 index 0000000..b868d04 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/cursor/cursor-usage-event-source.ts @@ -0,0 +1,145 @@ +import { watch } from "node:fs" +import { open, stat, writeFile } from "node:fs/promises" + +import type { UsageProcessingResult } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import { recordUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import type { CompletedUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { STAGE_GAUGE_THRESHOLDS } from "@jcendal/digital-pet-core/domain/evolution.ts" + +import { captureWatermark, settleTurnDelta } from "./cursor-api-watermark.ts" +import { hasStopTokens, toCompletedUsageFromStop } from "./cursor-stop-mapper.ts" +import { resolveHookEventsPath } from "./paths.ts" +import type { HookEventRecord, UsageWatermark } from "./types.ts" + +type TurnState = { + watermark: UsageWatermark +} + +export type CursorUsageSettleOptions = { + readonly settleDelayMs: number +} + +export type CursorUsageEventSource = { + dispose(): void +} + +const toSettleOptions = (options: CursorUsageSettleOptions) => ({ + initialDelayMs: options.settleDelayMs, + retryDelaysMs: [Math.max(options.settleDelayMs + 2000, 6000), Math.max(options.settleDelayMs + 6000, 10000)], +}) + +export const createCursorUsageEventSource = ( + ledger: UsageLedger, + onApplied?: (result: UsageProcessingResult) => void, + options: CursorUsageSettleOptions & { readonly eventsPath?: string } = { settleDelayMs: 4000 }, +): CursorUsageEventSource => { + const eventsPath = options.eventsPath ?? resolveHookEventsPath() + const settleOptions = toSettleOptions(options) + const pendingWatermarks = new Map() + const processedReceipts = new Set() + let disposed = false + let watcher: ReturnType | undefined + + const applyUsage = async (usage: CompletedUsage, source: string): Promise => { + if (processedReceipts.has(usage.receiptKey)) return + processedReceipts.add(usage.receiptKey) + const result = recordUsage({ + ledger, + usage, + digimonById: DIGIMON_CATALOG.byId, + catalogNodes: DIGIMON_CATALOG.nodes, + selector: Math.random, + thresholds: STAGE_GAUGE_THRESHOLDS, + }) + console.log(`[cursor-digital-pet] ${source}`, result) + if (result.kind === "applied") onApplied?.(result) + } + + const handleBeforeSubmit = async (record: HookEventRecord): Promise => { + const conversationId = record.payload.conversation_id + const watermark = await captureWatermark() + const key = conversationId ?? `turn:${record.receivedAt}` + pendingWatermarks.set(key, { watermark }) + } + + const handleStop = async (record: HookEventRecord): Promise => { + const conversationId = record.payload.conversation_id + const generationId = record.payload.generation_id ?? conversationId + if (generationId === undefined) return + + if (hasStopTokens(record.payload)) { + const usage = toCompletedUsageFromStop(record.payload, record.receivedAt) + if (usage !== null) { + await applyUsage(usage, "hook-stop") + pendingWatermarks.delete(conversationId ?? generationId) + return + } + } + + const key = conversationId ?? generationId + const state = pendingWatermarks.get(key) + pendingWatermarks.delete(key) + const usage = await settleTurnDelta( + state?.watermark ?? { timestampMs: 0, fingerprint: "none" }, + generationId, + settleOptions, + ) + if (usage !== null) { + await applyUsage(usage, "api-watermark") + } + } + + const processLine = async (line: string): Promise => { + const trimmed = line.trim() + if (trimmed.length === 0) return + let record: HookEventRecord + try { + record = JSON.parse(trimmed) as HookEventRecord + } catch { + return + } + const eventName = record.hookEventName ?? record.payload.hook_event_name + if (eventName === "beforeSubmitPrompt") await handleBeforeSubmit(record) + if (eventName === "stop") await handleStop(record) + } + + const startWatch = async (): Promise => { + let offset = 0 + try { + const fileStat = await stat(eventsPath) + offset = fileStat.size + } catch { + await writeFile(eventsPath, "", "utf8") + } + + watcher = watch(eventsPath, async () => { + if (disposed) return + try { + const fileStat = await stat(eventsPath) + if (fileStat.size <= offset) return + const handle = await open(eventsPath, "r") + const length = fileStat.size - offset + const buffer = Buffer.alloc(length) + await handle.read(buffer, 0, length, offset) + await handle.close() + offset = fileStat.size + for (const line of buffer.toString("utf8").split("\n")) { + await processLine(line) + } + } catch (error) { + console.error("[cursor-digital-pet] hook watch error", error) + } + }) + } + + void startWatch() + + return { + dispose() { + disposed = true + watcher?.close() + }, + } +} diff --git a/packages/cursor-digital-pet/src/adapters/cursor/install-hooks.ts b/packages/cursor-digital-pet/src/adapters/cursor/install-hooks.ts new file mode 100644 index 0000000..4900751 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/cursor/install-hooks.ts @@ -0,0 +1,62 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname } from "node:path" + +import { DIGITAL_PET_HOOK_MARKER } from "../../shared/constants/cursor.ts" +import { resolveHooksJsonPath } from "./paths.ts" + +export type HooksJson = { + readonly version: number + readonly hooks: Record +} + +const parseHooksJson = (raw: string): HooksJson => { + const parsed = JSON.parse(raw) as HooksJson + if (typeof parsed.version !== "number" || typeof parsed.hooks !== "object") { + throw new Error("Invalid hooks.json shape") + } + return parsed +} + +const isDigitalPetHook = (entry: { readonly command: string }): boolean => + entry.command.includes(DIGITAL_PET_HOOK_MARKER) + +export const installDigitalPetHooks = (bridgePath: string): { readonly hooksPath: string } => { + const hooksPath = resolveHooksJsonPath() + const command = `node "${bridgePath}"` + + mkdirSync(dirname(hooksPath), { recursive: true }) + + let config: HooksJson + try { + config = parseHooksJson(readFileSync(hooksPath, "utf8")) + } catch { + config = { version: 1, hooks: {} } + } + + const ensureHook = (name: string): void => { + const existing = config.hooks[name] ?? [] + config.hooks[name] = [...existing.filter((entry) => !isDigitalPetHook(entry)), { command }] + } + + ensureHook("beforeSubmitPrompt") + ensureHook("stop") + + writeFileSync(hooksPath, `${JSON.stringify(config, null, 2)}\n`, "utf8") + return { hooksPath } +} + +export const uninstallDigitalPetHooks = (): boolean => { + const hooksPath = resolveHooksJsonPath() + try { + const config = parseHooksJson(readFileSync(hooksPath, "utf8")) + const nextHooks: HooksJson["hooks"] = {} + for (const [name, entries] of Object.entries(config.hooks)) { + const filtered = entries.filter((entry) => !isDigitalPetHook(entry)) + if (filtered.length > 0) nextHooks[name] = filtered + } + writeFileSync(hooksPath, `${JSON.stringify({ ...config, hooks: nextHooks }, null, 2)}\n`, "utf8") + return true + } catch { + return false + } +} diff --git a/packages/cursor-digital-pet/src/adapters/cursor/paths.ts b/packages/cursor-digital-pet/src/adapters/cursor/paths.ts new file mode 100644 index 0000000..e4f0677 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/cursor/paths.ts @@ -0,0 +1,32 @@ +import { homedir } from "node:os" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +declare const __DIGITAL_PET_BUNDLE__: boolean + +export const resolveCursorUserDir = (): string => { + if (process.platform === "darwin") { + return join(homedir(), "Library", "Application Support", "Cursor", "User") + } + if (process.platform === "win32") { + const appData = process.env.APPDATA ?? join(homedir(), "AppData", "Roaming") + return join(appData, "Cursor", "User") + } + return join(homedir(), ".config", "Cursor", "User") +} + +export const resolveStateVscdbPath = (): string => join(resolveCursorUserDir(), "globalStorage", "state.vscdb") + +export const resolveHooksJsonPath = (): string => join(homedir(), ".cursor", "hooks.json") + +export const resolveHookEventsPath = (): string => + process.env.DIGITAL_PET_HOOK_EVENTS_PATH ?? join(homedir(), ".cursor", "digital-pet-hook-events.jsonl") + +export const resolveHookBridgePath = (): string => { + const override = process.env.DIGITAL_PET_HOOK_BRIDGE_PATH + if (override !== undefined && override.length > 0) return override + if (typeof __DIGITAL_PET_BUNDLE__ !== "undefined" && __DIGITAL_PET_BUNDLE__) { + return join(__dirname, "..", "hook-bridge.js") + } + return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "hook-bridge.js") +} diff --git a/packages/cursor-digital-pet/src/adapters/cursor/types.ts b/packages/cursor-digital-pet/src/adapters/cursor/types.ts new file mode 100644 index 0000000..6a9f82a --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/cursor/types.ts @@ -0,0 +1,41 @@ +export type { CompletedUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" + +export type UsageWatermark = { + readonly timestampMs: number + readonly fingerprint: string + readonly conversationId?: string +} + +export type HookEventRecord = { + readonly receivedAt: string + readonly hookEventName: string + readonly payload: CursorHookPayload +} + +export type CursorHookPayload = { + readonly hook_event_name?: string + readonly conversation_id?: string + readonly generation_id?: string + readonly input_tokens?: number + readonly output_tokens?: number + readonly cache_read_tokens?: number + readonly cache_write_tokens?: number + readonly status?: string +} + +export type UsageApiEvent = { + readonly timestamp: string + readonly model: string + readonly isTokenBasedCall?: boolean + readonly tokenUsage?: { + readonly inputTokens?: number + readonly outputTokens?: number + readonly cacheWriteTokens?: number + readonly cacheReadTokens?: number + } +} + +export type UsageApiResponse = { + readonly totalUsageEventsCount?: number + readonly usageEventsDisplay?: readonly UsageApiEvent[] +} diff --git a/packages/cursor-digital-pet/src/adapters/sqlite/database-change-watcher.ts b/packages/cursor-digital-pet/src/adapters/sqlite/database-change-watcher.ts new file mode 100644 index 0000000..c6134df --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/sqlite/database-change-watcher.ts @@ -0,0 +1,76 @@ +import { mkdirSync } from "node:fs" +import { watch } from "node:fs" +import { basename, dirname } from "node:path" + +import { + resolveHostDatabasePath, + type HostPathOptions, +} from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" + +export type DatabaseChangeWatcherOptions = HostPathOptions & { + readonly databasePath?: string + readonly onChange: () => void + readonly debounceMs?: number +} + +export type DatabaseChangeWatcher = { + dispose(): void +} + +type DirectoryWatcher = { close(): void } +export type WatchDirectory = ( + directory: string, + listener: (eventType: string, filename: string | null) => void, +) => DirectoryWatcher + +const isPetDatabaseFilename = (filename: string | null | undefined, databaseFileName: string): boolean => { + if (filename === undefined || filename === null) return true + return filename === databaseFileName || filename === `${databaseFileName}-wal` +} + +export const createDatabaseChangeWatcher = ( + options: DatabaseChangeWatcherOptions, + watchDirectory: WatchDirectory = watch, +): DatabaseChangeWatcher => { + const databasePath = options.databasePath ?? resolveHostDatabasePath(options) + const databaseDirectory = dirname(databasePath) + const databaseFileName = basename(databasePath) + const debounceMs = options.debounceMs ?? 200 + + let disposed = false + let debounceTimer: ReturnType | undefined + let watcher: DirectoryWatcher | undefined + + const scheduleChange = (): void => { + if (disposed) return + if (debounceTimer !== undefined) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + debounceTimer = undefined + if (!disposed) options.onChange() + }, debounceMs) + } + + const handleWatchEvent = (filename: string | null | undefined): void => { + if (disposed) return + if (!isPetDatabaseFilename(filename, databaseFileName)) return + scheduleChange() + } + + mkdirSync(databaseDirectory, { recursive: true }) + watcher = watchDirectory(databaseDirectory, (eventType, filename) => { + if (eventType !== "change" && eventType !== "rename") return + handleWatchEvent(filename) + }) + + return { + dispose() { + disposed = true + if (debounceTimer !== undefined) { + clearTimeout(debounceTimer) + debounceTimer = undefined + } + watcher?.close() + watcher = undefined + }, + } +} diff --git a/packages/cursor-digital-pet/src/adapters/sqlite/errors.ts b/packages/cursor-digital-pet/src/adapters/sqlite/errors.ts new file mode 100644 index 0000000..7726aa8 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/sqlite/errors.ts @@ -0,0 +1,3 @@ +export const isRecoverableSqliteReadError = (error: unknown): boolean => + error instanceof Error && + (error.message.includes("sqlite") || error.message.includes("database") || error.message.includes("no such table")) diff --git a/packages/cursor-digital-pet/src/adapters/sqlite/options.ts b/packages/cursor-digital-pet/src/adapters/sqlite/options.ts new file mode 100644 index 0000000..a8a3b06 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/sqlite/options.ts @@ -0,0 +1,11 @@ +import { + resolveHostDatabasePath, + type HostPathOptions, +} from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" + +export type SqliteDatabaseOptions = HostPathOptions & { + readonly databasePath?: string +} + +export const resolveDatabasePath = (options: SqliteDatabaseOptions = {}): string => + options.databasePath ?? resolveHostDatabasePath(options) diff --git a/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-digital-pet-archive-reader.ts b/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-digital-pet-archive-reader.ts new file mode 100644 index 0000000..98bf280 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-digital-pet-archive-reader.ts @@ -0,0 +1,98 @@ +import { existsSync } from "node:fs" + +import { + ARCHIVE_PARTNER_EVENTS_SELECT, + ARCHIVE_PARTNERS_SELECT, + type PersistedPartnerEventRow, + type PersistedPartnerRow, +} from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-schema.ts" +import type { + DigitalPetArchiveEvent, + DigitalPetArchivePartner, + DigitalPetArchiveResult, +} from "@jcendal/digital-pet-core/application/models/digital-pet-archive.ts" +import type { DigitalPetArchiveReader } from "@jcendal/digital-pet-core/application/ports/digital-pet-archive.ts" +import type { SqliteExecutor } from "@jcendal/digital-pet-core/ports/sqlite-executor.ts" + +import { UNAVAILABLE_ARCHIVE_MESSAGE } from "../../shared/constants/sqlite.ts" +import { isRecoverableSqliteReadError } from "./errors.ts" +import { resolveDatabasePath, type SqliteDatabaseOptions } from "./options.ts" +import { openReadonlySqliteDatabase } from "./sqlite-driver.ts" + +export type CreateSqliteDigitalPetArchiveReaderOptions = SqliteDatabaseOptions + +const toArchiveEvent = (row: PersistedPartnerEventRow): DigitalPetArchiveEvent => ({ + eventId: row.event_id, + currentNodeId: row.current_node_id, + createdAt: row.created_at, +}) + +const toArchivePartner = ( + row: PersistedPartnerRow, + events: readonly DigitalPetArchiveEvent[], +): DigitalPetArchivePartner => ({ + partnerId: row.partner_id, + generation: row.generation, + createdAt: row.created_at, + retiredAt: row.retired_at, + events, +}) + +const groupEventsByPartnerId = ( + rows: readonly PersistedPartnerEventRow[], +): ReadonlyMap => { + const eventsByPartnerId = new Map() + for (const row of rows) { + const events = eventsByPartnerId.get(row.partner_id) + if (events === undefined) { + eventsByPartnerId.set(row.partner_id, [toArchiveEvent(row)]) + continue + } + events.push(toArchiveEvent(row)) + } + return eventsByPartnerId +} + +export const readSqliteDigitalPetArchive = (executor: Pick): DigitalPetArchiveResult => { + const partners = executor.all(ARCHIVE_PARTNERS_SELECT) + if (partners.length === 0) return { kind: "empty" } + + const eventsByPartnerId = groupEventsByPartnerId( + executor.all(ARCHIVE_PARTNER_EVENTS_SELECT), + ) + return { + kind: "available", + partners: partners.map((partner) => toArchivePartner(partner, eventsByPartnerId.get(partner.partner_id) ?? [])), + } +} + +export const createSqliteDigitalPetArchiveReader = async ( + options: CreateSqliteDigitalPetArchiveReaderOptions = {}, +): Promise => { + const databasePath = resolveDatabasePath(options) + return { + getArchive(): DigitalPetArchiveResult { + return readArchiveAtPath(databasePath) + }, + } +} + +const readArchiveAtPath = (databasePath: string): DigitalPetArchiveResult => { + if (!existsSync(databasePath)) return { kind: "empty" } + + try { + const database = openReadonlySqliteDatabase(databasePath) + try { + return readSqliteDigitalPetArchive(database.executor) + } finally { + database.close() + } + } catch (error) { + if (isRecoverableSqliteReadError(error)) return { kind: "unavailable", message: UNAVAILABLE_ARCHIVE_MESSAGE } + throw error + } +} + +export const readArchive = async ( + options: CreateSqliteDigitalPetArchiveReaderOptions = {}, +): Promise => readArchiveAtPath(resolveDatabasePath(options)) diff --git a/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-digital-pet-write-store.ts b/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-digital-pet-write-store.ts new file mode 100644 index 0000000..023904c --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-digital-pet-write-store.ts @@ -0,0 +1,45 @@ +import type { SqliteDigitalPetWriteStore } from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-types.ts" +import { createSqliteDigitalPetWriteStore } from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-write-store.ts" +import type { SidebarSnapshotReader } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" + +import { resolveDatabasePath, type SqliteDatabaseOptions } from "./options.ts" +import { readSidebarSnapshotFromExecutor } from "./sqlite-sidebar-snapshot-reader.ts" +import { openWritableSqliteDatabase } from "./sqlite-driver.ts" + +export type CreateSqliteDigitalPetRepositoryOptions = SqliteDatabaseOptions + +export { resolveHostDatabasePath } from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" +export { resolveDatabasePath } from "./options.ts" +export type { HostPathOptions } from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" +export type { + PersistedPartnerEvent, + SqliteDigitalPetWriteStore, +} from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-types.ts" + +export type CursorSqliteDigitalPetRepository = SqliteDigitalPetWriteStore & + SidebarSnapshotReader & { + reloadFromDisk(): void + } + +export const createSqliteDigitalPetRepository = async ( + options: CreateSqliteDigitalPetRepositoryOptions = {}, +): Promise => { + const databasePath = resolveDatabasePath(options) + const database = openWritableSqliteDatabase(databasePath) + + const writeStore = createSqliteDigitalPetWriteStore({ + databasePath, + executor: database.executor, + close: () => { + database.close() + }, + }) + + return { + ...writeStore, + getSidebarSnapshot: () => readSidebarSnapshotFromExecutor(database.executor), + reloadFromDisk: () => { + database.reloadFromDisk() + }, + } +} diff --git a/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-driver.ts b/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-driver.ts new file mode 100644 index 0000000..f5eb53b --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-driver.ts @@ -0,0 +1,108 @@ +import { mkdirSync } from "node:fs" +import { dirname } from "node:path" + +import type { QueryValue, SqliteExecutor } from "@jcendal/digital-pet-core/ports/sqlite-executor.ts" + +type SqliteValue = string | number | null + +type Statement = { + run(...params: SqliteValue[]): unknown + get(...params: SqliteValue[]): unknown + all(...params: SqliteValue[]): unknown[] +} + +type Connection = { + exec(sql: string): void + prepare(sql: string): Statement + close(): void +} + +type BunDatabaseConstructor = new ( + path: string, + options: { readonly create: boolean; readonly readonly: boolean }, +) => { + run(sql: string): unknown + query(sql: string): Statement + close(): void +} + +export type SqliteDatabase = { + readonly executor: SqliteExecutor + reloadFromDisk(): void + close(): void +} + +const toSqliteValue = (value: QueryValue): SqliteValue => (typeof value === "boolean" ? Number(value) : value) + +const openConnection = (databasePath: string, readonly: boolean): Connection => { + if ("bun" in process.versions) { + const { Database } = require("bun:sqlite") as { Database: BunDatabaseConstructor } + const database = new Database(databasePath, { create: !readonly, readonly }) + return { + exec: (sql) => { + database.run(sql) + }, + prepare: (sql) => database.query(sql), + close: () => database.close(), + } + } + + const { DatabaseSync } = require("node:sqlite") as typeof import("node:sqlite") + const database = new DatabaseSync(databasePath, { readOnly: readonly }) + return { + exec: (sql) => database.exec(sql), + prepare: (sql) => database.prepare(sql), + close: () => database.close(), + } +} + +const createExecutor = (database: Connection): SqliteExecutor => ({ + run(sql, params = []) { + database.prepare(sql).run(...params.map(toSqliteValue)) + }, + get>(sql: string, params: readonly QueryValue[] = []) { + return (database.prepare(sql).get(...params.map(toSqliteValue)) as TRow | undefined) ?? null + }, + all>(sql: string, params: readonly QueryValue[] = []) { + return database.prepare(sql).all(...params.map(toSqliteValue)) as TRow[] + }, + transaction(operation) { + database.exec("BEGIN IMMEDIATE") + try { + const result = operation() + database.exec("COMMIT") + return result + } catch (error) { + try { + database.exec("ROLLBACK") + } catch { + // Preserve the original failure if SQLite already closed the transaction. + } + throw error + } + }, +}) + +export const openReadonlySqliteDatabase = (databasePath: string): SqliteDatabase => { + const database = openConnection(databasePath, true) + database.exec("PRAGMA busy_timeout = 5000") + return { + executor: createExecutor(database), + reloadFromDisk() {}, + close: () => database.close(), + } +} + +export const openWritableSqliteDatabase = (databasePath: string): SqliteDatabase => { + mkdirSync(dirname(databasePath), { recursive: true }) + const database = openConnection(databasePath, false) + database.exec("PRAGMA busy_timeout = 5000") + database.exec("PRAGMA journal_mode = WAL") + database.exec("PRAGMA foreign_keys = ON") + return { + executor: createExecutor(database), + // A file-backed SQLite connection sees committed writes from other processes. + reloadFromDisk() {}, + close: () => database.close(), + } +} diff --git a/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts b/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts new file mode 100644 index 0000000..d88c31a --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts @@ -0,0 +1,89 @@ +import { existsSync } from "node:fs" + +import { + ACTIVE_PARTNER_SELECT, + toPartner, + type PersistedPartnerRow, + type TrainerStateRow, +} from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-schema.ts" +import type { + SidebarSnapshot, + SidebarSnapshotReader, +} from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import type { SqliteExecutor } from "@jcendal/digital-pet-core/ports/sqlite-executor.ts" + +import { CONTROL_STATE_SELECT, TRAINER_STATE_SELECT } from "../../shared/constants/sqlite.ts" +import { isRecoverableSqliteReadError } from "./errors.ts" +import { resolveDatabasePath, type SqliteDatabaseOptions } from "./options.ts" +import { openReadonlySqliteDatabase } from "./sqlite-driver.ts" + +export type CreateSqliteSidebarSnapshotReaderOptions = SqliteDatabaseOptions + +type ControlStateRow = { + readonly frozen: number + readonly cheat_node_id: string | null +} + +export const readSidebarSnapshotFromExecutor = (executor: Pick): SidebarSnapshot | null => { + const trainer = executor.get(TRAINER_STATE_SELECT) + const control = executor.get(CONTROL_STATE_SELECT) + if (control?.cheat_node_id !== null && control?.cheat_node_id !== undefined) { + return { + currentNodeId: control.cheat_node_id, + gauge: 0, + isTerminal: true, + frozen: false, + isSetOverride: true, + trainerTotalTokens: trainer?.total_tokens ?? 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + } + } + + const partnerRow = executor.get(ACTIVE_PARTNER_SELECT) + if (partnerRow === null) return null + + const partner = toPartner(partnerRow) + return { + currentNodeId: partner.currentNodeId, + gauge: partner.gauge, + isTerminal: partner.isTerminal, + frozen: control?.frozen === 1, + isSetOverride: false, + trainerTotalTokens: trainer?.total_tokens ?? 0, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, + } +} + +const readSidebarSnapshotAtPath = (databasePath: string): SidebarSnapshot | null => { + if (!existsSync(databasePath)) return null + + const database = openReadonlySqliteDatabase(databasePath) + try { + return readSidebarSnapshotFromExecutor(database.executor) + } catch (error) { + if (isRecoverableSqliteReadError(error)) return null + throw error + } finally { + database.close() + } +} + +export const createSqliteSidebarSnapshotReader = async ( + options: CreateSqliteSidebarSnapshotReaderOptions = {}, +): Promise => { + const databasePath = resolveDatabasePath(options) + + return { + getSidebarSnapshot(): SidebarSnapshot | null { + return readSidebarSnapshotAtPath(databasePath) + }, + } +} + +export const readSidebarSnapshot = async ( + options: CreateSqliteSidebarSnapshotReaderOptions = {}, +): Promise => { + return readSidebarSnapshotAtPath(resolveDatabasePath(options)) +} diff --git a/packages/cursor-digital-pet/src/adapters/sqlite/sqljs-config.ts b/packages/cursor-digital-pet/src/adapters/sqlite/sqljs-config.ts new file mode 100644 index 0000000..20f7741 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/sqlite/sqljs-config.ts @@ -0,0 +1,29 @@ +import { createRequire } from "node:module" +import initSqlJs, { type SqlJsStatic } from "sql.js" + +declare const __DIGITAL_PET_BUNDLE__: boolean + +const require = createRequire( + typeof __DIGITAL_PET_BUNDLE__ !== "undefined" && __DIGITAL_PET_BUNDLE__ ? __filename : import.meta.url, +) + +let sqlRuntime: SqlJsStatic | undefined +let wasmPath: string | undefined + +export const configureSqlJsWasmPath = (path: string): void => { + wasmPath = path +} + +const resolveWasmPath = (): string => { + if (wasmPath !== undefined) return wasmPath + return require.resolve("sql.js/dist/sql-wasm.wasm") +} + +export const getSqlRuntime = async (): Promise => { + if (sqlRuntime === undefined) { + sqlRuntime = await initSqlJs({ + locateFile: () => resolveWasmPath(), + }) + } + return sqlRuntime +} diff --git a/packages/cursor-digital-pet/src/adapters/vscode/animation-sink.ts b/packages/cursor-digital-pet/src/adapters/vscode/animation-sink.ts new file mode 100644 index 0000000..75d76d8 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/vscode/animation-sink.ts @@ -0,0 +1,16 @@ +import type { SidebarWebviewPayload } from "../../webview/sidebar/webview-messages.ts" +import type { WebviewMessenger } from "./webview-messenger.ts" + +export type AnimationSink = { + postArtwork(artwork: string): Promise + postModel(payload: SidebarWebviewPayload): Promise +} + +export const createAnimationSink = (messenger: WebviewMessenger): AnimationSink => ({ + async postArtwork(artwork: string): Promise { + await messenger.post({ type: "animation-frame", artwork }) + }, + async postModel(payload: SidebarWebviewPayload): Promise { + await messenger.post(payload) + }, +}) diff --git a/packages/cursor-digital-pet/src/adapters/vscode/notification-port.ts b/packages/cursor-digital-pet/src/adapters/vscode/notification-port.ts new file mode 100644 index 0000000..8ad53bf --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/vscode/notification-port.ts @@ -0,0 +1,11 @@ +import * as vscode from "vscode" + +export type NotificationPort = { + showInformation(message: string): void +} + +export const createVsCodeNotificationPort = (): NotificationPort => ({ + showInformation(message: string): void { + void vscode.window.showInformationMessage(message) + }, +}) diff --git a/packages/cursor-digital-pet/src/adapters/vscode/scheduler.ts b/packages/cursor-digital-pet/src/adapters/vscode/scheduler.ts new file mode 100644 index 0000000..d4ba074 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/vscode/scheduler.ts @@ -0,0 +1,10 @@ +export type IntervalScheduler = { + start(callback: () => void, intervalMs: number): () => void +} + +export const createIntervalScheduler = (): IntervalScheduler => ({ + start(callback: () => void, intervalMs: number): () => void { + const handle = setInterval(callback, intervalMs) + return () => clearInterval(handle) + }, +}) diff --git a/packages/cursor-digital-pet/src/adapters/vscode/webview-messenger.ts b/packages/cursor-digital-pet/src/adapters/vscode/webview-messenger.ts new file mode 100644 index 0000000..b669826 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/vscode/webview-messenger.ts @@ -0,0 +1,13 @@ +import type * as vscode from "vscode" + +import type { ExtensionToWebviewMessage } from "../../webview/sidebar/webview-messages.ts" + +export type WebviewMessenger = { + post(message: ExtensionToWebviewMessage): Promise +} + +export const createWebviewMessenger = (webview: vscode.Webview): WebviewMessenger => ({ + async post(message: ExtensionToWebviewMessage): Promise { + await webview.postMessage(message) + }, +}) diff --git a/packages/cursor-digital-pet/src/application/usage-pipeline.ts b/packages/cursor-digital-pet/src/application/usage-pipeline.ts new file mode 100644 index 0000000..b801c79 --- /dev/null +++ b/packages/cursor-digital-pet/src/application/usage-pipeline.ts @@ -0,0 +1,18 @@ +import type { UsageProcessingResult } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" + +import { + createCursorUsageEventSource, + type CursorUsageEventSource, +} from "../adapters/cursor/cursor-usage-event-source.ts" + +export type UsagePipeline = CursorUsageEventSource + +export const createUsagePipeline = ( + ledger: UsageLedger, + onApplied: (result: UsageProcessingResult) => void, + options: { readonly settleDelayMs: number }, +): UsagePipeline => + createCursorUsageEventSource(ledger, onApplied, { + settleDelayMs: options.settleDelayMs, + }) diff --git a/packages/cursor-digital-pet/src/bootstrap/container.ts b/packages/cursor-digital-pet/src/bootstrap/container.ts new file mode 100644 index 0000000..1b3148a --- /dev/null +++ b/packages/cursor-digital-pet/src/bootstrap/container.ts @@ -0,0 +1,57 @@ +import { join } from "node:path" +import type * as vscode from "vscode" + +import { createDatabaseChangeWatcher } from "../adapters/sqlite/database-change-watcher.ts" +import { + createSqliteDigitalPetRepository, + type CursorSqliteDigitalPetRepository, +} from "../adapters/sqlite/sqlite-digital-pet-write-store.ts" +import { configureSqlJsWasmPath } from "../adapters/sqlite/sqljs-config.ts" +import { + getDigitalPetExtensionSettings, + toDatabaseOptions, + type DigitalPetExtensionSettings, +} from "../config/extension-settings.ts" +import { DigitalPetSidebarProvider } from "../webview/digital-pet-sidebar-provider.ts" + +export type DigitalPetContainer = { + readonly repository: CursorSqliteDigitalPetRepository + readonly sidebarProvider: DigitalPetSidebarProvider + readonly readSettings: () => DigitalPetExtensionSettings + readonly databaseOptions: () => ReturnType + readonly refreshSidebar: () => void +} + +export const createDigitalPetContainer = async (context: vscode.ExtensionContext): Promise => { + configureSqlJsWasmPath(join(context.extensionPath, "dist", "sql-wasm.wasm")) + + const readSettings = () => getDigitalPetExtensionSettings() + const databaseOptions = () => toDatabaseOptions(readSettings()) + + const repository = await createSqliteDigitalPetRepository(databaseOptions()) + context.subscriptions.push({ dispose: () => repository.close() }) + + const sidebarProvider = new DigitalPetSidebarProvider(context.extensionUri, repository, repository) + const refreshSidebar = (): void => { + void sidebarProvider.refresh() + } + + const databaseWatcher = createDatabaseChangeWatcher({ + ...databaseOptions(), + onChange: () => { + repository.reloadFromDisk() + if (sidebarProvider.isPresentationInProgress() !== true) { + refreshSidebar() + } + }, + }) + context.subscriptions.push({ dispose: () => databaseWatcher.dispose() }) + + return { + repository, + sidebarProvider, + readSettings, + databaseOptions, + refreshSidebar, + } +} diff --git a/packages/cursor-digital-pet/src/bootstrap/ensure-hooks.ts b/packages/cursor-digital-pet/src/bootstrap/ensure-hooks.ts new file mode 100644 index 0000000..3147ce6 --- /dev/null +++ b/packages/cursor-digital-pet/src/bootstrap/ensure-hooks.ts @@ -0,0 +1,21 @@ +import { join } from "node:path" +import * as vscode from "vscode" + +import { installDigitalPetHooks } from "../adapters/cursor/install-hooks.ts" + +export const ensureHooksInstalled = async (context: vscode.ExtensionContext): Promise => { + const key = "digital-pet.hooksInstalled" + if (context.globalState.get(key) === true) return + + const choice = await vscode.window.showInformationMessage( + "Cursor Digital Pet needs to register hooks in ~/.cursor/hooks.json to track Agent usage.", + "Install hooks", + "Not now", + ) + if (choice !== "Install hooks") return + + const bridgePath = join(context.extensionPath, "hook-bridge.js") + const result = installDigitalPetHooks(bridgePath) + await context.globalState.update(key, true) + vscode.window.showInformationMessage(`Digital Pet hooks installed at ${result.hooksPath}`) +} diff --git a/packages/cursor-digital-pet/src/bootstrap/register-commands.ts b/packages/cursor-digital-pet/src/bootstrap/register-commands.ts new file mode 100644 index 0000000..b115b99 --- /dev/null +++ b/packages/cursor-digital-pet/src/bootstrap/register-commands.ts @@ -0,0 +1,62 @@ +import { join } from "node:path" +import * as vscode from "vscode" + +import { freezeDigitalPet } from "@jcendal/digital-pet-core/application/use-cases/freeze-digital-pet.ts" +import { setDigitalPetCheatNode } from "@jcendal/digital-pet-core/application/use-cases/set-digital-pet-cheat-node.ts" +import { spawnPartner } from "@jcendal/digital-pet-core/application/use-cases/spawn-partner.ts" +import { unfreezeDigitalPet } from "@jcendal/digital-pet-core/application/use-cases/unfreeze-digital-pet.ts" + +import { installDigitalPetHooks, uninstallDigitalPetHooks } from "../adapters/cursor/install-hooks.ts" +import { openDexPanel } from "../webview/panels/dex-panel.ts" +import { openHistoryPanel } from "../webview/panels/history-panel.ts" +import type { DigitalPetContainer } from "./container.ts" + +export const registerCommands = (context: vscode.ExtensionContext, container: DigitalPetContainer): void => { + const { repository, databaseOptions, refreshSidebar } = container + + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((event) => { + if (!event.affectsConfiguration("digital-pet")) return + vscode.window.showInformationMessage( + "Digital Pet settings changed. Reload the window to apply database path changes.", + ) + }), + vscode.commands.registerCommand("cursorDigitalPet.spawn", async () => { + spawnPartner(repository, new Date().toISOString()) + vscode.window.showInformationMessage("Digital Pet partner spawned.") + refreshSidebar() + }), + vscode.commands.registerCommand("cursorDigitalPet.freeze", async () => { + const result = freezeDigitalPet(repository) + vscode.window.showInformationMessage( + result.kind === "frozen" ? "Digital Pet frozen." : "Digital Pet already frozen.", + ) + refreshSidebar() + }), + vscode.commands.registerCommand("cursorDigitalPet.unfreeze", async () => { + const result = unfreezeDigitalPet(repository) + vscode.window.showInformationMessage( + result.kind === "unfrozen" ? "Digital Pet unfrozen." : "Digital Pet already active.", + ) + refreshSidebar() + }), + vscode.commands.registerCommand("cursorDigitalPet.set", async () => { + const nodeId = await vscode.window.showInputBox({ prompt: "Digimon node ID (e.g. 0-001)" }) + if (nodeId === undefined || nodeId.length === 0) return + const result = setDigitalPetCheatNode(repository, nodeId) + vscode.window.showInformationMessage(`Set result: ${result.kind}`) + refreshSidebar() + }), + vscode.commands.registerCommand("cursorDigitalPet.dex", () => openDexPanel(context, databaseOptions())), + vscode.commands.registerCommand("cursorDigitalPet.history", () => openHistoryPanel(context, databaseOptions())), + vscode.commands.registerCommand("cursorDigitalPet.installHooks", async () => { + const bridgePath = join(context.extensionPath, "hook-bridge.js") + installDigitalPetHooks(bridgePath) + await context.globalState.update("digital-pet.hooksInstalled", true) + }), + vscode.commands.registerCommand("cursorDigitalPet.uninstallHooks", () => { + uninstallDigitalPetHooks() + void context.globalState.update("digital-pet.hooksInstalled", false) + }), + ) +} diff --git a/packages/cursor-digital-pet/src/bootstrap/register-sidebar.ts b/packages/cursor-digital-pet/src/bootstrap/register-sidebar.ts new file mode 100644 index 0000000..5eee1a8 --- /dev/null +++ b/packages/cursor-digital-pet/src/bootstrap/register-sidebar.ts @@ -0,0 +1,13 @@ +import * as vscode from "vscode" + +import { DigitalPetSidebarProvider } from "../webview/digital-pet-sidebar-provider.ts" +import type { DigitalPetContainer } from "./container.ts" + +export const registerSidebar = (context: vscode.ExtensionContext, container: DigitalPetContainer): void => { + context.subscriptions.push( + vscode.window.registerWebviewViewProvider(DigitalPetSidebarProvider.viewType, container.sidebarProvider, { + webviewOptions: { retainContextWhenHidden: true }, + }), + { dispose: () => container.sidebarProvider.dispose() }, + ) +} diff --git a/packages/cursor-digital-pet/src/config/extension-settings.ts b/packages/cursor-digital-pet/src/config/extension-settings.ts new file mode 100644 index 0000000..b21a073 --- /dev/null +++ b/packages/cursor-digital-pet/src/config/extension-settings.ts @@ -0,0 +1,37 @@ +import * as vscode from "vscode" + +import type { HostPathOptions } from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" + +export type DigitalPetExtensionSettings = { + readonly databasePath?: string + readonly settleDelayMs: number +} + +export const getDigitalPetExtensionSettings = (): DigitalPetExtensionSettings => { + const configuration = vscode.workspace.getConfiguration("digital-pet") + const databasePath = configuration.get("databasePath", "").trim() + const settings: DigitalPetExtensionSettings = { + settleDelayMs: configuration.get("usage.settleDelayMs", 4000), + } + if (databasePath.length > 0) { + return { ...settings, databasePath } + } + return settings +} + +export const toDatabaseOptions = ( + settings: DigitalPetExtensionSettings, +): HostPathOptions & { readonly databasePath?: string } => + settings.databasePath === undefined ? {} : { databasePath: settings.databasePath } + +export const DEV_TOOLS_CONTEXT = "cursorDigitalPet.devToolsEnabled" + +export const isDevToolsEnabled = (extensionMode: vscode.ExtensionMode): boolean => { + if (process.env.CURSOR_DIGITAL_PET_DEV === "1") return true + if (extensionMode === vscode.ExtensionMode.Development) return true + return vscode.workspace.getConfiguration("digital-pet").get("devTools", false) +} + +export const syncDevToolsContext = async (extensionMode: vscode.ExtensionMode): Promise => { + await vscode.commands.executeCommand("setContext", DEV_TOOLS_CONTEXT, isDevToolsEnabled(extensionMode)) +} diff --git a/packages/cursor-digital-pet/src/dev/catalog.ts b/packages/cursor-digital-pet/src/dev/catalog.ts new file mode 100644 index 0000000..5fd052f --- /dev/null +++ b/packages/cursor-digital-pet/src/dev/catalog.ts @@ -0,0 +1,12 @@ +export const DEV_SHOW_MENU_COMMAND = "cursorDigitalPet.dev.showMenu" + +/** Keep in sync with `menus.commandPalette` in package.json */ +export const DEV_TOOLS_COMMAND_PALETTE_WHEN = "cursorDigitalPet.devToolsEnabled" + +export const CURSOR_DEV_ACTIONS = [ + { id: "feed", label: "Dev Feed" }, + { id: "evolution_reveal", label: "Dev Evolution Reveal" }, + { id: "evolution_battle", label: "Dev Evolution Battle" }, +] as const + +export type CursorDevActionId = (typeof CURSOR_DEV_ACTIONS)[number]["id"] diff --git a/packages/cursor-digital-pet/src/dev/fixtures.ts b/packages/cursor-digital-pet/src/dev/fixtures.ts new file mode 100644 index 0000000..5eadda9 --- /dev/null +++ b/packages/cursor-digital-pet/src/dev/fixtures.ts @@ -0,0 +1,6 @@ +import type { UsageEvolutionTransition } from "@jcendal/digital-pet-core/application/models/usage.ts" + +export const DEV_EVOLUTION: UsageEvolutionTransition = { + fromNodeId: "3-001", + toNodeId: "4-017", +} diff --git a/packages/cursor-digital-pet/src/dev/register-commands.ts b/packages/cursor-digital-pet/src/dev/register-commands.ts new file mode 100644 index 0000000..4f7f8f1 --- /dev/null +++ b/packages/cursor-digital-pet/src/dev/register-commands.ts @@ -0,0 +1,57 @@ +import * as vscode from "vscode" + +import type { DigitalPetContainer } from "../bootstrap/container.ts" +import { CURSOR_DEV_ACTIONS, type CursorDevActionId, DEV_SHOW_MENU_COMMAND } from "./catalog.ts" +import { DEV_EVOLUTION } from "./fixtures.ts" +import { setupBattlePending } from "./setup-scenario.ts" + +const warnBusy = (): void => { + void vscode.window.showWarningMessage("Digital Pet dev: presentation already in progress.") +} + +export const registerDevCommands = (context: vscode.ExtensionContext, container: DigitalPetContainer): void => { + const { sidebarProvider, repository } = container + + const handlers: Record void | Promise> = { + feed: () => { + if (sidebarProvider.isPresentationInProgress()) { + warnBusy() + return + } + sidebarProvider.playFeedAnimation() + }, + evolution_reveal: async () => { + if (sidebarProvider.isPresentationInProgress()) { + warnBusy() + return + } + sidebarProvider.queueEvolutionReveal(DEV_EVOLUTION) + await sidebarProvider.refresh() + }, + evolution_battle: async () => { + if (sidebarProvider.isPresentationInProgress()) { + warnBusy() + return + } + try { + setupBattlePending(repository) + await sidebarProvider.refresh() + } catch (error) { + void vscode.window.showErrorMessage( + `Digital Pet dev: ${error instanceof Error ? error.message : String(error)}`, + ) + } + }, + } + + context.subscriptions.push( + vscode.commands.registerCommand(DEV_SHOW_MENU_COMMAND, async () => { + const picked = await vscode.window.showQuickPick( + CURSOR_DEV_ACTIONS.map((action) => ({ label: action.label, actionId: action.id })), + { title: "Digital Pet Dev Tools" }, + ) + if (picked === undefined) return + void handlers[picked.actionId]() + }), + ) +} diff --git a/packages/cursor-digital-pet/src/dev/setup-scenario.ts b/packages/cursor-digital-pet/src/dev/setup-scenario.ts new file mode 100644 index 0000000..292a2b1 --- /dev/null +++ b/packages/cursor-digital-pet/src/dev/setup-scenario.ts @@ -0,0 +1,84 @@ +import type { + SidebarSnapshot, + SidebarSnapshotReader, +} from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import { recordUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import { spawnPartner } from "@jcendal/digital-pet-core/application/use-cases/spawn-partner.ts" +import type { SqliteDigitalPetWriteStore } from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-types.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { type StageThresholds } from "@jcendal/digital-pet-core/domain/evolution.ts" +import { DIGIMON_STAGES } from "@jcendal/digital-pet-core/domain/stage.ts" + +const DEV_INSTANT_BATTLE_THRESHOLDS: StageThresholds = Object.freeze( + Object.fromEntries(DIGIMON_STAGES.map((stage) => [stage, 1])) as StageThresholds, +) + +export type DevScenarioRepository = SqliteDigitalPetWriteStore & UsageLedger & SidebarSnapshotReader + +const usageReceipt = (receiptKey: string, tokenDelta = 1): Parameters[0]["usage"] => ({ + receiptKey, + eventId: `dev:${receiptKey}`, + tokenDelta, + cost: null, + createdAt: new Date().toISOString(), +}) + +const applyTokenUsage = (ledger: UsageLedger, receiptKey: string, tokenDelta = 1): ReturnType => + recordUsage({ + usage: usageReceipt(receiptKey, tokenDelta), + ledger, + digimonById: DIGIMON_CATALOG.byId, + catalogNodes: DIGIMON_CATALOG.nodes, + selector: () => 0, + thresholds: DEV_INSTANT_BATTLE_THRESHOLDS, + }) + +const hasPendingBattle = (snapshot: SidebarSnapshot): boolean => + snapshot.pendingEvolutionTargetId !== null && snapshot.battleOpponentNodeId !== null + +const assertPartnerCanBattle = (snapshot: SidebarSnapshot): void => { + const current = DIGIMON_CATALOG.byId.get(snapshot.currentNodeId) + if (current === undefined) { + throw new Error(`Digital Pet dev: partner node ${snapshot.currentNodeId} is missing from the catalog.`) + } + if (snapshot.frozen) { + throw new Error("Digital Pet dev: partner is frozen. Unfreeze before opening an evolution battle.") + } + if (snapshot.isSetOverride) { + throw new Error("Digital Pet dev: clear the digimon override before opening an evolution battle.") + } + if (snapshot.isTerminal || current.nextEvolutions.length === 0) { + throw new Error("Digital Pet dev: partner cannot evolve further, so no evolution battle can be opened.") + } +} + +export const ensureActivePartner = (repository: DevScenarioRepository): void => { + if (repository.getActivePartner() !== null) return + spawnPartner(repository, new Date().toISOString()) +} + +export const setupBattlePending = (repository: DevScenarioRepository): void => { + ensureActivePartner(repository) + const initialSnapshot = repository.getSidebarSnapshot() + if (initialSnapshot === null) throw new Error("Digital Pet dev: could not read the active partner snapshot.") + + if (hasPendingBattle(initialSnapshot)) return + + assertPartnerCanBattle(initialSnapshot) + + const receiptSuffix = Date.now().toString(36) + const current = DIGIMON_CATALOG.byId.get(initialSnapshot.currentNodeId) + if (current === undefined) + throw new Error(`Digital Pet dev: partner node ${initialSnapshot.currentNodeId} is missing from the catalog.`) + + if (current.stage === 0) applyTokenUsage(repository, `dev-hatch-${receiptSuffix}`, 1) + + const afterHatch = repository.getSidebarSnapshot() + if (afterHatch !== null && !hasPendingBattle(afterHatch)) + applyTokenUsage(repository, `dev-battle-${receiptSuffix}`, 1) + + const pending = repository.getSidebarSnapshot() + if (pending === null || !hasPendingBattle(pending)) + throw new Error("Digital Pet dev: could not open a pending evolution battle for the current partner.") +} diff --git a/packages/cursor-digital-pet/src/extension.ts b/packages/cursor-digital-pet/src/extension.ts new file mode 100644 index 0000000..fc2998e --- /dev/null +++ b/packages/cursor-digital-pet/src/extension.ts @@ -0,0 +1,58 @@ +import { existsSync } from "node:fs" +import * as vscode from "vscode" + +import { resolveStateVscdbPath } from "./adapters/cursor/paths.ts" +import { createUsagePipeline } from "./application/usage-pipeline.ts" +import { isDevToolsEnabled, syncDevToolsContext } from "./config/extension-settings.ts" +import { createDigitalPetContainer } from "./bootstrap/container.ts" +import { ensureHooksInstalled } from "./bootstrap/ensure-hooks.ts" +import { registerCommands } from "./bootstrap/register-commands.ts" +import { registerSidebar } from "./bootstrap/register-sidebar.ts" + +const isCursorRuntime = (): boolean => existsSync(resolveStateVscdbPath()) + +export async function activate(context: vscode.ExtensionContext): Promise { + if (!isCursorRuntime()) { + vscode.window.showWarningMessage("Cursor Digital Pet: token tracking requires Cursor (state.vscdb not found).") + } + + const container = await createDigitalPetContainer(context) + registerSidebar(context, container) + registerCommands(context, container) + + const usageSource = createUsagePipeline( + container.repository, + (result) => { + if (result.kind === "applied") { + container.sidebarProvider.playFeedAnimation() + if (result.evolution !== undefined) { + container.sidebarProvider.queueEvolutionReveal(result.evolution) + } + } + container.refreshSidebar() + }, + { settleDelayMs: container.readSettings().settleDelayMs }, + ) + context.subscriptions.push(usageSource) + + const poll = setInterval(container.refreshSidebar, 30_000) + context.subscriptions.push({ dispose: () => clearInterval(poll) }) + + await ensureHooksInstalled(context) + container.refreshSidebar() + + await syncDevToolsContext(context.extensionMode) + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((event) => { + if (!event.affectsConfiguration("digital-pet.devTools")) return + void syncDevToolsContext(context.extensionMode) + }), + ) + + if (isDevToolsEnabled(context.extensionMode)) { + const { registerDevCommands } = await import("./dev/register-commands.ts") + registerDevCommands(context, container) + } +} + +export function deactivate(): void {} diff --git a/packages/cursor-digital-pet/src/shared/assert-never.ts b/packages/cursor-digital-pet/src/shared/assert-never.ts new file mode 100644 index 0000000..1e92484 --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/assert-never.ts @@ -0,0 +1,3 @@ +export const assertNever = (value: never): never => { + throw new Error(`Unexpected variant: ${JSON.stringify(value)}`) +} diff --git a/packages/cursor-digital-pet/src/shared/async-refresh-queue.ts b/packages/cursor-digital-pet/src/shared/async-refresh-queue.ts new file mode 100644 index 0000000..1800f64 --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/async-refresh-queue.ts @@ -0,0 +1,27 @@ +export type AsyncRefreshQueue = { + run(task: () => Promise): Promise +} + +export const createAsyncRefreshQueue = (): AsyncRefreshQueue => { + let inFlight = false + let pending = false + + const run = async (task: () => Promise): Promise => { + if (inFlight) { + pending = true + return + } + + inFlight = true + try { + do { + pending = false + await task() + } while (pending) + } finally { + inFlight = false + } + } + + return { run } +} diff --git a/packages/cursor-digital-pet/src/shared/constants/cursor.ts b/packages/cursor-digital-pet/src/shared/constants/cursor.ts new file mode 100644 index 0000000..0702bd9 --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/constants/cursor.ts @@ -0,0 +1,3 @@ +export const CURSOR_ACCESS_TOKEN_KEY = "cursorAuth/accessToken" +export const CURSOR_USAGE_EVENTS_URL = "https://cursor.com/api/dashboard/get-filtered-usage-events" +export const DIGITAL_PET_HOOK_MARKER = "hook-bridge.js" diff --git a/packages/cursor-digital-pet/src/shared/constants/presentation-timing.ts b/packages/cursor-digital-pet/src/shared/constants/presentation-timing.ts new file mode 100644 index 0000000..dd785b9 --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/constants/presentation-timing.ts @@ -0,0 +1,2 @@ +/** Sidebar monster animation tick interval (host-specific). */ +export const SIDEBAR_VISUAL_INTERVAL_MS = 500 diff --git a/packages/cursor-digital-pet/src/shared/constants/sidebar-ui.ts b/packages/cursor-digital-pet/src/shared/constants/sidebar-ui.ts new file mode 100644 index 0000000..157dcbd --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/constants/sidebar-ui.ts @@ -0,0 +1,6 @@ +export const MIN_ARTWORK_WIDTH = 16 +export const DEFAULT_ARTWORK_WIDTH = 32 + +export const NEXT_CHECK_PREFIX = "Next check: " +export const NEXT_CHECK_BAR_WIDTH = 20 +export const SIDEBAR_URL_LABEL = "Encyclopedia entry" diff --git a/packages/cursor-digital-pet/src/shared/constants/sqlite.ts b/packages/cursor-digital-pet/src/shared/constants/sqlite.ts new file mode 100644 index 0000000..61ffa31 --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/constants/sqlite.ts @@ -0,0 +1,3 @@ +export const TRAINER_STATE_SELECT = "SELECT total_tokens FROM trainer_state WHERE trainer_id = 1" +export const CONTROL_STATE_SELECT = "SELECT frozen, cheat_node_id FROM vpet_control_state WHERE control_id = 1" +export const UNAVAILABLE_ARCHIVE_MESSAGE = "Digital Pet archive is unavailable." diff --git a/packages/cursor-digital-pet/src/shared/escape-html.ts b/packages/cursor-digital-pet/src/shared/escape-html.ts new file mode 100644 index 0000000..9e65fb2 --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/escape-html.ts @@ -0,0 +1,2 @@ +export const escapeHtml = (value: string): string => + value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """) diff --git a/packages/cursor-digital-pet/src/shared/random.ts b/packages/cursor-digital-pet/src/shared/random.ts new file mode 100644 index 0000000..16dee68 --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/random.ts @@ -0,0 +1,6 @@ +/** Returns value in [0, 1) safe for index selection. */ +export const normalizedRandom = (random: () => number): number => { + const sample = random() + if (!Number.isFinite(sample) || sample <= 0) return 0 + return sample >= 1 ? 1 - Number.EPSILON : sample +} diff --git a/packages/cursor-digital-pet/src/shared/sleep.ts b/packages/cursor-digital-pet/src/shared/sleep.ts new file mode 100644 index 0000000..3df1811 --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/sleep.ts @@ -0,0 +1,4 @@ +export const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) diff --git a/packages/cursor-digital-pet/src/types/sql.js.d.ts b/packages/cursor-digital-pet/src/types/sql.js.d.ts new file mode 100644 index 0000000..01dd3d5 --- /dev/null +++ b/packages/cursor-digital-pet/src/types/sql.js.d.ts @@ -0,0 +1,31 @@ +declare module "sql.js" { + export type SqlJsStatic = { + Database: new (data?: ArrayLike | Buffer | null) => Database + } + + export type Database = { + run(sql: string, params?: readonly (string | number | boolean | null)[]): void + prepare(sql: string): Statement + exec(sql: string, params?: readonly (string | number | boolean | null)[]): QueryExecResult[] + export(): Uint8Array + close(): void + } + + export type QueryExecResult = { + columns: string[] + values: unknown[][] + } + + export type Statement = { + bind(values?: readonly (string | number | boolean | null)[]): boolean + step(): boolean + getAsObject(): Record + free(): void + } + + export type InitSqlJsConfig = { + readonly locateFile?: (file: string) => string + } + + export default function initSqlJs(config?: InitSqlJsConfig): Promise +} diff --git a/packages/cursor-digital-pet/src/webview/digital-pet-sidebar-provider.ts b/packages/cursor-digital-pet/src/webview/digital-pet-sidebar-provider.ts new file mode 100644 index 0000000..454807c --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/digital-pet-sidebar-provider.ts @@ -0,0 +1 @@ +export { DigitalPetSidebarProvider, type DigitalPetSidebarProviderOptions } from "./sidebar/provider.ts" diff --git a/packages/cursor-digital-pet/src/webview/panels/dex-panel.ts b/packages/cursor-digital-pet/src/webview/panels/dex-panel.ts new file mode 100644 index 0000000..1e1fb74 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/panels/dex-panel.ts @@ -0,0 +1,39 @@ +import * as vscode from "vscode" + +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { buildDexViewModel } from "@jcendal/digital-pet-core/view-models/dex-view-model.ts" + +import { readArchive } from "../../adapters/sqlite/sqlite-digital-pet-archive-reader.ts" +import type { CreateSqliteDigitalPetArchiveReaderOptions } from "../../adapters/sqlite/sqlite-digital-pet-archive-reader.ts" +import { escapeHtml } from "../../shared/escape-html.ts" + +export const openDexPanel = async ( + context: vscode.ExtensionContext, + options: CreateSqliteDigitalPetArchiveReaderOptions = {}, +): Promise => { + const archive = await readArchive(options) + const model = buildDexViewModel(archive, DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS) + + const panel = vscode.window.createWebviewPanel("cursorDigitalPetDex", "Digital Pet Dex", vscode.ViewColumn.One, {}) + if (model.kind !== "available") { + const message = model.kind === "empty" ? "No discoveries yet." : escapeHtml(model.message) + panel.webview.html = `${message}` + context.subscriptions.push(panel) + return + } + + const discoveredCount = model.rows.filter((row) => row.discovered).length + const rows = model.rows + .map( + (row) => + `${escapeHtml(row.name)}${escapeHtml(row.stage)}${row.discovered ? "✓" : "—"}`, + ) + .join("") + panel.webview.html = ` +

Digital Pet Dex

+

Discovered: ${discoveredCount} / ${model.rows.length}

+ ${rows}
NameStageFound
+ ` + context.subscriptions.push(panel) +} diff --git a/packages/cursor-digital-pet/src/webview/panels/history-panel.ts b/packages/cursor-digital-pet/src/webview/panels/history-panel.ts new file mode 100644 index 0000000..28ddc13 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/panels/history-panel.ts @@ -0,0 +1,42 @@ +import * as vscode from "vscode" + +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { buildHistoryViewModel } from "@jcendal/digital-pet-core/view-models/history-view-model.ts" + +import { readArchive } from "../../adapters/sqlite/sqlite-digital-pet-archive-reader.ts" +import type { CreateSqliteDigitalPetArchiveReaderOptions } from "../../adapters/sqlite/sqlite-digital-pet-archive-reader.ts" +import { escapeHtml } from "../../shared/escape-html.ts" + +export const openHistoryPanel = async ( + context: vscode.ExtensionContext, + options: CreateSqliteDigitalPetArchiveReaderOptions = {}, +): Promise => { + const archive = await readArchive(options) + const model = buildHistoryViewModel(archive, DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS) + + const panel = vscode.window.createWebviewPanel( + "cursorDigitalPetHistory", + "Digital Pet History", + vscode.ViewColumn.One, + {}, + ) + if (model.kind !== "available") { + const message = model.kind === "empty" ? "No history yet." : escapeHtml(model.message) + panel.webview.html = `${message}` + context.subscriptions.push(panel) + return + } + + const rows = model.generations + .map( + (generation) => + `Gen ${generation.generation}${escapeHtml(generation.path.join(" → "))}${escapeHtml(generation.createdAt)}`, + ) + .join("") + panel.webview.html = ` +

Digital Pet History

+ ${rows}
GenPathCreated
+ ` + context.subscriptions.push(panel) +} diff --git a/packages/cursor-digital-pet/src/webview/sidebar/provider.ts b/packages/cursor-digital-pet/src/webview/sidebar/provider.ts new file mode 100644 index 0000000..e7a6680 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/sidebar/provider.ts @@ -0,0 +1,148 @@ +import * as vscode from "vscode" + +import type { SidebarSnapshotReader } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import type { EvolutionBattleRepository } from "@jcendal/digital-pet-core/application/use-cases/resolve-evolution-battle.ts" +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +import { createAnimationSink, type AnimationSink } from "../../adapters/vscode/animation-sink.ts" +import { createVsCodeNotificationPort, type NotificationPort } from "../../adapters/vscode/notification-port.ts" +import { createIntervalScheduler, type IntervalScheduler } from "../../adapters/vscode/scheduler.ts" +import { createWebviewMessenger } from "../../adapters/vscode/webview-messenger.ts" +import { createSidebarAnimationHost, type SidebarAnimationHost } from "./sidebar-animation-host.ts" +import { createSidebarOrchestrator, type SidebarOrchestrator } from "./sidebar-orchestrator.ts" +import { buildSidebarWebviewHtml } from "./sidebar-render.ts" +import { parseWebviewInboundMessage } from "./webview-messages.ts" + +export type DigitalPetSidebarProviderOptions = { + readonly notification?: NotificationPort + readonly scheduler?: IntervalScheduler + readonly random?: () => number + readonly nowMs?: () => number +} + +const createDeferredAnimationSink = (): { readonly sink: AnimationSink; setSink(next: AnimationSink): void } => { + let current: AnimationSink = { + postArtwork: async () => {}, + postModel: async () => {}, + } + return { + sink: { + postArtwork: async (artwork) => current.postArtwork(artwork), + postModel: async (payload) => current.postModel(payload), + }, + setSink(next: AnimationSink): void { + current = next + }, + } +} + +export class DigitalPetSidebarProvider implements vscode.WebviewViewProvider { + public static readonly viewType = "cursorDigitalPet.sidebar" + + private view?: vscode.WebviewView + private readonly notification: NotificationPort + private readonly scheduler: IntervalScheduler + private readonly animationHost: SidebarAnimationHost + private readonly orchestrator: SidebarOrchestrator + private readonly deferredSink: ReturnType + + constructor( + private readonly extensionUri: vscode.Uri, + snapshotReader: SidebarSnapshotReader, + battleRepository: EvolutionBattleRepository, + options: DigitalPetSidebarProviderOptions = {}, + ) { + this.notification = options.notification ?? createVsCodeNotificationPort() + this.scheduler = options.scheduler ?? createIntervalScheduler() + const random = options.random ?? Math.random + const nowMs = options.nowMs ?? (() => performance.now()) + this.deferredSink = createDeferredAnimationSink() + + this.animationHost = createSidebarAnimationHost({ + frameCatalog: MONSTER_FRAME_CATALOG, + sink: this.deferredSink.sink, + scheduler: this.scheduler, + random, + nowMs, + isVisible: () => this.view?.visible === true, + }) + + this.orchestrator = createSidebarOrchestrator({ + snapshotReader, + battleRepository, + animationHost: this.animationHost, + notification: this.notification, + random, + onPresentationEnd: () => { + if (this.view?.visible === true) this.animationHost.start() + }, + }) + this.orchestrator.setSink(this.deferredSink.sink) + } + + isPresentationInProgress(): boolean { + return this.orchestrator.isPresentationInProgress() + } + + queueEvolutionReveal(evolution: Parameters[0]): void { + this.orchestrator.queueEvolutionReveal(evolution) + } + + playFeedAnimation(): void { + if (this.orchestrator.isPresentationInProgress()) return + this.animationHost.playFeedAnimation() + } + + async refresh(): Promise { + await this.orchestrator.refresh() + } + + resolveWebviewView(webviewView: vscode.WebviewView): void { + this.view = webviewView + const sink = createAnimationSink(createWebviewMessenger(webviewView.webview)) + this.deferredSink.setSink(sink) + this.orchestrator.setSink(sink) + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this.extensionUri], + } + + webviewView.webview.html = buildSidebarWebviewHtml(String(Date.now())) + + webviewView.webview.onDidReceiveMessage((message: unknown) => { + const inbound = parseWebviewInboundMessage(message) + if (inbound === null) return + if (inbound.type === "open-url") { + void vscode.env.openExternal(vscode.Uri.parse(inbound.url)) + return + } + this.animationHost.setArtworkWidth(inbound.width) + void this.animationHost.postCurrentFrame() + }) + + webviewView.onDidChangeVisibility(() => { + if (webviewView.visible) { + this.animationHost.start() + void this.refresh() + } else { + this.animationHost.stop() + } + }) + + const cachedPayload = this.orchestrator.getCachedPayload() + if (cachedPayload !== undefined) { + void sink.postModel(cachedPayload) + } + + if (webviewView.visible) { + this.animationHost.start() + void this.refresh() + } + } + + dispose(): void { + this.animationHost.stop() + delete this.view + } +} diff --git a/packages/cursor-digital-pet/src/webview/sidebar/sidebar-animation-host.ts b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-animation-host.ts new file mode 100644 index 0000000..4dbe0dc --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-animation-host.ts @@ -0,0 +1,98 @@ +import type { MonsterFrameCatalog } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" +import { + MonsterAnimationController, + type MonsterAnimationIdentity, + type MonsterAnimationOutput, +} from "@jcendal/digital-pet-animation/idle/monster-animation.ts" +import { renderPositionedArtwork } from "@jcendal/digital-pet-animation/render/positioned-artwork.ts" + +import type { AnimationSink } from "../../adapters/vscode/animation-sink.ts" +import type { IntervalScheduler } from "../../adapters/vscode/scheduler.ts" +import { SIDEBAR_VISUAL_INTERVAL_MS } from "../../shared/constants/presentation-timing.ts" +import { DEFAULT_ARTWORK_WIDTH, MIN_ARTWORK_WIDTH } from "../../shared/constants/sidebar-ui.ts" + +export type SidebarAnimationHost = { + setArtworkWidth(width: number): void + syncPartner(partner: MonsterAnimationIdentity | undefined): void + start(): void + stop(): void + clearArtwork(): void + postCurrentFrame(animation?: MonsterAnimationOutput): Promise + playFeedAnimation(): void + isPresentationBlocked(): boolean + setPresentationBlocked(blocked: boolean): void + getArtworkWidth(): number +} + +export type CreateSidebarAnimationHostOptions = { + readonly frameCatalog: MonsterFrameCatalog + readonly sink: AnimationSink + readonly scheduler: IntervalScheduler + readonly random?: () => number + readonly nowMs?: () => number + readonly isVisible?: () => boolean +} + +export const createSidebarAnimationHost = ({ + frameCatalog, + sink, + scheduler, + random = Math.random, + nowMs = () => performance.now(), + isVisible = () => true, +}: CreateSidebarAnimationHostOptions): SidebarAnimationHost => { + const animation = new MonsterAnimationController(frameCatalog, random, nowMs) + let artworkWidth = DEFAULT_ARTWORK_WIDTH + let cachedArtwork = "" + let presentationBlocked = false + let stopInterval: (() => void) | undefined + + const postCurrentFrame = async (nextAnimation?: MonsterAnimationOutput): Promise => { + if (presentationBlocked) return + const output = nextAnimation ?? animation.output() + const artwork = renderPositionedArtwork(output, artworkWidth) + if (artwork === cachedArtwork) return + cachedArtwork = artwork + await sink.postArtwork(artwork) + } + + return { + setArtworkWidth(width: number): void { + artworkWidth = Math.max(MIN_ARTWORK_WIDTH, Math.floor(width)) + animation.dispatch({ kind: "viewport_resized", width: artworkWidth }) + }, + syncPartner(partner: MonsterAnimationIdentity | undefined): void { + animation.dispatch({ kind: "partner_changed", partner }) + }, + start(): void { + if (stopInterval !== undefined) return + stopInterval = scheduler.start(() => { + if (!isVisible() || presentationBlocked) return + const nextAnimation = animation.dispatch({ kind: "tick" }) + void postCurrentFrame(nextAnimation) + }, SIDEBAR_VISUAL_INTERVAL_MS) + }, + stop(): void { + stopInterval?.() + stopInterval = undefined + }, + clearArtwork(): void { + cachedArtwork = "" + }, + postCurrentFrame, + playFeedAnimation(): void { + if (presentationBlocked) return + const nextAnimation = animation.dispatch({ kind: "feed" }) + void postCurrentFrame(nextAnimation) + }, + isPresentationBlocked(): boolean { + return presentationBlocked + }, + setPresentationBlocked(blocked: boolean): void { + presentationBlocked = blocked + }, + getArtworkWidth(): number { + return artworkWidth + }, + } +} diff --git a/packages/cursor-digital-pet/src/webview/sidebar/sidebar-orchestrator.ts b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-orchestrator.ts new file mode 100644 index 0000000..91a5777 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-orchestrator.ts @@ -0,0 +1,142 @@ +import type { UsageEvolutionTransition } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { SidebarSnapshotReader } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import type { EvolutionBattleRepository } from "@jcendal/digital-pet-core/application/use-cases/resolve-evolution-battle.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" +import { runEvolutionBattleSession } from "@jcendal/digital-pet-animation/sessions/evolution-battle-session.ts" +import { runEvolutionRevealSession } from "@jcendal/digital-pet-animation/sessions/evolution-reveal-session.ts" + +import type { AnimationSink } from "../../adapters/vscode/animation-sink.ts" +import type { NotificationPort } from "../../adapters/vscode/notification-port.ts" +import { createAsyncRefreshQueue } from "../../shared/async-refresh-queue.ts" +import type { SidebarAnimationHost } from "./sidebar-animation-host.ts" +import { buildSidebarPresentation } from "./sidebar-presenter.ts" +import type { SidebarWebviewPayload } from "./webview-messages.ts" + +export type SidebarOrchestrator = { + refresh(): Promise + queueEvolutionReveal(evolution: UsageEvolutionTransition): void + isPresentationInProgress(): boolean + getCachedPayload(): SidebarWebviewPayload | undefined + setSink(sink: AnimationSink): void +} + +export type CreateSidebarOrchestratorOptions = { + readonly snapshotReader: SidebarSnapshotReader + readonly battleRepository: EvolutionBattleRepository + readonly animationHost: SidebarAnimationHost + readonly notification: NotificationPort + readonly random?: () => number + readonly onPresentationEnd?: () => void +} + +export const createSidebarOrchestrator = ({ + snapshotReader, + battleRepository, + animationHost, + notification, + random = Math.random, + onPresentationEnd, +}: CreateSidebarOrchestratorOptions): SidebarOrchestrator => { + const refreshQueue = createAsyncRefreshQueue() + let sink: AnimationSink | undefined + let cachedPayload: SidebarWebviewPayload | undefined + let pendingEvolutionReveal: UsageEvolutionTransition | undefined + let presentationInProgress = false + + const publishSidebarModel = async (): Promise => { + if (sink === undefined) return + const presentation = buildSidebarPresentation(snapshotReader.getSidebarSnapshot()) + cachedPayload = presentation.payload + await sink.postModel(presentation.payload) + if (presentationInProgress) return + animationHost.syncPartner(presentation.partner) + await animationHost.postCurrentFrame() + } + + const resolvePendingBattleIfNeeded = async (): Promise => { + if (presentationInProgress) return + const snapshot = snapshotReader.getSidebarSnapshot() + if (snapshot === null) return + if (snapshot.pendingEvolutionTargetId === null || snapshot.battleOpponentNodeId === null) return + + presentationInProgress = true + animationHost.setPresentationBlocked(true) + animationHost.stop() + animationHost.clearArtwork() + + try { + await runEvolutionBattleSession(snapshot, animationHost.getArtworkWidth(), { + frameCatalog: MONSTER_FRAME_CATALOG, + digimonCatalog: DIGIMON_CATALOG, + repository: battleRepository, + random, + onArtwork: async (artwork) => { + if (sink !== undefined) await sink.postArtwork(artwork) + }, + onResolved: async (result) => { + if (result.kind === "won") { + pendingEvolutionReveal = undefined + notification.showInformation("Victory! Your partner evolved!") + } else if (result.kind === "lost") { + notification.showInformation("Defeat! You lost all tokens for this stage.") + } + }, + }) + } finally { + presentationInProgress = false + animationHost.setPresentationBlocked(false) + onPresentationEnd?.() + } + } + + const playPendingEvolutionReveal = async (): Promise => { + const evolution = pendingEvolutionReveal + if (evolution === undefined || presentationInProgress) return + + pendingEvolutionReveal = undefined + presentationInProgress = true + animationHost.setPresentationBlocked(true) + animationHost.stop() + animationHost.clearArtwork() + + try { + const revealed = await runEvolutionRevealSession(evolution, animationHost.getArtworkWidth(), { + frameCatalog: MONSTER_FRAME_CATALOG, + digimonCatalog: DIGIMON_CATALOG, + onArtwork: async (artwork) => { + if (sink !== undefined) await sink.postArtwork(artwork) + }, + }) + if (revealed) { + notification.showInformation("Your partner evolved!") + } + } finally { + presentationInProgress = false + animationHost.setPresentationBlocked(false) + onPresentationEnd?.() + } + } + + return { + async refresh(): Promise { + await refreshQueue.run(async () => { + await resolvePendingBattleIfNeeded() + await playPendingEvolutionReveal() + await publishSidebarModel() + }) + }, + queueEvolutionReveal(evolution: UsageEvolutionTransition): void { + pendingEvolutionReveal = evolution + }, + isPresentationInProgress(): boolean { + return presentationInProgress + }, + getCachedPayload(): SidebarWebviewPayload | undefined { + return cachedPayload + }, + setSink(nextSink: AnimationSink): void { + sink = nextSink + }, + } +} diff --git a/packages/cursor-digital-pet/src/webview/sidebar/sidebar-presenter.ts b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-presenter.ts new file mode 100644 index 0000000..aa58b1f --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-presenter.ts @@ -0,0 +1,22 @@ +import type { SidebarSnapshot } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import { getSidebarCardInputs } from "@jcendal/digital-pet-core/application/use-cases/get-sidebar-card-inputs.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { buildSidebarCardModel } from "@jcendal/digital-pet-core/view-models/sidebar-view-model.ts" + +import type { MonsterAnimationIdentity } from "@jcendal/digital-pet-animation/idle/monster-animation.ts" +import { toSidebarWebviewPayload, type SidebarWebviewPayload } from "./sidebar-render.ts" + +export type SidebarPresentation = { + readonly payload: SidebarWebviewPayload + readonly partner: MonsterAnimationIdentity | undefined +} + +export const buildSidebarPresentation = (snapshot: SidebarSnapshot | null): SidebarPresentation => { + const reader = { getSidebarSnapshot: () => snapshot } + const inputs = getSidebarCardInputs(reader, DIGIMON_CATALOG) + const model = buildSidebarCardModel(inputs, DEFAULT_DIGITAL_PET_SETTINGS) + const payload = toSidebarWebviewPayload(model) + const partner = model.kind === "partner" ? { sprite: model.sprite, isDigitama: model.stageNumber === 0 } : undefined + return { payload, partner } +} diff --git a/packages/cursor-digital-pet/src/webview/sidebar/sidebar-render.ts b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-render.ts new file mode 100644 index 0000000..3e5ab12 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-render.ts @@ -0,0 +1,199 @@ +import type { SidebarCardModel } from "@jcendal/digital-pet-core/view-models/sidebar-view-model.ts" + +import { + DEFAULT_ARTWORK_WIDTH, + MIN_ARTWORK_WIDTH, + NEXT_CHECK_BAR_WIDTH, + NEXT_CHECK_PREFIX, + SIDEBAR_URL_LABEL, +} from "../../shared/constants/sidebar-ui.ts" +import type { SidebarWebviewPayload } from "./webview-messages.ts" + +export type { AnimationFramePayload, SidebarWebviewPayload } from "./webview-messages.ts" + +export const pixelWidthToArtworkColumns = (pixelWidth: number, charWidthPx: number): number => { + if (!Number.isFinite(pixelWidth) || pixelWidth <= 0) return DEFAULT_ARTWORK_WIDTH + if (!Number.isFinite(charWidthPx) || charWidthPx <= 0) return DEFAULT_ARTWORK_WIDTH + return Math.max(MIN_ARTWORK_WIDTH, Math.floor(pixelWidth / charWidthPx)) +} + +const formatCount = (value: number): string => value.toLocaleString("en-US") + +export const buildNextCheckLine = (model: SidebarCardModel): string => { + if (model.kind === "no_partner") return "" + if (model.isTerminal && !model.isSetOverride) return `${NEXT_CHECK_PREFIX}None` + if (model.evolutionBattlePending) return `${NEXT_CHECK_PREFIX}Evolution battle!` + + const progress = model.isTerminal ? 1 : Math.min(Math.max(model.gauge / model.threshold, 0), 1) + const filled = Math.floor(progress * NEXT_CHECK_BAR_WIDTH) + return `${NEXT_CHECK_PREFIX}[${"█".repeat(filled)}${"░".repeat(NEXT_CHECK_BAR_WIDTH - filled)}]` +} + +export const buildGaugeLine = (model: SidebarCardModel): string => { + if (model.kind !== "partner") return "" + return model.isTerminal ? "-/-" : `${formatCount(model.gauge)}/${formatCount(model.threshold)}` +} + +export const toSidebarWebviewPayload = (model: SidebarCardModel): SidebarWebviewPayload => { + if (model.kind === "no_partner") { + return { type: "sidebar-model", kind: "no_partner", messageLine: model.messageLine } + } + + return { + type: "sidebar-model", + kind: "partner", + name: model.isSetOverride ? `${model.name} (set)` : model.name, + stage: model.frozen ? `${model.stage} (frozen)` : model.stage, + nextCheck: buildNextCheckLine(model), + gauge: buildGaugeLine(model), + url: model.url, + urlLabel: SIDEBAR_URL_LABEL, + frozen: model.frozen, + } +} + +export const buildSidebarWebviewHtml = (nonce: string): string => ` + + + + + + + +
Loading Digital Pet…
+ + +` diff --git a/packages/cursor-digital-pet/src/webview/sidebar/webview-messages.ts b/packages/cursor-digital-pet/src/webview/sidebar/webview-messages.ts new file mode 100644 index 0000000..9f63900 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/sidebar/webview-messages.ts @@ -0,0 +1,36 @@ +export type SidebarWebviewPayload = + | { readonly type: "sidebar-model"; readonly kind: "no_partner"; readonly messageLine: string } + | { + readonly type: "sidebar-model" + readonly kind: "partner" + readonly name: string + readonly stage: string + readonly nextCheck: string + readonly gauge: string + readonly url: string + readonly urlLabel: string + readonly frozen: boolean + } + +export type AnimationFramePayload = { + readonly type: "animation-frame" + readonly artwork: string +} + +export type ExtensionToWebviewMessage = SidebarWebviewPayload | AnimationFramePayload + +export type WebviewToExtensionMessage = + | { readonly type: "open-url"; readonly url: string } + | { readonly type: "artwork-width"; readonly width: number } + +export const parseWebviewInboundMessage = (raw: unknown): WebviewToExtensionMessage | null => { + if (raw === null || typeof raw !== "object") return null + const message = raw as { readonly type?: unknown; readonly url?: unknown; readonly width?: unknown } + if (message.type === "open-url" && typeof message.url === "string" && message.url.length > 0) { + return { type: "open-url", url: message.url } + } + if (message.type === "artwork-width" && typeof message.width === "number" && Number.isFinite(message.width)) { + return { type: "artwork-width", width: message.width } + } + return null +} diff --git a/packages/cursor-digital-pet/tests/architecture-boundary-helpers.ts b/packages/cursor-digital-pet/tests/architecture-boundary-helpers.ts new file mode 100644 index 0000000..debebcb --- /dev/null +++ b/packages/cursor-digital-pet/tests/architecture-boundary-helpers.ts @@ -0,0 +1,73 @@ +import { readdir, readFile } from "node:fs/promises" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +export const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..") + +export const FORBIDDEN_PRESENTATION_IMPORTS = [ + "vscode", + "/adapters/", + "/bootstrap/", + "node:fs", + "node:path", + "sql.js", + "/webview/sidebar/", +] as const + +export const FORBIDDEN_SHARED_IMPORTS = ["vscode", "/adapters/", "/webview/", "/bootstrap/"] as const + +export const FORBIDDEN_SQLITE_ADAPTER_IMPORTS = ["vscode"] as const + +export const FORBIDDEN_VSCODE_ADAPTER_IMPORTS = ["/adapters/cursor/", "/adapters/sqlite/", "/bootstrap/"] as const + +export const FORBIDDEN_CURSOR_ADAPTER_IMPORTS = ["vscode", "/webview/"] as const + +export const FORBIDDEN_SIDEBAR_IMPORTS = ["vscode"] as const + +export const SIDEBAR_VSCODE_EXCEPTION = "webview/sidebar/provider.ts" + +const matchesForbiddenImport = (specifier: string, forbidden: string): boolean => { + if (forbidden.startsWith("/")) return specifier.includes(forbidden) + return specifier === forbidden +} + +export const findForbiddenImports = ( + sourcePath: string, + source: string, + forbiddenImports: readonly string[], +): readonly string[] => { + const importSpecifiers = source.matchAll(/\bfrom\s+["']([^"']+)["']|\bimport\s+["']([^"']+)["']/g) + const violations: string[] = [] + for (const match of importSpecifiers) { + const specifier = match[1] ?? match[2] + if (specifier !== undefined && forbiddenImports.some((forbidden) => matchesForbiddenImport(specifier, forbidden))) + violations.push(`${sourcePath}: ${specifier}`) + } + return violations +} + +export const scanForbiddenImports = async ( + directory: string, + forbiddenImports: readonly string[], + ignorePaths: readonly string[] = [], +): Promise => { + const entries = await readdir(directory, { withFileTypes: true }) + const violations: string[] = [] + for (const entry of entries) { + const entryPath = join(directory, entry.name) + if (ignorePaths.some((ignored) => entryPath.endsWith(ignored))) continue + if (entry.isDirectory()) violations.push(...(await scanForbiddenImports(entryPath, forbiddenImports, ignorePaths))) + else if (entry.isFile() && entry.name.endsWith(".ts")) + violations.push(...findForbiddenImports(entryPath, await readFile(entryPath, "utf8"), forbiddenImports)) + } + return violations.sort() +} + +export const scanPresentationImports = (directory: string): Promise => + scanForbiddenImports(directory, FORBIDDEN_PRESENTATION_IMPORTS) + +export const scanSharedImports = (directory: string): Promise => + scanForbiddenImports(directory, FORBIDDEN_SHARED_IMPORTS) + +export const scanSidebarImports = (directory: string): Promise => + scanForbiddenImports(directory, FORBIDDEN_SIDEBAR_IMPORTS, [SIDEBAR_VSCODE_EXCEPTION]) diff --git a/packages/cursor-digital-pet/tests/architecture-boundary.test.ts b/packages/cursor-digital-pet/tests/architecture-boundary.test.ts new file mode 100644 index 0000000..0140e1a --- /dev/null +++ b/packages/cursor-digital-pet/tests/architecture-boundary.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { + FORBIDDEN_PRESENTATION_IMPORTS, + FORBIDDEN_SHARED_IMPORTS, + findForbiddenImports, + scanForbiddenImports, + scanPresentationImports, + scanSharedImports, +} from "./architecture-boundary-helpers.ts" + +describe("cursor-digital-pet architecture boundary fixtures", () => { + test("Given presentation source importing vscode When scanned Then it is rejected", async () => { + const directory = await mkdtemp(join(tmpdir(), "cursor-digital-pet-presentation-")) + const path = join(directory, "forbidden.ts") + try { + await writeFile(path, 'import * as vscode from "vscode"\n') + expect(await scanPresentationImports(directory)).toEqual([`${path}: vscode`]) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("Given presentation source importing sqlite adapters When scanned Then it is rejected", async () => { + const directory = await mkdtemp(join(tmpdir(), "cursor-digital-pet-presentation-")) + const path = join(directory, "forbidden.ts") + try { + await writeFile(path, 'import { openReadonlySqliteDatabase } from "../../adapters/sqlite/sqlite-driver.ts"\n') + expect(await scanPresentationImports(directory)).toEqual([`${path}: ../../adapters/sqlite/sqlite-driver.ts`]) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("Given shared source importing vscode When scanned Then it is rejected", async () => { + const directory = await mkdtemp(join(tmpdir(), "cursor-digital-pet-shared-")) + const path = join(directory, "forbidden.ts") + try { + await writeFile(path, 'import * as vscode from "vscode"\n') + expect(await scanSharedImports(directory)).toEqual([`${path}: vscode`]) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("Given malformed source When scanned Then valid imports are still found", () => + expect( + findForbiddenImports("malformed.ts", 'import { broken\nimport "vscode"\n', FORBIDDEN_SHARED_IMPORTS), + ).toEqual(["malformed.ts: vscode"])) + + test("Given sqlite adapter fixtures importing vscode When scanned Then violations are reported", async () => { + const directory = await mkdtemp(join(tmpdir(), "cursor-digital-pet-sqlite-")) + const path = join(directory, "forbidden.ts") + try { + await writeFile(path, 'import * as vscode from "vscode"\n') + expect(await scanForbiddenImports(directory, ["vscode"])).toEqual([`${path}: vscode`]) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("Given presentation fixtures importing sidebar modules When scanned Then violations are reported", async () => { + const directory = await mkdtemp(join(tmpdir(), "cursor-digital-pet-presentation-sidebar-")) + const path = join(directory, "forbidden.ts") + try { + await writeFile(path, 'import { buildSidebarPresentation } from "../webview/sidebar/sidebar-presenter.ts"\n') + expect(await scanForbiddenImports(directory, FORBIDDEN_PRESENTATION_IMPORTS)).toEqual([ + `${path}: ../webview/sidebar/sidebar-presenter.ts`, + ]) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/cursor-digital-pet/tests/catalog-evolution.test.ts b/packages/cursor-digital-pet/tests/catalog-evolution.test.ts new file mode 100644 index 0000000..170e3eb --- /dev/null +++ b/packages/cursor-digital-pet/tests/catalog-evolution.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test" + +import type { DigimonNode } from "@jcendal/digital-pet-core/data/catalog.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { + applyTokenProgress, + resolveEvolutionBattle, + STAGE_GAUGE_THRESHOLDS, + type StageThresholds, +} from "@jcendal/digital-pet-core/domain/evolution.ts" + +const noBattle = { + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, +} as const + +const controlledCurrent: DigimonNode = { + id: "controlled-current", + nameEn: "Controlled Current", + nameJp: "Controlled Current", + nextEvolutions: ["controlled-target"], + sprite: "controlled-current.png", + stage: 0, + url: "https://example.test/controlled-current", +} + +const controlledTarget: DigimonNode = { + id: "controlled-target", + nameEn: "Controlled Target", + nameJp: "Controlled Target", + nextEvolutions: [], + sprite: "controlled-target.png", + stage: 1, + url: "https://example.test/controlled-target", +} + +const controlledCatalogNodes = [controlledCurrent, controlledTarget] +const controlledLookup = new Map(controlledCatalogNodes.map((node) => [node.id, node])) + +describe("catalog and evolution", () => { + test("Given a Digitama threshold crossing When token progress applies Then it evolves immediately without a battle", () => { + const evolved = applyTokenProgress( + { current: controlledCurrent, gauge: 4_999_999, isTerminal: false, ...noBattle }, + 1, + () => 0, + controlledLookup, + controlledCatalogNodes, + STAGE_GAUGE_THRESHOLDS, + ) + + expect(evolved).toEqual({ + current: controlledTarget, + gauge: 0, + isTerminal: true, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }) + }) + + test("Given progress below threshold When token progress applies Then it only accumulates gauge", () => { + const thresholds: StageThresholds = Object.freeze({ + ...STAGE_GAUGE_THRESHOLDS, + 0: 2, + }) + + expect( + applyTokenProgress( + { current: controlledCurrent, gauge: 0, isTerminal: false, ...noBattle }, + 1, + () => { + throw new Error("selector must not run") + }, + controlledLookup, + controlledCatalogNodes, + thresholds, + ), + ).toEqual({ current: controlledCurrent, gauge: 1, isTerminal: false, ...noBattle }) + }) + + test("Given a lost evolution battle When resolving Then gauge resets for the current stage", () => { + const andromon = DIGIMON_CATALOG.byId.get("5-003") + if (andromon === undefined) throw new Error("Expected Andromon in catalog") + + const pending = applyTokenProgress( + { current: andromon, gauge: 124_999_999, isTerminal: false, ...noBattle }, + 1, + () => 0.5, + DIGIMON_CATALOG.byId, + DIGIMON_CATALOG.nodes, + STAGE_GAUGE_THRESHOLDS, + ) + + expect(pending.pendingEvolutionTargetId).not.toBeNull() + expect(resolveEvolutionBattle(pending, false, DIGIMON_CATALOG.byId)).toEqual({ + current: andromon, + gauge: 0, + isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }) + }) +}) diff --git a/packages/cursor-digital-pet/tests/cursor-api-watermark.test.ts b/packages/cursor-digital-pet/tests/cursor-api-watermark.test.ts new file mode 100644 index 0000000..dc78dcf --- /dev/null +++ b/packages/cursor-digital-pet/tests/cursor-api-watermark.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" + +import { + fingerprintEvent, + hasValidTokens, + isNewEvent, + sumTokenDelta, +} from "../src/adapters/cursor/cursor-api-watermark.ts" +import type { UsageApiEvent, UsageWatermark } from "../src/adapters/cursor/types.ts" + +describe("cursor-api-watermark", () => { + const event: UsageApiEvent = { + timestamp: "1000", + model: "composer-2", + isTokenBasedCall: true, + tokenUsage: { + inputTokens: 10, + outputTokens: 5, + cacheWriteTokens: 2, + cacheReadTokens: 3, + }, + } + + test("sums token fields", () => { + expect(sumTokenDelta([event])).toBe(20) + }) + + test("filters new events by watermark", () => { + const watermark: UsageWatermark = { + timestampMs: 1000, + fingerprint: fingerprintEvent(event), + } + expect(isNewEvent(event, watermark)).toBe(false) + expect(isNewEvent({ ...event, timestamp: "1001" }, watermark)).toBe(true) + }) + + test("validates token based events", () => { + expect(hasValidTokens(event)).toBe(true) + expect(hasValidTokens({ timestamp: "1", model: "x" })).toBe(false) + }) +}) diff --git a/packages/cursor-digital-pet/tests/cursor-stop-mapper.test.ts b/packages/cursor-digital-pet/tests/cursor-stop-mapper.test.ts new file mode 100644 index 0000000..6366f58 --- /dev/null +++ b/packages/cursor-digital-pet/tests/cursor-stop-mapper.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" + +import { hasStopTokens, toCompletedUsageFromStop } from "../src/adapters/cursor/cursor-stop-mapper.ts" + +describe("cursor-stop-mapper", () => { + test("detects tokens in stop payload", () => { + expect(hasStopTokens({ input_tokens: 100, output_tokens: 50 })).toBe(true) + expect(hasStopTokens({})).toBe(false) + }) + + test("maps stop payload to CompletedUsage", () => { + const usage = toCompletedUsageFromStop( + { + generation_id: "gen-1", + input_tokens: 1000, + output_tokens: 200, + }, + "2026-09-09T00:00:00.000Z", + ) + + expect(usage).toEqual({ + receiptKey: "cursor-turn:gen-1", + eventId: "cursor-stop:gen-1", + tokenDelta: 1200, + cost: null, + createdAt: "2026-09-09T00:00:00.000Z", + }) + }) +}) diff --git a/packages/cursor-digital-pet/tests/cursor-usage-event-source.test.ts b/packages/cursor-digital-pet/tests/cursor-usage-event-source.test.ts new file mode 100644 index 0000000..26724de --- /dev/null +++ b/packages/cursor-digital-pet/tests/cursor-usage-event-source.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" +import { appendFile, mkdtemp, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" + +import type { UsageProcessingResult } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import type { Partner } from "@jcendal/digital-pet-core/domain/partner.ts" + +import type { HookEventRecord } from "../src/adapters/cursor/types.ts" + +mock.module("../src/adapters/cursor/cursor-api-watermark.ts", () => ({ + captureWatermark: async () => ({ timestampMs: 0, fingerprint: "test-watermark" }), + settleTurnDelta: async () => ({ + receiptKey: "cursor-api:gen-api", + eventId: "cursor-api:gen-api", + tokenDelta: 12, + cost: null, + createdAt: "2026-09-09T00:00:01.000Z", + }), +})) + +import { createCursorUsageEventSource } from "../src/adapters/cursor/cursor-usage-event-source.ts" + +const wait = async (milliseconds: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +const activePartner: Partner = { + partnerId: "partner-1", + generation: 1, + currentNodeId: "0-001", + gauge: 0, + isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + createdAt: "2026-09-09T00:00:00.000Z", + retiredAt: null, +} + +const createLedger = (): UsageLedger & { readonly receiptKeys: string[] } => { + const receiptKeys: string[] = [] + return { + receiptKeys, + applyUsageReceipt: (metadata, evolve) => { + receiptKeys.push(metadata.receiptKey) + evolve(activePartner) + return { kind: "applied" } + }, + } +} + +const appendHookEvent = async (eventsPath: string, record: HookEventRecord): Promise => { + await appendFile(eventsPath, `${JSON.stringify(record)}\n`, "utf8") +} + +const createHookEventsFixture = async (): Promise<{ tempRoot: string; eventsPath: string }> => { + const tempRoot = await mkdtemp(join(process.cwd(), ".tmp-cursor-digital-pet-hook-events-")) + const eventsPath = join(tempRoot, "events.jsonl") + await writeFile(eventsPath, "", "utf8") + return { tempRoot, eventsPath } +} + +describe("cursor usage event source", () => { + let tempRoot: string | undefined + let source: ReturnType | undefined + + afterEach(async () => { + source?.dispose() + source = undefined + if (tempRoot !== undefined) { + await rm(tempRoot, { recursive: true, force: true }) + tempRoot = undefined + } + }) + + test("Given a stop hook with token fields When the events file changes Then usage is applied once", async () => { + const fixture = await createHookEventsFixture() + tempRoot = fixture.tempRoot + const { eventsPath } = fixture + + const ledger = createLedger() + const applied: UsageProcessingResult[] = [] + source = createCursorUsageEventSource( + ledger, + (result) => { + applied.push(result) + }, + { eventsPath, settleDelayMs: 50 }, + ) + + await wait(50) + await appendHookEvent(eventsPath, { + receivedAt: "2026-09-09T00:00:00.000Z", + hookEventName: "stop", + payload: { + hook_event_name: "stop", + generation_id: "gen-stop", + input_tokens: 100, + output_tokens: 50, + }, + }) + + await wait(150) + expect(ledger.receiptKeys).toEqual(["cursor-turn:gen-stop"]) + expect(applied.some((result) => result.kind === "applied")).toBe(true) + }) + + test("Given duplicate stop receipts When events are replayed Then usage is applied only once", async () => { + const fixture = await createHookEventsFixture() + tempRoot = fixture.tempRoot + const { eventsPath } = fixture + + const ledger = createLedger() + source = createCursorUsageEventSource(ledger, undefined, { eventsPath, settleDelayMs: 50 }) + + const stopEvent: HookEventRecord = { + receivedAt: "2026-09-09T00:00:00.000Z", + hookEventName: "stop", + payload: { + hook_event_name: "stop", + generation_id: "gen-dup", + input_tokens: 10, + output_tokens: 5, + }, + } + + await wait(50) + await appendHookEvent(eventsPath, stopEvent) + await wait(150) + await appendHookEvent(eventsPath, stopEvent) + await wait(150) + + expect(ledger.receiptKeys).toEqual(["cursor-turn:gen-dup"]) + }) + + test("Given beforeSubmit and a tokenless stop When the events file changes Then usage falls back to the watermark settle path", async () => { + const fixture = await createHookEventsFixture() + tempRoot = fixture.tempRoot + const { eventsPath } = fixture + + const ledger = createLedger() + source = createCursorUsageEventSource(ledger, undefined, { eventsPath, settleDelayMs: 50 }) + + await wait(50) + await appendHookEvent(eventsPath, { + receivedAt: "2026-09-09T00:00:00.000Z", + hookEventName: "beforeSubmitPrompt", + payload: { + hook_event_name: "beforeSubmitPrompt", + conversation_id: "conv-1", + }, + }) + await wait(150) + await appendHookEvent(eventsPath, { + receivedAt: "2026-09-09T00:00:01.000Z", + hookEventName: "stop", + payload: { + hook_event_name: "stop", + conversation_id: "conv-1", + generation_id: "gen-api", + }, + }) + + await wait(250) + expect(ledger.receiptKeys).toEqual(["cursor-api:gen-api"]) + }) + + test("Given a disposed source When new hook events arrive Then usage is not applied", async () => { + const fixture = await createHookEventsFixture() + tempRoot = fixture.tempRoot + const { eventsPath } = fixture + + const ledger = createLedger() + source = createCursorUsageEventSource(ledger, undefined, { eventsPath, settleDelayMs: 50 }) + await wait(50) + source.dispose() + + await appendHookEvent(eventsPath, { + receivedAt: "2026-09-09T00:00:00.000Z", + hookEventName: "stop", + payload: { + hook_event_name: "stop", + generation_id: "gen-disposed", + input_tokens: 1, + output_tokens: 1, + }, + }) + + await wait(150) + expect(ledger.receiptKeys).toEqual([]) + }) +}) diff --git a/packages/cursor-digital-pet/tests/database-change-watcher.test.ts b/packages/cursor-digital-pet/tests/database-change-watcher.test.ts new file mode 100644 index 0000000..f6bffce --- /dev/null +++ b/packages/cursor-digital-pet/tests/database-change-watcher.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" + +import { createDatabaseChangeWatcher, type WatchDirectory } from "../src/adapters/sqlite/database-change-watcher.ts" + +const wait = async (milliseconds: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +const waitForChangeCount = async (count: () => number, expected: number): Promise => { + const deadline = Date.now() + 1_000 + while (count() < expected && Date.now() < deadline) await wait(10) + expect(count()).toBe(expected) +} + +const createFakeWatchDirectory = () => { + let listener: ((eventType: string, filename: string | null) => void) | undefined + let directory: string | undefined + let closed = false + + const watchDirectory: WatchDirectory = (path, onEvent) => { + directory = path + listener = onEvent + return { + close: () => { + closed = true + }, + } + } + + return { + watchDirectory, + watchedDirectory: () => directory, + isClosed: () => closed, + emit: (eventType: string, filename: string | null) => { + if (!closed) listener?.(eventType, filename) + }, + } +} + +describe("database change watcher", () => { + let tempRoot: string | undefined + let watcher: ReturnType | undefined + + afterEach(async () => { + watcher?.dispose() + watcher = undefined + if (tempRoot !== undefined) { + await rm(tempRoot, { recursive: true, force: true }) + tempRoot = undefined + } + }) + + const startWatcher = async (debounceMs: number) => { + tempRoot = await mkdtemp(join(tmpdir(), "cursor-digital-pet-watch-")) + const databasePath = join(tempRoot, "opencode-digital-pet", "pet.db") + const fake = createFakeWatchDirectory() + let changeCount = 0 + + watcher = createDatabaseChangeWatcher( + { + databasePath, + debounceMs, + onChange: () => { + changeCount += 1 + }, + }, + fake.watchDirectory, + ) + + return { fake, databasePath, count: () => changeCount } + } + + test("Given a pet.db event When watching the data directory Then each separate change fires once", async () => { + const { fake, databasePath, count } = await startWatcher(40) + expect(fake.watchedDirectory()).toBe(dirname(databasePath)) + + fake.emit("rename", "pet.db") + await waitForChangeCount(count, 1) + + fake.emit("change", "pet.db") + await waitForChangeCount(count, 2) + }) + + test("Given rapid pet.db events When watching Then onChange coalesces into one debounced callback", async () => { + const { fake, count } = await startWatcher(60) + + fake.emit("change", "pet.db") + fake.emit("change", "pet.db") + fake.emit("rename", "pet.db") + await waitForChangeCount(count, 1) + await wait(100) + + expect(count()).toBe(1) + }) + + test("Given another file in the data directory When it changes Then onChange is not called", async () => { + const { fake, count } = await startWatcher(40) + + fake.emit("change", "notes.txt") + await wait(100) + + expect(count()).toBe(0) + }) + + test("Given no filename in a watch event When it changes Then the database is refreshed", async () => { + const { fake, count } = await startWatcher(40) + + fake.emit("rename", null) + await waitForChangeCount(count, 1) + }) + + test("Given a SQLite WAL write When watching Then the shared database is refreshed", async () => { + const { fake, count } = await startWatcher(40) + + fake.emit("change", "pet.db-wal") + await waitForChangeCount(count, 1) + }) + + test("Given a disposed watcher When pet.db changes Then onChange is not called", async () => { + const { fake, count } = await startWatcher(40) + + watcher?.dispose() + watcher = undefined + fake.emit("change", "pet.db") + await wait(100) + + expect(fake.isClosed()).toBe(true) + expect(count()).toBe(0) + }) +}) diff --git a/packages/cursor-digital-pet/tests/dev/register-commands.test.ts b/packages/cursor-digital-pet/tests/dev/register-commands.test.ts new file mode 100644 index 0000000..ebae10b --- /dev/null +++ b/packages/cursor-digital-pet/tests/dev/register-commands.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from "bun:test" + +import { CURSOR_DEV_ACTIONS, DEV_SHOW_MENU_COMMAND, DEV_TOOLS_COMMAND_PALETTE_WHEN } from "../../src/dev/catalog.ts" + +describe("cursor-digital-pet dev register-commands", () => { + test("Given the dev catalog When inspected Then it exposes three dev tool actions and one palette command", () => { + expect(DEV_SHOW_MENU_COMMAND).toBe("cursorDigitalPet.dev.showMenu") + expect(DEV_TOOLS_COMMAND_PALETTE_WHEN).toBe("cursorDigitalPet.devToolsEnabled") + expect(CURSOR_DEV_ACTIONS.map((action) => action.id)).toEqual(["feed", "evolution_reveal", "evolution_battle"]) + }) +}) diff --git a/packages/cursor-digital-pet/tests/dev/setup-scenario.test.ts b/packages/cursor-digital-pet/tests/dev/setup-scenario.test.ts new file mode 100644 index 0000000..4e94693 --- /dev/null +++ b/packages/cursor-digital-pet/tests/dev/setup-scenario.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { join } from "node:path" + +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { recordUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import { resolveEvolutionBattleForPartner } from "@jcendal/digital-pet-core/application/use-cases/resolve-evolution-battle.ts" +import { spawnPartner } from "@jcendal/digital-pet-core/application/use-cases/spawn-partner.ts" +import type { StageThresholds } from "@jcendal/digital-pet-core/domain/evolution.ts" +import { DIGIMON_STAGES } from "@jcendal/digital-pet-core/domain/stage.ts" + +import { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" +import { setupBattlePending } from "../../src/dev/setup-scenario.ts" + +const DEV_INSTANT_BATTLE_THRESHOLDS: StageThresholds = Object.freeze( + Object.fromEntries(DIGIMON_STAGES.map((stage) => [stage, 1])) as StageThresholds, +) + +const applyDevUsage = ( + repository: Awaited>, + receiptKey: string, +): void => { + recordUsage({ + usage: { + receiptKey, + eventId: `dev:${receiptKey}`, + tokenDelta: 1, + cost: null, + createdAt: new Date().toISOString(), + }, + ledger: repository, + digimonById: DIGIMON_CATALOG.byId, + catalogNodes: DIGIMON_CATALOG.nodes, + selector: () => 0, + thresholds: DEV_INSTANT_BATTLE_THRESHOLDS, + }) +} + +const advancePartnerToStage = ( + repository: Awaited>, + targetStage: number, +): void => { + for (let step = 0; step < 12; step += 1) { + const partner = repository.getActivePartner() + const current = partner === null ? undefined : DIGIMON_CATALOG.byId.get(partner.currentNodeId) + if (current !== undefined && current.stage >= targetStage) return + + applyDevUsage(repository, `dev-progress-${step}`) + const pending = repository.getActivePartner() + if (pending?.pendingEvolutionTargetId !== null && pending?.battleOpponentNodeId !== null) { + resolveEvolutionBattleForPartner(repository, true, DIGIMON_CATALOG.byId, `dev-battle-${step}-${Date.now()}`) + } + } +} + +describe("cursor-digital-pet dev setup-scenario", () => { + test("Given a perfect-stage partner When battle setup runs Then a pending evolution battle opens", async () => { + const root = await mkdtemp(join(process.cwd(), ".tmp-cursor-digital-pet-dev-perfect-")) + try { + const repository = await createSqliteDigitalPetRepository({ appDataRoot: root }) + try { + spawnPartner(repository, new Date().toISOString()) + advancePartnerToStage(repository, 4) + + const current = DIGIMON_CATALOG.byId.get(repository.getActivePartner()?.currentNodeId ?? "") + expect(current?.stage).toBeGreaterThanOrEqual(4) + + setupBattlePending(repository) + const snapshot = repository.getSidebarSnapshot() + expect(snapshot?.pendingEvolutionTargetId).not.toBeNull() + expect(snapshot?.battleOpponentNodeId).not.toBeNull() + } finally { + repository.close() + } + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("Given an empty database When battle setup runs Then a pending evolution battle opens", async () => { + const root = await mkdtemp(join(process.cwd(), ".tmp-cursor-digital-pet-dev-")) + try { + const repository = await createSqliteDigitalPetRepository({ appDataRoot: root }) + try { + setupBattlePending(repository) + const snapshot = repository.getSidebarSnapshot() + expect(snapshot?.pendingEvolutionTargetId).not.toBeNull() + expect(snapshot?.battleOpponentNodeId).not.toBeNull() + } finally { + repository.close() + } + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/cursor-digital-pet/tests/escape-html.test.ts b/packages/cursor-digital-pet/tests/escape-html.test.ts new file mode 100644 index 0000000..cbfcd90 --- /dev/null +++ b/packages/cursor-digital-pet/tests/escape-html.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, test } from "bun:test" + +import { escapeHtml } from "../src/shared/escape-html.ts" + +describe("escapeHtml", () => { + test("escapes HTML special characters", () => { + expect(escapeHtml(``)).toBe("<script>"&"</script>") + }) +}) diff --git a/packages/cursor-digital-pet/tests/get-sidebar-card-inputs.test.ts b/packages/cursor-digital-pet/tests/get-sidebar-card-inputs.test.ts new file mode 100644 index 0000000..be134f4 --- /dev/null +++ b/packages/cursor-digital-pet/tests/get-sidebar-card-inputs.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" + +import type { DigimonCatalog, DigimonNode } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { SidebarSnapshotReader } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import { getSidebarCardInputs } from "@jcendal/digital-pet-core/application/use-cases/get-sidebar-card-inputs.ts" + +const agumon: DigimonNode = { + id: "3-001", + nameEn: "Agumon", + nameJp: "Agumon", + nextEvolutions: [], + sprite: "agumon", + stage: 3, + url: "https://example.test/agumon", +} + +const egg: DigimonNode = { + id: "0-001", + nameEn: "Digitama", + nameJp: "Digitama", + nextEvolutions: [], + sprite: "egg", + stage: 0, + url: "https://example.test/egg", +} + +const catalog: DigimonCatalog = { + nodes: [agumon, egg], + byId: new Map([ + [agumon.id, agumon], + [egg.id, egg], + ]), +} + +const snapshotFields = { + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, +} as const + +describe("get sidebar card inputs", () => { + test("Given a reader without a snapshot When querying sidebar card inputs Then it returns no partner", () => { + const reader: SidebarSnapshotReader = { + getSidebarSnapshot: () => null, + } + + expect(getSidebarCardInputs(reader, catalog)).toEqual({ kind: "no_partner" }) + }) + + test("Given a reader with a catalogued partner snapshot When querying sidebar card inputs Then it preserves partner card fields", () => { + const reader: SidebarSnapshotReader = { + getSidebarSnapshot: () => ({ + currentNodeId: agumon.id, + gauge: 25_000, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 40_000, + ...snapshotFields, + }), + } + + expect(getSidebarCardInputs(reader, catalog)).toEqual({ + kind: "partner", + node: agumon, + gauge: 25_000, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 40_000, + evolutionBattlePending: false, + }) + }) + + test("Given a pending evolution battle When querying sidebar card inputs Then evolutionBattlePending is true", () => { + const reader: SidebarSnapshotReader = { + getSidebarSnapshot: () => ({ + currentNodeId: agumon.id, + gauge: 25_000, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 40_000, + pendingEvolutionTargetId: "4-017", + battleOpponentNodeId: "3-051", + }), + } + + expect(getSidebarCardInputs(reader, catalog)).toEqual({ + kind: "partner", + node: agumon, + gauge: 25_000, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 40_000, + evolutionBattlePending: true, + }) + }) +}) diff --git a/packages/cursor-digital-pet/tests/hexagonal-boundary.test.ts b/packages/cursor-digital-pet/tests/hexagonal-boundary.test.ts new file mode 100644 index 0000000..e3f24bb --- /dev/null +++ b/packages/cursor-digital-pet/tests/hexagonal-boundary.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" +import { readFile } from "node:fs/promises" +import { join } from "node:path" + +import { + FORBIDDEN_CURSOR_ADAPTER_IMPORTS, + FORBIDDEN_SQLITE_ADAPTER_IMPORTS, + FORBIDDEN_VSCODE_ADAPTER_IMPORTS, + PROJECT_ROOT, + findForbiddenImports, + scanForbiddenImports, + scanSharedImports, + scanSidebarImports, +} from "./architecture-boundary-helpers.ts" + +describe("cursor-digital-pet hexagonal dependency direction", () => { + test("Given the shared source tree When scanned Then it has no forbidden dependencies", async () => + expect(await scanSharedImports(join(PROJECT_ROOT, "src", "shared"))).toEqual([])) + + test("Given the sqlite adapter tree When scanned Then it has no vscode dependency", async () => + expect( + await scanForbiddenImports(join(PROJECT_ROOT, "src", "adapters", "sqlite"), FORBIDDEN_SQLITE_ADAPTER_IMPORTS), + ).toEqual([])) + + test("Given the vscode adapter tree When scanned Then it points inward", async () => + expect( + await scanForbiddenImports(join(PROJECT_ROOT, "src", "adapters", "vscode"), FORBIDDEN_VSCODE_ADAPTER_IMPORTS), + ).toEqual([])) + + test("Given the cursor adapter tree When scanned Then it has no webview or vscode dependency", async () => + expect( + await scanForbiddenImports(join(PROJECT_ROOT, "src", "adapters", "cursor"), FORBIDDEN_CURSOR_ADAPTER_IMPORTS), + ).toEqual([])) + + test("Given the sidebar source tree When scanned Then only provider imports vscode", async () => + expect(await scanSidebarImports(join(PROJECT_ROOT, "src", "webview", "sidebar"))).toEqual([])) + + test("Given sidebar presenter When scanned Then it remains free of adapter imports", async () => { + const path = join(PROJECT_ROOT, "src", "webview", "sidebar", "sidebar-presenter.ts") + expect(findForbiddenImports(path, await readFile(path, "utf8"), ["/adapters/", "vscode"])).toEqual([]) + }) + + test("Given extension composition When scanned Then it wires the usage pipeline", async () => { + const path = join(PROJECT_ROOT, "src", "extension.ts") + const source = await readFile(path, "utf8") + expect(source.includes("application/usage-pipeline")).toBe(true) + }) +}) diff --git a/packages/cursor-digital-pet/tests/persistence/digital-pet-game-persistence.test.ts b/packages/cursor-digital-pet/tests/persistence/digital-pet-game-persistence.test.ts new file mode 100644 index 0000000..49c9ac3 --- /dev/null +++ b/packages/cursor-digital-pet/tests/persistence/digital-pet-game-persistence.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" + +import { freezeDigitalPet } from "@jcendal/digital-pet-core/application/use-cases/freeze-digital-pet.ts" +import { resolveEvolutionBattleForPartner } from "@jcendal/digital-pet-core/application/use-cases/resolve-evolution-battle.ts" +import { setDigitalPetCheatNode } from "@jcendal/digital-pet-core/application/use-cases/set-digital-pet-cheat-node.ts" +import { spawnPartner } from "@jcendal/digital-pet-core/application/use-cases/spawn-partner.ts" +import { unfreezeDigitalPet } from "@jcendal/digital-pet-core/application/use-cases/unfreeze-digital-pet.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { STAGE_GAUGE_THRESHOLDS } from "@jcendal/digital-pet-core/domain/evolution.ts" + +import { createSqliteSidebarSnapshotReader } from "../../src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts" +import { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" +import { applyTokenUsage, createTempTestRoot, removeTempTestRoot, type TempTestRoot } from "./persistence-fixtures.ts" + +let tempRoot: TempTestRoot | undefined + +beforeEach(async () => { + tempRoot = await createTempTestRoot() +}) + +afterEach(async () => { + if (tempRoot === undefined) return + await removeTempTestRoot(tempRoot) + tempRoot = undefined +}) + +describe("cursor-digital-pet game persistence", () => { + test("Given repeated spawns When reading the active partner Then generation increments", async () => { + if (tempRoot === undefined) throw new Error("Missing temp root.") + + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + try { + spawnPartner(repository, "2026-09-09T12:00:00.000Z") + spawnPartner(repository, "2026-09-09T12:01:00.000Z") + expect(repository.getActivePartner()?.generation).toBe(2) + } finally { + repository.close() + } + }) + + test("Given a Digitama and a low hatch threshold When usage is recorded Then the partner evolves immediately", async () => { + if (tempRoot === undefined) throw new Error("Missing temp root.") + + const hatchThresholds = Object.freeze({ ...STAGE_GAUGE_THRESHOLDS, 0: 1 }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const reader = await createSqliteSidebarSnapshotReader({ appDataRoot: tempRoot.appDataRoot }) + try { + spawnPartner(repository, "2026-09-09T12:00:00.000Z") + const outcome = applyTokenUsage(repository, "receipt-hatch", 1, { thresholds: hatchThresholds }) + expect(outcome.kind).toBe("applied") + if (outcome.kind !== "applied") throw new Error("expected applied usage outcome") + expect(outcome.evolution).toBeDefined() + expect(reader.getSidebarSnapshot()?.currentNodeId).not.toBe("0-001") + expect(reader.getSidebarSnapshot()?.pendingEvolutionTargetId).toBeNull() + } finally { + repository.close() + } + }) + + test("Given a threshold crossing on a higher stage When usage is recorded Then a pending evolution battle opens", async () => { + if (tempRoot === undefined) throw new Error("Missing temp root.") + + const battleThresholds = Object.freeze({ ...STAGE_GAUGE_THRESHOLDS, 0: 1, 1: 1 }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + try { + spawnPartner(repository, "2026-09-09T12:00:00.000Z") + applyTokenUsage(repository, "receipt-hatch", 1, { thresholds: battleThresholds }) + applyTokenUsage(repository, "receipt-threshold", 1, { thresholds: battleThresholds }) + + const pending = repository.getSidebarSnapshot() + expect(pending?.pendingEvolutionTargetId).not.toBeNull() + expect(pending?.battleOpponentNodeId).not.toBeNull() + } finally { + repository.close() + } + }) + + test("Given a lost evolution battle When it is resolved Then gauge resets for the current stage", async () => { + if (tempRoot === undefined) throw new Error("Missing temp root.") + + const battleThresholds = Object.freeze({ ...STAGE_GAUGE_THRESHOLDS, 0: 1, 1: 1 }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + try { + spawnPartner(repository, "2026-09-09T12:00:00.000Z") + applyTokenUsage(repository, "receipt-hatch", 1, { thresholds: battleThresholds }) + applyTokenUsage(repository, "receipt-threshold", 1, { thresholds: battleThresholds }) + const gaugeBeforeLoss = repository.getSidebarSnapshot()?.gauge + + resolveEvolutionBattleForPartner(repository, false, DIGIMON_CATALOG.byId, "2026-09-09T12:02:00.000Z") + + const resolved = repository.getSidebarSnapshot() + expect(resolved?.pendingEvolutionTargetId).toBeNull() + expect(resolved?.battleOpponentNodeId).toBeNull() + expect(gaugeBeforeLoss).toBeGreaterThan(0) + expect(resolved?.gauge).toBe(0) + } finally { + repository.close() + } + }) + + test("Given a duplicate usage receipt When it is recorded twice Then only the first application changes state", async () => { + if (tempRoot === undefined) throw new Error("Missing temp root.") + + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + try { + spawnPartner(repository, "2026-09-09T12:00:00.000Z") + const first = applyTokenUsage(repository, "receipt-dup", 5) + const second = applyTokenUsage(repository, "receipt-dup", 5) + + expect(first.kind).toBe("applied") + expect(second.kind).toBe("duplicate") + expect(repository.getSidebarSnapshot()?.gauge).toBe(5) + } finally { + repository.close() + } + }) + + test("Given a frozen partner When usage is recorded Then trainer tokens increase without evolving the partner", async () => { + if (tempRoot === undefined) throw new Error("Missing temp root.") + + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const reader = await createSqliteSidebarSnapshotReader({ appDataRoot: tempRoot.appDataRoot }) + try { + spawnPartner(repository, "2026-09-09T12:00:00.000Z") + applyTokenUsage(repository, "receipt-before-freeze", 3) + const gaugeBeforeFreeze = reader.getSidebarSnapshot()?.gauge + freezeDigitalPet(repository) + applyTokenUsage(repository, "receipt-while-frozen", 4) + + const snapshot = reader.getSidebarSnapshot() + expect(snapshot?.frozen).toBe(true) + expect(snapshot?.gauge).toBe(gaugeBeforeFreeze) + expect(snapshot?.trainerTotalTokens).toBe(7) + } finally { + repository.close() + } + }) + + test("Given a frozen partner When it is unfrozen Then later usage progresses the gauge again", async () => { + if (tempRoot === undefined) throw new Error("Missing temp root.") + + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const reader = await createSqliteSidebarSnapshotReader({ appDataRoot: tempRoot.appDataRoot }) + try { + spawnPartner(repository, "2026-09-09T12:00:00.000Z") + freezeDigitalPet(repository) + applyTokenUsage(repository, "receipt-frozen", 2) + unfreezeDigitalPet(repository) + applyTokenUsage(repository, "receipt-after-unfreeze", 3) + + const snapshot = reader.getSidebarSnapshot() + expect(snapshot?.frozen).toBe(false) + expect(snapshot?.gauge).toBe(3) + } finally { + repository.close() + } + }) + + test("Given a cheat node override When the sidebar snapshot is read Then it projects the set partner", async () => { + if (tempRoot === undefined) throw new Error("Missing temp root.") + + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const reader = await createSqliteSidebarSnapshotReader({ appDataRoot: tempRoot.appDataRoot }) + try { + spawnPartner(repository, "2026-09-09T12:00:00.000Z") + setDigitalPetCheatNode(repository, "3-001") + + const snapshot = reader.getSidebarSnapshot() + expect(snapshot?.currentNodeId).toBe("3-001") + expect(snapshot?.isSetOverride).toBe(true) + } finally { + repository.close() + } + }) +}) diff --git a/packages/cursor-digital-pet/tests/persistence/persistence-fixtures.ts b/packages/cursor-digital-pet/tests/persistence/persistence-fixtures.ts new file mode 100644 index 0000000..6ceb1fc --- /dev/null +++ b/packages/cursor-digital-pet/tests/persistence/persistence-fixtures.ts @@ -0,0 +1,51 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises" +import { join } from "node:path" + +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import { recordUsage, type CompletedUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { STAGE_GAUGE_THRESHOLDS, type StageThresholds } from "@jcendal/digital-pet-core/domain/evolution.ts" + +export type TempTestRoot = { readonly root: string; readonly appDataRoot: string } + +export const createTempTestRoot = async (): Promise => { + const root = await mkdtemp(join(process.cwd(), ".tmp-cursor-digital-pet-persistence-")) + const appDataRoot = join(root, "app-data") + await mkdir(appDataRoot, { recursive: true }) + return { root, appDataRoot } +} + +export const removeTempTestRoot = async (tempRoot: TempTestRoot): Promise => { + await rm(tempRoot.root, { recursive: true, force: true }) +} + +export const usageReceipt = ( + receiptKey: string, + tokenDelta = 1, + createdAt = "2026-09-09T12:01:00.000Z", +): CompletedUsage => ({ + receiptKey, + eventId: `event:${receiptKey}`, + tokenDelta, + cost: null, + createdAt, +}) + +export const applyTokenUsage = ( + ledger: UsageLedger, + receiptKey: string, + tokenDelta = 1, + options: { + readonly thresholds?: StageThresholds + readonly selector?: () => number + readonly createdAt?: string + } = {}, +) => + recordUsage({ + usage: usageReceipt(receiptKey, tokenDelta, options.createdAt), + ledger, + digimonById: DIGIMON_CATALOG.byId, + catalogNodes: DIGIMON_CATALOG.nodes, + selector: options.selector ?? (() => 0), + thresholds: options.thresholds ?? STAGE_GAUGE_THRESHOLDS, + }) diff --git a/packages/cursor-digital-pet/tests/persistence/shared-sqlite.persistence.test.ts b/packages/cursor-digital-pet/tests/persistence/shared-sqlite.persistence.test.ts new file mode 100644 index 0000000..e0b1fa6 --- /dev/null +++ b/packages/cursor-digital-pet/tests/persistence/shared-sqlite.persistence.test.ts @@ -0,0 +1,58 @@ +import { test, expect } from "bun:test" +import { spawnSync } from "node:child_process" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" + +import { build } from "esbuild" + +test("Cursor and OpenCode retain each other's writes in one SQLite database", async () => { + const root = await mkdtemp(join(tmpdir(), "digital-pet-shared-sqlite-")) + const driverPath = join(root, "cursor-sqlite-driver.cjs") + const scriptPath = join(root, "verify.cjs") + const databasePath = join(root, "pet.db") + + try { + await build({ + entryPoints: [resolve(import.meta.dir, "../../src/adapters/sqlite/sqlite-driver.ts")], + outfile: driverPath, + bundle: true, + platform: "node", + format: "cjs", + external: ["bun:sqlite"], + logLevel: "silent", + }) + await writeFile( + scriptPath, + ` +const { spawnSync } = require("node:child_process") +const { openReadonlySqliteDatabase, openWritableSqliteDatabase } = require(process.argv[2]) +const databasePath = process.argv[3] +const cursor = openWritableSqliteDatabase(databasePath) +cursor.executor.run("CREATE TABLE events (name TEXT)") +cursor.executor.run("INSERT INTO events VALUES (?)", ["cursor"]) +const opencode = spawnSync("bun", ["-e", 'import { Database } from "bun:sqlite"; const db = new Database(process.argv[1]); db.run("PRAGMA journal_mode=WAL"); db.run("INSERT INTO events VALUES (?)", ["opencode"]); db.close()', databasePath], { encoding: "utf8" }) +if (opencode.status !== 0) throw new Error(opencode.stderr) +const beforeClose = cursor.executor.all("SELECT name FROM events ORDER BY name") +const reader = openReadonlySqliteDatabase(databasePath) +const readonlyView = reader.executor.all("SELECT name FROM events ORDER BY name") +reader.close() +cursor.close() +const reopened = openWritableSqliteDatabase(databasePath) +const afterClose = reopened.executor.all("SELECT name FROM events ORDER BY name") +reopened.close() +process.stdout.write(JSON.stringify({ beforeClose, readonlyView, afterClose })) +`, + ) + + const result = spawnSync("node", ["--no-warnings", scriptPath, driverPath, databasePath], { encoding: "utf8" }) + if (result.status !== 0) throw new Error(result.stderr) + expect(JSON.parse(result.stdout)).toEqual({ + beforeClose: [{ name: "cursor" }, { name: "opencode" }], + readonlyView: [{ name: "cursor" }, { name: "opencode" }], + afterClose: [{ name: "cursor" }, { name: "opencode" }], + }) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/cursor-digital-pet/tests/persistence/sqlite-digital-pet-archive-reader.persistence.test.ts b/packages/cursor-digital-pet/tests/persistence/sqlite-digital-pet-archive-reader.persistence.test.ts new file mode 100644 index 0000000..9e7eaa6 --- /dev/null +++ b/packages/cursor-digital-pet/tests/persistence/sqlite-digital-pet-archive-reader.persistence.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { join } from "node:path" + +import { spawnPartner } from "@jcendal/digital-pet-core/application/use-cases/spawn-partner.ts" + +import { readArchive } from "../../src/adapters/sqlite/sqlite-digital-pet-archive-reader.ts" +import { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" +import { applyTokenUsage, createTempTestRoot, removeTempTestRoot, type TempTestRoot } from "./persistence-fixtures.ts" + +let tempRoot: TempTestRoot | undefined + +beforeEach(async () => { + tempRoot = await createTempTestRoot() +}) + +afterEach(async () => { + if (tempRoot === undefined) return + await removeTempTestRoot(tempRoot) + tempRoot = undefined +}) + +describe("SQLite digital-pet archive reader persistence", () => { + test("Given no database file When reading an archive Then it returns empty", async () => { + if (tempRoot === undefined) throw new Error("Missing temp root.") + + const databasePath = join(tempRoot.appDataRoot, "missing", "pet.db") + expect(await readArchive({ databasePath })).toEqual({ kind: "empty" }) + }) + + test("Given a spawned partner with usage events When reading an archive Then partners and events are available", async () => { + if (tempRoot === undefined) throw new Error("Missing temp root.") + + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + try { + spawnPartner(repository, "2026-09-09T12:00:00.000Z") + applyTokenUsage(repository, "receipt-archive", 9) + + const archive = await readArchive({ appDataRoot: tempRoot.appDataRoot }) + expect(archive.kind).toBe("available") + if (archive.kind !== "available") return + + expect(archive.partners).toHaveLength(1) + expect(archive.partners[0]?.generation).toBe(1) + expect(archive.partners[0]?.events.length).toBeGreaterThan(0) + expect(archive.partners[0]?.events.some((event) => event.currentNodeId === "0-001")).toBe(true) + } finally { + repository.close() + } + }) +}) diff --git a/packages/cursor-digital-pet/tests/persistence/sqlite-digital-pet-repository.persistence.test.ts b/packages/cursor-digital-pet/tests/persistence/sqlite-digital-pet-repository.persistence.test.ts new file mode 100644 index 0000000..4ac7f8d --- /dev/null +++ b/packages/cursor-digital-pet/tests/persistence/sqlite-digital-pet-repository.persistence.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, rm } from "node:fs/promises" +import { join } from "node:path" + +import { recordUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import { resolveEvolutionBattleForPartner } from "@jcendal/digital-pet-core/application/use-cases/resolve-evolution-battle.ts" +import { spawnPartner } from "@jcendal/digital-pet-core/application/use-cases/spawn-partner.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { STAGE_GAUGE_THRESHOLDS } from "@jcendal/digital-pet-core/domain/evolution.ts" + +import { + createSqliteSidebarSnapshotReader, + readSidebarSnapshot, +} from "../../src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts" +import { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" + +type TempTestRoot = { readonly root: string; readonly appDataRoot: string } + +let tempRoot: TempTestRoot | undefined + +const createTempTestRoot = async (): Promise => { + const root = await mkdtemp(join(process.cwd(), ".tmp-cursor-digital-pet-persistence-")) + const appDataRoot = join(root, "app-data") + await mkdir(appDataRoot, { recursive: true }) + return { root, appDataRoot } +} + +beforeEach(async () => { + tempRoot = await createTempTestRoot() +}) + +afterEach(async () => { + if (tempRoot === undefined) return + await rm(tempRoot.root, { recursive: true, force: true }) + tempRoot = undefined +}) + +describe("SQLite digital-pet repository persistence", () => { + test("Given usage applied through the write store When reading through the snapshot reader Then it returns fresh gauge", async () => { + if (tempRoot === undefined) throw new Error("Missing temp root.") + + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const reader = await createSqliteSidebarSnapshotReader({ appDataRoot: tempRoot.appDataRoot }) + try { + spawnPartner(repository, "2026-09-09T12:00:00.000Z") + expect(reader.getSidebarSnapshot()?.gauge).toBe(0) + + recordUsage({ + usage: { + receiptKey: "receipt-1", + eventId: "event-1", + tokenDelta: 42, + cost: null, + createdAt: "2026-09-09T12:01:00.000Z", + }, + ledger: repository, + digimonById: DIGIMON_CATALOG.byId, + catalogNodes: DIGIMON_CATALOG.nodes, + selector: () => 0, + thresholds: STAGE_GAUGE_THRESHOLDS, + }) + + expect(reader.getSidebarSnapshot()?.gauge).toBe(42) + } finally { + repository.close() + } + }) + + test("Given a stale in-memory repository When another instance spawns Then usage applies to the new partner", async () => { + if (tempRoot === undefined) throw new Error("Missing temp root.") + + const writer = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const staleReader = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const snapshotReader = await createSqliteSidebarSnapshotReader({ appDataRoot: tempRoot.appDataRoot }) + try { + spawnPartner(writer, "2026-09-09T12:00:00.000Z") + spawnPartner(writer, "2026-09-09T12:01:00.000Z") + + recordUsage({ + usage: { + receiptKey: "receipt-cross-window", + eventId: "event-cross-window", + tokenDelta: 7, + cost: null, + createdAt: "2026-09-09T12:02:00.000Z", + }, + ledger: staleReader, + digimonById: DIGIMON_CATALOG.byId, + catalogNodes: DIGIMON_CATALOG.nodes, + selector: () => 0, + thresholds: STAGE_GAUGE_THRESHOLDS, + }) + + expect(staleReader.getActivePartner()?.generation).toBe(2) + expect(snapshotReader.getSidebarSnapshot()?.gauge).toBe(7) + } finally { + writer.close() + staleReader.close() + } + }) + + test("Given a resolved evolution battle When reading through the repository snapshot Then pending battle fields are cleared", async () => { + if (tempRoot === undefined) throw new Error("Missing temp root.") + + const battleThresholds = Object.freeze({ ...STAGE_GAUGE_THRESHOLDS, 0: 1, 1: 1 }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + try { + spawnPartner(repository, "2026-09-09T12:00:00.000Z") + recordUsage({ + usage: { + receiptKey: "receipt-hatch", + eventId: "event-hatch", + tokenDelta: 1, + cost: null, + createdAt: "2026-09-09T12:01:00.000Z", + }, + ledger: repository, + digimonById: DIGIMON_CATALOG.byId, + catalogNodes: DIGIMON_CATALOG.nodes, + selector: () => 0, + thresholds: battleThresholds, + }) + recordUsage({ + usage: { + receiptKey: "receipt-threshold", + eventId: "event-threshold", + tokenDelta: 1, + cost: null, + createdAt: "2026-09-09T12:01:30.000Z", + }, + ledger: repository, + digimonById: DIGIMON_CATALOG.byId, + catalogNodes: DIGIMON_CATALOG.nodes, + selector: () => 0, + thresholds: battleThresholds, + }) + + const pending = repository.getSidebarSnapshot() + expect(pending?.pendingEvolutionTargetId).not.toBeNull() + expect(pending?.battleOpponentNodeId).not.toBeNull() + + resolveEvolutionBattleForPartner(repository, true, DIGIMON_CATALOG.byId, "2026-09-09T12:02:00.000Z") + + const resolved = repository.getSidebarSnapshot() + expect(resolved?.pendingEvolutionTargetId).toBeNull() + expect(resolved?.battleOpponentNodeId).toBeNull() + expect(resolved?.currentNodeId).not.toBe("0-001") + } finally { + repository.close() + } + }) + + test("Given a spawned partner When reading the sidebar snapshot Then it returns the active partner", async () => { + if (tempRoot === undefined) throw new Error("Missing temp root.") + + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + try { + spawnPartner(repository, "2026-09-09T12:00:00.000Z") + const snapshot = await readSidebarSnapshot({ appDataRoot: tempRoot.appDataRoot }) + expect(snapshot?.currentNodeId).toBe("0-001") + expect(snapshot?.frozen).toBe(false) + } finally { + repository.close() + } + }) +}) diff --git a/packages/cursor-digital-pet/tests/record-usage.test.ts b/packages/cursor-digital-pet/tests/record-usage.test.ts new file mode 100644 index 0000000..9304d4a --- /dev/null +++ b/packages/cursor-digital-pet/tests/record-usage.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "bun:test" + +import type { DigimonNode } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { Partner } from "@jcendal/digital-pet-core/domain/partner.ts" +import type { UsageReceiptMetadata } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import { recordUsage, type CompletedUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import { STAGE_GAUGE_THRESHOLDS } from "@jcendal/digital-pet-core/domain/evolution.ts" + +const currentNode: DigimonNode = { + id: "current", + nameEn: "Current", + nameJp: "Current", + nextEvolutions: ["target"], + sprite: "current.png", + stage: 0, + url: "https://example.test/current", +} + +const targetNode: DigimonNode = { + id: "target", + nameEn: "Target", + nameJp: "Target", + nextEvolutions: [], + sprite: "target.png", + stage: 1, + url: "https://example.test/target", +} + +const catalogNodes = [currentNode, targetNode] + +const activePartner: Partner = { + partnerId: "partner-1", + generation: 1, + currentNodeId: currentNode.id, + gauge: 4_999_999, + isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + createdAt: "2026-07-31T00:00:00.000Z", + retiredAt: null, +} + +const usage = (receiptKey: string, tokenDelta = 1): CompletedUsage => ({ + receiptKey, + eventId: `usage:${receiptKey}`, + tokenDelta, + cost: null, + createdAt: "2026-07-31T00:01:00.000Z", +}) + +type FakeLedger = UsageLedger & { + readonly receipts: UsageReceiptMetadata[] + readonly evolutions: Array<{ + currentNodeId: string + gauge: number + isTerminal: boolean + pendingEvolutionTargetId: string | null + battleOpponentNodeId: string | null + }> +} + +const createLedger = (outcome: "applied" | "duplicate" | "no_active_partner", partner = activePartner): FakeLedger => { + const receipts: UsageReceiptMetadata[] = [] + const evolutions: FakeLedger["evolutions"] = [] + + return { + receipts, + evolutions, + applyUsageReceipt(receipt, evolve) { + if (outcome !== "applied") return { kind: outcome } + const evolution = evolve(partner) + receipts.push(receipt) + evolutions.push(evolution) + return { kind: "applied" } + }, + } +} + +describe("record usage application use case", () => { + test("Given completed usage and an active Digitama When recording it Then the ledger evolves immediately", () => { + const ledger = createLedger("applied") + + const outcome = recordUsage({ + usage: usage("receipt-1"), + ledger, + digimonById: new Map([ + [currentNode.id, currentNode], + [targetNode.id, targetNode], + ]), + catalogNodes, + selector: () => 0, + thresholds: STAGE_GAUGE_THRESHOLDS, + }) + + expect(outcome).toEqual({ + kind: "applied", + receiptKey: "receipt-1", + evolution: { fromNodeId: currentNode.id, toNodeId: targetNode.id }, + }) + expect(ledger.evolutions).toEqual([ + { + currentNodeId: targetNode.id, + gauge: 0, + isTerminal: true, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }, + ]) + }) + + test("Given an applied receipt without a threshold crossing When recording it Then only the gauge progresses", () => { + const ledger = createLedger("applied", { ...activePartner, gauge: 0 }) + + const outcome = recordUsage({ + usage: usage("receipt-gauge-only"), + ledger, + digimonById: new Map([ + [currentNode.id, currentNode], + [targetNode.id, targetNode], + ]), + selector: () => { + throw new Error("selector must not run") + }, + catalogNodes, + thresholds: STAGE_GAUGE_THRESHOLDS, + }) + + expect(outcome).toEqual({ kind: "applied", receiptKey: "receipt-gauge-only" }) + expect(ledger.evolutions).toEqual([ + { + currentNodeId: currentNode.id, + gauge: 1, + isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }, + ]) + }) + + test("Given a duplicate receipt When recording it Then the use case is a no-op", () => { + const ledger = createLedger("duplicate") + + const outcome = recordUsage({ + usage: usage("receipt-duplicate"), + ledger, + digimonById: new Map(), + selector: () => { + throw new Error("selector must not run") + }, + catalogNodes, + thresholds: STAGE_GAUGE_THRESHOLDS, + }) + + expect(outcome).toEqual({ kind: "duplicate", receiptKey: "receipt-duplicate" }) + expect(ledger.receipts).toEqual([]) + }) + + test("Given no active partner When recording completed usage Then the ledger outcome is preserved", () => { + const ledger = createLedger("no_active_partner") + + const outcome = recordUsage({ + usage: usage("receipt-no-partner"), + ledger, + digimonById: new Map(), + selector: () => { + throw new Error("selector must not run") + }, + catalogNodes, + thresholds: STAGE_GAUGE_THRESHOLDS, + }) + + expect(outcome).toEqual({ kind: "no_active_partner", receiptKey: "receipt-no-partner" }) + }) +}) diff --git a/packages/cursor-digital-pet/tests/sidebar-presenter.test.ts b/packages/cursor-digital-pet/tests/sidebar-presenter.test.ts new file mode 100644 index 0000000..8e2b878 --- /dev/null +++ b/packages/cursor-digital-pet/tests/sidebar-presenter.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" + +import { buildSidebarPresentation } from "../src/webview/sidebar/sidebar-presenter.ts" +import { buildNextCheckLine } from "../src/webview/sidebar/sidebar-render.ts" + +describe("sidebar presenter", () => { + test("builds no_partner payload when snapshot is null", () => { + const presentation = buildSidebarPresentation(null) + expect(presentation.payload.kind).toBe("no_partner") + expect(presentation.partner).toBeUndefined() + }) + + test("builds partner payload and animation identity for an active partner", () => { + const presentation = buildSidebarPresentation({ + currentNodeId: "3-001", + gauge: 10, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 100, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }) + + expect(presentation.payload.kind).toBe("partner") + expect(presentation.partner).toEqual({ sprite: "agumon", isDigitama: false }) + }) + + test("builds evolution battle pending next-check line for the sidebar card", () => { + const presentation = buildSidebarPresentation({ + currentNodeId: "3-001", + gauge: 50, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 500, + pendingEvolutionTargetId: "4-017", + battleOpponentNodeId: "3-051", + }) + + if (presentation.payload.kind !== "partner") throw new Error("Expected partner payload") + expect(presentation.payload.nextCheck).toBe("Next check: Evolution battle!") + expect( + buildNextCheckLine({ + kind: "partner", + name: "Agumon", + sprite: "agumon", + stage: "Child", + stageNumber: 3, + url: "https://example.test/agumon", + gauge: 50, + threshold: 100, + isTerminal: false, + frozen: false, + isSetOverride: false, + evolutionBattlePending: true, + }), + ).toBe("Next check: Evolution battle!") + }) + + test("marks digitama partners for the animation host", () => { + const presentation = buildSidebarPresentation({ + currentNodeId: "0-001", + gauge: 0, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }) + + expect(presentation.partner).toEqual({ sprite: "egg", isDigitama: true }) + }) +}) diff --git a/packages/cursor-digital-pet/tests/sidebar-render.test.ts b/packages/cursor-digital-pet/tests/sidebar-render.test.ts new file mode 100644 index 0000000..5487f10 --- /dev/null +++ b/packages/cursor-digital-pet/tests/sidebar-render.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" + +import { + buildGaugeLine, + buildNextCheckLine, + buildSidebarWebviewHtml, + pixelWidthToArtworkColumns, + toSidebarWebviewPayload, +} from "../src/webview/sidebar/sidebar-render.ts" + +const partnerCard = { + kind: "partner" as const, + name: "Agumon", + sprite: "agumon", + stage: "Rookie", + stageNumber: 3, + url: "https://example.com", + gauge: 50, + threshold: 100, + isTerminal: false, + frozen: false, + isSetOverride: false, + evolutionBattlePending: false, +} + +describe("sidebar render", () => { + test("converts pixel width to monospace artwork columns", () => { + expect(pixelWidthToArtworkColumns(250, 7.5)).toBe(33) + expect(pixelWidthToArtworkColumns(120, 7.5)).toBe(16) + expect(pixelWidthToArtworkColumns(10, 7.5)).toBe(16) + }) + + test("builds next check bar for partner progress", () => { + const line = buildNextCheckLine(partnerCard) + + expect(line).toBe("Next check: [██████████░░░░░░░░░░]") + }) + + test("embeds nonce in sidebar CSP and script tag", () => { + const html = buildSidebarWebviewHtml("test-nonce-123") + expect(html).toContain("script-src 'nonce-test-nonce-123'") + expect(html).toContain('nonce="test-nonce-123"') + }) + + test("maps partner model to sidebar payload without artwork", () => { + const payload = toSidebarWebviewPayload({ + ...partnerCard, + url: "https://example.com/agumon", + gauge: 10, + threshold: 100, + }) + + expect(payload.kind).toBe("partner") + if (payload.kind !== "partner") return + expect(payload.name).toBe("Agumon") + expect(payload.stage).toBe("Rookie") + expect("artwork" in payload).toBe(false) + expect( + buildGaugeLine({ + ...partnerCard, + url: "https://example.com/agumon", + gauge: 10, + threshold: 100, + }), + ).toBe("10/100") + }) +}) diff --git a/packages/cursor-digital-pet/tests/sidebar/sidebar-animation-host.test.ts b/packages/cursor-digital-pet/tests/sidebar/sidebar-animation-host.test.ts new file mode 100644 index 0000000..3e6e49f --- /dev/null +++ b/packages/cursor-digital-pet/tests/sidebar/sidebar-animation-host.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test" + +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +import { createSidebarAnimationHost } from "../../src/webview/sidebar/sidebar-animation-host.ts" +import { createCaptureSink, createManualScheduler, sequenceRandom } from "./test-fixtures.ts" + +describe("sidebar animation host", () => { + test("start and tick posts artwork when visible", async () => { + const { sink, artworks } = createCaptureSink() + const scheduler = createManualScheduler() + const host = createSidebarAnimationHost({ + frameCatalog: MONSTER_FRAME_CATALOG, + sink, + scheduler, + random: sequenceRandom([0.5]), + isVisible: () => true, + }) + host.syncPartner({ sprite: "agumon", isDigitama: false }) + host.start() + scheduler.tick() + await Promise.resolve() + expect(artworks.length).toBeGreaterThan(0) + host.stop() + }) + + test("stop prevents further tick posts", async () => { + const { sink, artworks } = createCaptureSink() + const scheduler = createManualScheduler() + const host = createSidebarAnimationHost({ + frameCatalog: MONSTER_FRAME_CATALOG, + sink, + scheduler, + random: sequenceRandom([0.5]), + }) + host.syncPartner({ sprite: "agumon", isDigitama: false }) + host.start() + scheduler.tick() + await Promise.resolve() + const countAfterFirst = artworks.length + host.stop() + scheduler.tick() + await Promise.resolve() + expect(artworks.length).toBe(countAfterFirst) + }) + + test("isVisible false suppresses tick posts", async () => { + const { sink, artworks } = createCaptureSink() + const scheduler = createManualScheduler() + const host = createSidebarAnimationHost({ + frameCatalog: MONSTER_FRAME_CATALOG, + sink, + scheduler, + random: sequenceRandom([0.5]), + isVisible: () => false, + }) + host.syncPartner({ sprite: "agumon", isDigitama: false }) + host.start() + scheduler.tick() + await Promise.resolve() + expect(artworks).toHaveLength(0) + }) + + test("playFeedAnimation posts eat frames for a partner with eat clips", async () => { + const { sink, artworks } = createCaptureSink() + const host = createSidebarAnimationHost({ + frameCatalog: MONSTER_FRAME_CATALOG, + sink, + scheduler: createManualScheduler(), + random: sequenceRandom([0.5]), + }) + host.syncPartner({ sprite: "agumon", isDigitama: false }) + host.setArtworkWidth(40) + host.playFeedAnimation() + await Promise.resolve() + expect(artworks.length).toBe(1) + expect(artworks[0]?.trim().length).toBeGreaterThan(0) + }) + + test("presentation blocked suppresses tick posts", async () => { + const { sink, artworks } = createCaptureSink() + const scheduler = createManualScheduler() + const host = createSidebarAnimationHost({ + frameCatalog: MONSTER_FRAME_CATALOG, + sink, + scheduler, + random: sequenceRandom([0.5]), + }) + host.setPresentationBlocked(true) + host.syncPartner({ sprite: "agumon", isDigitama: false }) + host.start() + scheduler.tick() + await Promise.resolve() + expect(artworks).toHaveLength(0) + }) + + test("setArtworkWidth updates viewport width", async () => { + const { sink, artworks } = createCaptureSink() + const host = createSidebarAnimationHost({ + frameCatalog: MONSTER_FRAME_CATALOG, + sink, + scheduler: createManualScheduler(), + random: sequenceRandom([0.5]), + }) + host.setArtworkWidth(80) + host.syncPartner({ sprite: "agumon", isDigitama: false }) + await host.postCurrentFrame() + expect(artworks.length).toBe(1) + expect(host.getArtworkWidth()).toBe(80) + }) + + test("syncPartner change produces different artwork", async () => { + const { sink, artworks } = createCaptureSink() + const host = createSidebarAnimationHost({ + frameCatalog: MONSTER_FRAME_CATALOG, + sink, + scheduler: createManualScheduler(), + random: sequenceRandom([0.5]), + }) + host.syncPartner({ sprite: "agumon", isDigitama: false }) + await host.postCurrentFrame() + host.syncPartner({ sprite: "gabumon", isDigitama: false }) + await host.postCurrentFrame() + expect(artworks.length).toBe(2) + expect(artworks[0]).not.toBe(artworks[1]) + }) + + test("identical consecutive frames are deduplicated", async () => { + const { sink, artworks } = createCaptureSink() + const host = createSidebarAnimationHost({ + frameCatalog: MONSTER_FRAME_CATALOG, + sink, + scheduler: createManualScheduler(), + random: sequenceRandom([0.5]), + }) + host.syncPartner({ sprite: "agumon", isDigitama: false }) + await host.postCurrentFrame() + await host.postCurrentFrame() + expect(artworks).toHaveLength(1) + }) + + test("clearArtwork allows reposting the same frame", async () => { + const { sink, artworks } = createCaptureSink() + const host = createSidebarAnimationHost({ + frameCatalog: MONSTER_FRAME_CATALOG, + sink, + scheduler: createManualScheduler(), + random: sequenceRandom([0.5]), + }) + host.syncPartner({ sprite: "agumon", isDigitama: false }) + await host.postCurrentFrame() + host.clearArtwork() + await host.postCurrentFrame() + expect(artworks).toHaveLength(2) + }) +}) diff --git a/packages/cursor-digital-pet/tests/sidebar/sidebar-orchestrator.test.ts b/packages/cursor-digital-pet/tests/sidebar/sidebar-orchestrator.test.ts new file mode 100644 index 0000000..4c20a90 --- /dev/null +++ b/packages/cursor-digital-pet/tests/sidebar/sidebar-orchestrator.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, mock, test } from "bun:test" + +import type { UsageEvolutionTransition } from "@jcendal/digital-pet-core/application/models/usage.ts" + +mock.module("../../../digital-pet-animation/src/utils/sleep.ts", () => ({ + sleep: async () => {}, +})) + +import { createSidebarOrchestrator } from "../../src/webview/sidebar/sidebar-orchestrator.ts" +import { + battleSidebarSnapshot, + createBattleRepository, + createCaptureNotification, + createCaptureSink, + createSpyAnimationHost, + nullSidebarSnapshot, + partnerSidebarSnapshot, + sequenceRandom, +} from "./test-fixtures.ts" + +describe("sidebar orchestrator", () => { + test("refresh publishes no_partner when snapshot is null", async () => { + const { sink, models } = createCaptureSink() + const animationHost = createSpyAnimationHost() + const orchestrator = createSidebarOrchestrator({ + snapshotReader: { getSidebarSnapshot: nullSidebarSnapshot }, + battleRepository: createBattleRepository(), + animationHost, + notification: createCaptureNotification(), + }) + orchestrator.setSink(sink) + await orchestrator.refresh() + expect(models.at(-1)?.kind).toBe("no_partner") + expect(animationHost.syncCalls).toBe(1) + }) + + test("refresh publishes partner payload when snapshot has a partner", async () => { + const { sink, models } = createCaptureSink() + const animationHost = createSpyAnimationHost() + const orchestrator = createSidebarOrchestrator({ + snapshotReader: { getSidebarSnapshot: partnerSidebarSnapshot }, + battleRepository: createBattleRepository(), + animationHost, + notification: createCaptureNotification(), + }) + orchestrator.setSink(sink) + await orchestrator.refresh() + expect(models.at(-1)?.kind).toBe("partner") + expect(animationHost.syncCalls).toBe(1) + }) + + test("concurrent refresh calls coalesce instead of publishing once per call", async () => { + const { sink, models } = createCaptureSink() + const orchestrator = createSidebarOrchestrator({ + snapshotReader: { getSidebarSnapshot: partnerSidebarSnapshot }, + battleRepository: createBattleRepository(), + animationHost: createSpyAnimationHost(), + notification: createCaptureNotification(), + }) + orchestrator.setSink(sink) + await Promise.all([orchestrator.refresh(), orchestrator.refresh(), orchestrator.refresh()]) + expect(models.length).toBeGreaterThan(0) + expect(models.length).toBeLessThan(3) + }) + + test("pending battle resolves with victory notification", async () => { + const { sink } = createCaptureSink() + const notification = createCaptureNotification() + const orchestrator = createSidebarOrchestrator({ + snapshotReader: { getSidebarSnapshot: battleSidebarSnapshot }, + battleRepository: createBattleRepository("won"), + animationHost: createSpyAnimationHost(), + notification, + random: sequenceRandom([0]), + }) + orchestrator.setSink(sink) + await orchestrator.refresh() + expect(notification.messages.some((message) => message.includes("Victory"))).toBe(true) + }) + + test("pending battle resolves with defeat notification when the player loses", async () => { + const { sink } = createCaptureSink() + const notification = createCaptureNotification() + const orchestrator = createSidebarOrchestrator({ + snapshotReader: { getSidebarSnapshot: battleSidebarSnapshot }, + battleRepository: createBattleRepository("lost"), + animationHost: createSpyAnimationHost(), + notification, + random: sequenceRandom([0.99]), + }) + orchestrator.setSink(sink) + await orchestrator.refresh() + expect(notification.messages.some((message) => message.includes("Defeat"))).toBe(true) + }) + + test("queueEvolutionReveal plays reveal on next refresh", async () => { + const { sink, artworks } = createCaptureSink() + const notification = createCaptureNotification() + const evolution: UsageEvolutionTransition = { fromNodeId: "3-001", toNodeId: "4-017" } + const orchestrator = createSidebarOrchestrator({ + snapshotReader: { getSidebarSnapshot: partnerSidebarSnapshot }, + battleRepository: createBattleRepository(), + animationHost: createSpyAnimationHost(), + notification, + }) + orchestrator.setSink(sink) + orchestrator.queueEvolutionReveal(evolution) + await orchestrator.refresh() + expect(artworks.length).toBeGreaterThan(0) + expect(notification.messages.some((message) => message.includes("Your partner evolved!"))).toBe(true) + }) + + test("isPresentationInProgress is false after refresh completes", async () => { + const orchestrator = createSidebarOrchestrator({ + snapshotReader: { getSidebarSnapshot: partnerSidebarSnapshot }, + battleRepository: createBattleRepository(), + animationHost: createSpyAnimationHost(), + notification: createCaptureNotification(), + }) + orchestrator.setSink(createCaptureSink().sink) + await orchestrator.refresh() + expect(orchestrator.isPresentationInProgress()).toBe(false) + }) + + test("onPresentationEnd callback runs after battle", async () => { + let ended = false + const orchestrator = createSidebarOrchestrator({ + snapshotReader: { getSidebarSnapshot: battleSidebarSnapshot }, + battleRepository: createBattleRepository("won"), + animationHost: createSpyAnimationHost(), + notification: createCaptureNotification(), + random: sequenceRandom([0]), + onPresentationEnd: () => { + ended = true + }, + }) + orchestrator.setSink(createCaptureSink().sink) + await orchestrator.refresh() + expect(ended).toBe(true) + }) + + test("setSink routes later refresh payloads to the new sink", async () => { + const first = createCaptureSink() + const second = createCaptureSink() + const orchestrator = createSidebarOrchestrator({ + snapshotReader: { getSidebarSnapshot: partnerSidebarSnapshot }, + battleRepository: createBattleRepository(), + animationHost: createSpyAnimationHost(), + notification: createCaptureNotification(), + }) + orchestrator.setSink(first.sink) + orchestrator.setSink(second.sink) + await orchestrator.refresh() + expect(first.models).toHaveLength(0) + expect(second.models).toHaveLength(1) + }) + + test("refresh without sink configured does not throw", async () => { + const orchestrator = createSidebarOrchestrator({ + snapshotReader: { getSidebarSnapshot: partnerSidebarSnapshot }, + battleRepository: createBattleRepository(), + animationHost: createSpyAnimationHost(), + notification: createCaptureNotification(), + }) + await expect(orchestrator.refresh()).resolves.toBeUndefined() + }) + + test("getCachedPayload returns the last published model", async () => { + const { sink } = createCaptureSink() + const orchestrator = createSidebarOrchestrator({ + snapshotReader: { getSidebarSnapshot: partnerSidebarSnapshot }, + battleRepository: createBattleRepository(), + animationHost: createSpyAnimationHost(), + notification: createCaptureNotification(), + }) + orchestrator.setSink(sink) + await orchestrator.refresh() + expect(orchestrator.getCachedPayload()?.kind).toBe("partner") + }) +}) diff --git a/packages/cursor-digital-pet/tests/sidebar/sidebar-provider.test.ts b/packages/cursor-digital-pet/tests/sidebar/sidebar-provider.test.ts new file mode 100644 index 0000000..e8c4073 --- /dev/null +++ b/packages/cursor-digital-pet/tests/sidebar/sidebar-provider.test.ts @@ -0,0 +1,141 @@ +import { beforeAll, describe, expect, mock, test } from "bun:test" + +const openedUrls: string[] = [] + +beforeAll(() => { + mock.module("vscode", () => ({ + env: { + openExternal: async (uri: { toString: () => string }) => { + openedUrls.push(uri.toString()) + }, + }, + Uri: { + parse: (value: string) => ({ toString: () => value }), + }, + })) +}) + +const loadProvider = async () => { + const { DigitalPetSidebarProvider } = await import("../../src/webview/sidebar/provider.ts") + return DigitalPetSidebarProvider +} + +const extensionUri = { fsPath: "/extension", scheme: "file", path: "/extension" } + +const createStubWebviewView = (visible = true) => { + let messageHandler: ((message: unknown) => void) | undefined + const posted: unknown[] = [] + const webview = { + options: {}, + html: "", + postMessage: async (message: unknown) => { + posted.push(message) + }, + onDidReceiveMessage: (handler: (message: unknown) => void) => { + messageHandler = handler + return { dispose: () => undefined } + }, + } + const webviewView = { + visible, + webview, + onDidChangeVisibility: () => ({ dispose: () => undefined }), + } + return { + webviewView, + posted, + sendMessage: (message: unknown) => messageHandler?.(message), + } +} + +describe("sidebar provider", () => { + test("resolveWebviewView sets CSP html with a nonce", async () => { + const DigitalPetSidebarProvider = await loadProvider() + const stub = createStubWebviewView() + const provider = new DigitalPetSidebarProvider( + extensionUri as import("vscode").Uri, + { getSidebarSnapshot: () => null }, + { getActivePartner: () => null, resolveEvolutionBattle: () => ({ kind: "no_pending_battle" }) }, + { scheduler: { start: () => () => undefined } }, + ) + provider.resolveWebviewView(stub.webviewView as unknown as import("vscode").WebviewView) + expect(stub.webviewView.webview.html).toContain("Content-Security-Policy") + expect(stub.webviewView.webview.html).toMatch(/script nonce="[^"]+"/) + }) + + test("open-url message opens the link through vscode", async () => { + const DigitalPetSidebarProvider = await loadProvider() + openedUrls.length = 0 + const stub = createStubWebviewView() + const provider = new DigitalPetSidebarProvider( + extensionUri as import("vscode").Uri, + { getSidebarSnapshot: () => null }, + { getActivePartner: () => null, resolveEvolutionBattle: () => ({ kind: "no_pending_battle" }) }, + { scheduler: { start: () => () => undefined } }, + ) + provider.resolveWebviewView(stub.webviewView as unknown as import("vscode").WebviewView) + stub.sendMessage({ type: "open-url", url: "https://example.com/agumon" }) + await Promise.resolve() + expect(openedUrls).toEqual(["https://example.com/agumon"]) + }) + + test("invalid inbound messages are ignored", async () => { + const DigitalPetSidebarProvider = await loadProvider() + const stub = createStubWebviewView() + const provider = new DigitalPetSidebarProvider( + extensionUri as import("vscode").Uri, + { getSidebarSnapshot: () => null }, + { getActivePartner: () => null, resolveEvolutionBattle: () => ({ kind: "no_pending_battle" }) }, + { scheduler: { start: () => () => undefined } }, + ) + provider.resolveWebviewView(stub.webviewView as unknown as import("vscode").WebviewView) + stub.sendMessage({ type: "unknown" }) + expect(stub.posted).toHaveLength(0) + }) + + test("cached payload is posted when webview resolves", async () => { + const DigitalPetSidebarProvider = await loadProvider() + const stub = createStubWebviewView() + const provider = new DigitalPetSidebarProvider( + extensionUri as import("vscode").Uri, + { + getSidebarSnapshot: () => ({ + currentNodeId: "3-001", + gauge: 10, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 100, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }), + }, + { getActivePartner: () => null, resolveEvolutionBattle: () => ({ kind: "no_pending_battle" }) }, + { scheduler: { start: () => () => undefined } }, + ) + await provider.refresh() + provider.resolveWebviewView(stub.webviewView as unknown as import("vscode").WebviewView) + await Promise.resolve() + expect(stub.posted.some((message) => (message as { kind?: string }).kind === "partner")).toBe(true) + }) + + test("dispose stops the animation host scheduler", async () => { + const DigitalPetSidebarProvider = await loadProvider() + const state = { stopped: false } + const scheduler = { + start: () => () => { + state.stopped = true + }, + } + const provider = new DigitalPetSidebarProvider( + extensionUri as import("vscode").Uri, + { getSidebarSnapshot: () => null }, + { getActivePartner: () => null, resolveEvolutionBattle: () => ({ kind: "no_pending_battle" }) }, + { scheduler }, + ) + const stub = createStubWebviewView() + provider.resolveWebviewView(stub.webviewView as unknown as import("vscode").WebviewView) + provider.dispose() + expect(state.stopped).toBe(true) + }) +}) diff --git a/packages/cursor-digital-pet/tests/sidebar/test-fixtures.ts b/packages/cursor-digital-pet/tests/sidebar/test-fixtures.ts new file mode 100644 index 0000000..56cd139 --- /dev/null +++ b/packages/cursor-digital-pet/tests/sidebar/test-fixtures.ts @@ -0,0 +1,156 @@ +import type { SidebarSnapshot } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import type { EvolutionBattleRepository } from "@jcendal/digital-pet-core/application/use-cases/resolve-evolution-battle.ts" +import type { AnimationSink } from "../../src/adapters/vscode/animation-sink.ts" +import type { NotificationPort } from "../../src/adapters/vscode/notification-port.ts" +import type { IntervalScheduler } from "../../src/adapters/vscode/scheduler.ts" +import type { SidebarWebviewPayload } from "../../src/webview/sidebar/webview-messages.ts" +import type { SidebarAnimationHost } from "../../src/webview/sidebar/sidebar-animation-host.ts" +export const nullSidebarSnapshot = (): null => null + +export const partnerSidebarSnapshot = (): SidebarSnapshot => ({ + currentNodeId: "3-001", + gauge: 10, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 100, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, +}) + +export const battleSidebarSnapshot = (): SidebarSnapshot => ({ + currentNodeId: "3-001", + gauge: 50, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 500, + pendingEvolutionTargetId: "4-017", + battleOpponentNodeId: "3-051", +}) + +export const createCaptureSink = (): { + sink: AnimationSink + models: SidebarWebviewPayload[] + artworks: string[] +} => { + const models: SidebarWebviewPayload[] = [] + const artworks: string[] = [] + return { + models, + artworks, + sink: { + postModel: async (payload) => { + models.push(payload) + }, + postArtwork: async (artwork) => { + artworks.push(artwork) + }, + }, + } +} + +export const createCaptureNotification = (): NotificationPort & { messages: string[] } => { + const messages: string[] = [] + return { + messages, + showInformation(message: string): void { + messages.push(message) + }, + } +} + +export const createSpyAnimationHost = (): SidebarAnimationHost & { + syncCalls: number + blocked: boolean + artworkWidth: number + started: boolean +} => { + const state = { + syncCalls: 0, + blocked: false, + artworkWidth: 32, + started: false, + } + return { + setArtworkWidth(width: number): void { + state.artworkWidth = width + }, + syncPartner(): void { + state.syncCalls += 1 + }, + start(): void { + state.started = true + }, + stop(): void { + state.started = false + }, + clearArtwork(): void {}, + async postCurrentFrame(): Promise {}, + playFeedAnimation(): void {}, + isPresentationBlocked(): boolean { + return state.blocked + }, + setPresentationBlocked(blocked: boolean): void { + state.blocked = blocked + }, + getArtworkWidth(): number { + return state.artworkWidth + }, + get syncCalls(): number { + return state.syncCalls + }, + get blocked(): boolean { + return state.blocked + }, + get artworkWidth(): number { + return state.artworkWidth + }, + get started(): boolean { + return state.started + }, + } +} + +export const createBattleRepository = (outcome: "won" | "lost" = "won"): EvolutionBattleRepository => ({ + getActivePartner: () => ({ + partnerId: "partner-1", + generation: 1, + currentNodeId: "3-001", + gauge: 50, + isTerminal: false, + createdAt: "2026-01-01T00:00:00.000Z", + retiredAt: null, + pendingEvolutionTargetId: "4-017", + battleOpponentNodeId: "3-051", + }), + resolveEvolutionBattle: () => + outcome === "won" ? { kind: "won", evolution: { fromNodeId: "3-001", toNodeId: "4-017" } } : { kind: "lost" }, +}) + +export const createManualScheduler = (): IntervalScheduler & { + tick: () => void + readonly state: { stopped: boolean } +} => { + let callback: (() => void) | undefined + const state = { stopped: false } + return { + state, + start(next: () => void): () => void { + callback = next + state.stopped = false + return () => { + callback = undefined + state.stopped = true + } + }, + tick(): void { + callback?.() + }, + } +} + +export const sequenceRandom = (values: readonly number[]): (() => number) => { + let index = 0 + return () => values[index++] ?? values[values.length - 1] ?? 0.5 +} diff --git a/packages/cursor-digital-pet/tests/tsconfig.json b/packages/cursor-digital-pet/tests/tsconfig.json new file mode 100644 index 0000000..f34f24f --- /dev/null +++ b/packages/cursor-digital-pet/tests/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.test.json", + "include": ["./**/*"], + "exclude": ["../node_modules", "../dist"] +} diff --git a/packages/cursor-digital-pet/tests/webview-messages.test.ts b/packages/cursor-digital-pet/tests/webview-messages.test.ts new file mode 100644 index 0000000..c7cc7e4 --- /dev/null +++ b/packages/cursor-digital-pet/tests/webview-messages.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" + +import { parseWebviewInboundMessage } from "../src/webview/sidebar/webview-messages.ts" + +describe("webview messages", () => { + test("parses open-url messages", () => { + expect(parseWebviewInboundMessage({ type: "open-url", url: "https://example.com" })).toEqual({ + type: "open-url", + url: "https://example.com", + }) + }) + + test("parses artwork-width messages", () => { + expect(parseWebviewInboundMessage({ type: "artwork-width", width: 42 })).toEqual({ + type: "artwork-width", + width: 42, + }) + }) + + test("rejects invalid payloads", () => { + expect(parseWebviewInboundMessage(null)).toBeNull() + expect(parseWebviewInboundMessage({ type: "open-url", url: "" })).toBeNull() + expect(parseWebviewInboundMessage({ type: "artwork-width", width: Number.NaN })).toBeNull() + }) +}) diff --git a/packages/cursor-digital-pet/tsconfig.json b/packages/cursor-digital-pet/tsconfig.json new file mode 100644 index 0000000..c70bb19 --- /dev/null +++ b/packages/cursor-digital-pet/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "target": "ESNext", + "lib": ["ESNext"], + "types": ["node", "vscode"], + "outDir": "dist", + "rootDir": "src", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src", "src/types"] +} diff --git a/packages/cursor-digital-pet/tsconfig.test.json b/packages/cursor-digital-pet/tsconfig.test.json new file mode 100644 index 0000000..d89fd07 --- /dev/null +++ b/packages/cursor-digital-pet/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "types": ["bun", "node", "vscode"] + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/digital-pet-animation/package.json b/packages/digital-pet-animation/package.json new file mode 100644 index 0000000..e91056a --- /dev/null +++ b/packages/digital-pet-animation/package.json @@ -0,0 +1,29 @@ +{ + "name": "@jcendal/digital-pet-animation", + "version": "0.2.0-dev.0", + "private": true, + "type": "module", + "engines": { + "node": "24.11.0", + "bun": "1.3.5" + }, + "dependencies": { + "@jcendal/digital-pet-core": "0.2.0-dev.0" + }, + "exports": { + "./constants/*": "./src/constants/*", + "./idle/*": "./src/idle/*", + "./render/*": "./src/render/*", + "./sequences/*": "./src/sequences/*", + "./sessions/*": "./src/sessions/*", + "./utils/*": "./src/utils/*" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "typescript": "5.9.3" + } +} diff --git a/packages/digital-pet-animation/src/constants/evolution-battle.ts b/packages/digital-pet-animation/src/constants/evolution-battle.ts new file mode 100644 index 0000000..1b4fad0 --- /dev/null +++ b/packages/digital-pet-animation/src/constants/evolution-battle.ts @@ -0,0 +1,6 @@ +export const EVOLUTION_BATTLE_HITS_TO_WIN = 3 +export const EVOLUTION_BATTLE_MAX_SHOTS = 24 +export const BATTLE_GAP_COLUMNS = 12 + +export const BATTLE_SCORE_HIT_PIP = "█" +export const BATTLE_SCORE_MISS_PIP = "░" diff --git a/packages/digital-pet-animation/src/constants/monster-artwork.ts b/packages/digital-pet-animation/src/constants/monster-artwork.ts new file mode 100644 index 0000000..a959902 --- /dev/null +++ b/packages/digital-pet-animation/src/constants/monster-artwork.ts @@ -0,0 +1,5 @@ +/** Rows in a single monster ASCII frame (Digimon sprite sheet cell). */ +export const MONSTER_FRAME_ROWS = 8 + +/** Columns in a single monster ASCII frame (Digimon sprite sheet cell). */ +export const MONSTER_FRAME_COLUMNS = 16 diff --git a/packages/digital-pet-animation/src/constants/presentation-timing.ts b/packages/digital-pet-animation/src/constants/presentation-timing.ts new file mode 100644 index 0000000..236bc73 --- /dev/null +++ b/packages/digital-pet-animation/src/constants/presentation-timing.ts @@ -0,0 +1,24 @@ +/** Sidebar monster animation tick interval. */ +export const SIDEBAR_VISUAL_INTERVAL_MS = 500 + +/** Idle time before the partner enters the sleep animation. */ +export const MONSTER_SLEEP_AFTER_MS = 300_000 + +/** Pause before the evolution reveal sequence starts. */ +export const EVOLUTION_PRE_ANIMATION_MS = 400 + +export const EVOLUTION_GLOW_MS = 700 +export const EVOLUTION_MORPH_MS = 1300 +export const EVOLUTION_REVEAL_MS = 1500 +export const EVOLUTION_TICK_MS = 70 + +export const EVOLUTION_BATTLE_INTRO_MS = 1600 +export const EVOLUTION_BATTLE_TRAVEL_MS = 900 +export const EVOLUTION_BATTLE_PAUSE_MS = 550 +export const EVOLUTION_BATTLE_IMPACT_MS = 650 +export const EVOLUTION_BATTLE_OUTCOME_MS = 1400 +export const EVOLUTION_BATTLE_TICK_MS = 70 + +export const DEFEAT_CYCLE_MS = 550 +export const DEFEAT_CYCLES = 5 +export const DEFEAT_PRE_ANIMATION_MS = 300 diff --git a/src/tui/monster-action-policy.ts b/packages/digital-pet-animation/src/idle/monster-action-policy.ts similarity index 73% rename from src/tui/monster-action-policy.ts rename to packages/digital-pet-animation/src/idle/monster-action-policy.ts index cbe3d01..64a2677 100644 --- a/src/tui/monster-action-policy.ts +++ b/packages/digital-pet-animation/src/idle/monster-action-policy.ts @@ -1,4 +1,6 @@ -import type { MonsterFrameCatalog, MonsterFrameName } from "../data/monster-frame-catalog.ts" +import type { MonsterFrameCatalog, MonsterFrameName } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +import { normalizedRandom } from "../utils/random.ts" export type CosmeticActionClip = readonly [MonsterFrameName, MonsterFrameName] export type SleepClip = readonly [MonsterFrameName, MonsterFrameName] @@ -26,6 +28,15 @@ export const selectCosmeticAction = ( return actions[Math.floor(normalizedRandom(random) * actions.length)] } +export const resolveEatClip = (sprite: string, catalog: MonsterFrameCatalog): CosmeticActionClip | undefined => { + const first = catalog.get(sprite, "eat_1") === undefined ? undefined : "eat_1" + const second = catalog.get(sprite, "eat_2") === undefined ? undefined : "eat_2" + if (first !== undefined && second !== undefined) return [first, second] + if (first !== undefined) return [first, first] + if (second !== undefined) return [second, second] + return undefined +} + export const resolveSleepClip = ( sprite: string, fallback: MonsterFrameName, @@ -38,9 +49,3 @@ export const resolveSleepClip = ( if (second !== undefined) return [second, second] return [fallback, fallback] } - -const normalizedRandom = (random: () => number): number => { - const sample = random() - if (!Number.isFinite(sample) || sample <= 0) return 0 - return sample >= 1 ? 1 - Number.EPSILON : sample -} diff --git a/src/tui/monster-animation.ts b/packages/digital-pet-animation/src/idle/monster-animation.ts similarity index 88% rename from src/tui/monster-animation.ts rename to packages/digital-pet-animation/src/idle/monster-animation.ts index caa222d..3c51128 100644 --- a/src/tui/monster-animation.ts +++ b/packages/digital-pet-animation/src/idle/monster-animation.ts @@ -1,6 +1,14 @@ -import type { MonsterFrame, MonsterFrameCatalog, MonsterFrameName } from "../data/monster-frame-catalog.ts" +import type { + MonsterFrame, + MonsterFrameCatalog, + MonsterFrameName, +} from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +import { MONSTER_SLEEP_AFTER_MS } from "../constants/presentation-timing.ts" +import { assertNever } from "../utils/assert-never.ts" import { resolveCosmeticActions, + resolveEatClip, resolveSleepClip, selectCosmeticAction, type CosmeticActionClip, @@ -28,6 +36,7 @@ export type MonsterAnimationEvent = | { readonly kind: "partner_changed"; readonly partner: MonsterAnimationIdentity | undefined } | { readonly kind: "tick" } | { readonly kind: "activity" } + | { readonly kind: "feed" } | { readonly kind: "viewport_resized"; readonly width: number } export type MonsterAnimationResult = @@ -90,11 +99,6 @@ type SleepingState = { type MonsterAnimationState = BlankState | UnavailableState | DigitamaState | WalkingState | ActionState | SleepingState const ORIGIN = { offset: 0, facing: "left" } as const -const SLEEP_AFTER_MS = 300_000 - -const assertNever = (value: never): never => { - throw new Error(`Unexpected monster animation variant: ${JSON.stringify(value)}`) -} const sameIdentity = (left: MonsterAnimationIdentity, right: MonsterAnimationIdentity): boolean => left.sprite === right.sprite && left.isDigitama === right.isDigitama @@ -108,7 +112,11 @@ export class MonsterAnimationController { #latestViewportWidth: number | undefined #state: MonsterAnimationState = { kind: "blank" } - constructor(catalog: MonsterFrameCatalog, random: () => number = Math.random, nowMs: () => number = performance.now) { + constructor( + catalog: MonsterFrameCatalog, + random: () => number = Math.random, + nowMs: () => number = () => performance.now(), + ) { this.#catalog = catalog this.#random = random this.#nowMs = nowMs @@ -125,6 +133,9 @@ export class MonsterAnimationController { case "activity": this.#state = this.#activity() break + case "feed": + this.#state = this.#feed() + break case "viewport_resized": this.#latestViewportWidth = event.width this.#state = this.#resize(event.width) @@ -263,7 +274,7 @@ export class MonsterAnimationController { #inactive(): boolean { const baseline = this.#lastActivityMs - return baseline !== undefined && this.#nowMs() - baseline >= SLEEP_AFTER_MS + return baseline !== undefined && this.#nowMs() - baseline >= MONSTER_SLEEP_AFTER_MS } #sleep(identity: MonsterAnimationIdentity, boundary: ActionBoundaryState): SleepingState { @@ -290,6 +301,29 @@ export class MonsterAnimationController { } } + #feed(): MonsterAnimationState { + const state = this.#state + if (state.kind === "blank" || state.kind === "unavailable" || state.kind === "digitama") return state + + this.#lastActivityMs = this.#nowMs() + const identity = state.identity + const boundary: ActionBoundaryState = + state.kind === "walking" + ? { + frameName: state.policy.frameName, + offset: state.policy.offset, + facing: state.policy.facing, + bounds: state.policy.bounds, + } + : state.boundary + const clip = resolveEatClip(identity.sprite, this.#catalog) + if (clip === undefined) { + if (state.kind === "sleeping") return this.#activity() + return state + } + return { kind: "action", identity, boundary, clip, phase: 0 } + } + #currentIdentity(): MonsterAnimationIdentity | undefined { const state = this.#state switch (state.kind) { diff --git a/src/tui/monster-artwork-mirror.ts b/packages/digital-pet-animation/src/idle/monster-artwork-mirror.ts similarity index 82% rename from src/tui/monster-artwork-mirror.ts rename to packages/digital-pet-animation/src/idle/monster-artwork-mirror.ts index 3a3a725..99e794e 100644 --- a/src/tui/monster-artwork-mirror.ts +++ b/packages/digital-pet-animation/src/idle/monster-artwork-mirror.ts @@ -1,7 +1,6 @@ -import { MonsterFrame } from "../data/monster-frame-catalog.ts" +import { MonsterFrame } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" -const FRAME_ROWS = 8 -const FRAME_COLUMNS = 16 +import { MONSTER_FRAME_COLUMNS, MONSTER_FRAME_ROWS } from "../constants/monster-artwork.ts" export type MalformedMonsterFrameReason = | { readonly kind: "row_count"; readonly actual: number } @@ -11,7 +10,7 @@ export class MalformedMonsterFrameError extends Error { readonly name = "MalformedMonsterFrameError" constructor(readonly reason: MalformedMonsterFrameReason) { - super("Monster frame must contain eight rows of 16 Unicode code points") + super(`Monster frame must contain ${MONSTER_FRAME_ROWS} rows of ${MONSTER_FRAME_COLUMNS} Unicode code points`) } } @@ -56,13 +55,13 @@ const mirrorGlyph = (glyph: string): string => { export const mirrorMonsterFrame = (frame: MonsterFrame): MirrorMonsterFrameResult => { const rows = frame.content.split("\n") - if (rows.length !== FRAME_ROWS) { + if (rows.length !== MONSTER_FRAME_ROWS) { return { kind: "invalid", error: new MalformedMonsterFrameError({ kind: "row_count", actual: rows.length }) } } for (const [row, content] of rows.entries()) { const cells = Array.from(content) - if (cells.length !== FRAME_COLUMNS) { + if (cells.length !== MONSTER_FRAME_COLUMNS) { return { kind: "invalid", error: new MalformedMonsterFrameError({ kind: "column_count", row, actual: cells.length }), diff --git a/src/tui/monster-walking-policy.ts b/packages/digital-pet-animation/src/idle/monster-walking-policy.ts similarity index 92% rename from src/tui/monster-walking-policy.ts rename to packages/digital-pet-animation/src/idle/monster-walking-policy.ts index 49a61db..764a7b9 100644 --- a/src/tui/monster-walking-policy.ts +++ b/packages/digital-pet-animation/src/idle/monster-walking-policy.ts @@ -1,4 +1,7 @@ -import type { MonsterFrameCatalog, MonsterFrameName } from "../data/monster-frame-catalog.ts" +import type { MonsterFrameCatalog, MonsterFrameName } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +import { assertNever } from "../utils/assert-never.ts" +import { normalizedRandom } from "../utils/random.ts" export type WalkingFacing = "left" | "right" export type WalkFrame = "walk_1" | "walk_2" @@ -28,10 +31,6 @@ export type WalkingPolicyTick = | { readonly kind: "walking"; readonly walking: WalkingPolicyState } | { readonly kind: "action"; readonly action: ActionBoundaryState } -const assertNever = (value: never): never => { - throw new Error(`Unexpected walking policy variant: ${JSON.stringify(value)}`) -} - export const initialWalkingPolicy = (frameName: WalkFrame): WalkingPolicyState => ({ frameName, offset: 0, @@ -142,9 +141,3 @@ const planBout = (state: WalkingPolicyState, random: () => number): WalkingPolic const remaining = available < 5 ? available : 5 + Math.floor(normalizedRandom(random) * (available - 4)) return { ...state, bout: { kind: "moving", remaining } } } - -const normalizedRandom = (random: () => number): number => { - const sample = random() - if (!Number.isFinite(sample) || sample <= 0) return 0 - return sample >= 1 ? 1 - Number.EPSILON : sample -} diff --git a/packages/digital-pet-animation/src/render/positioned-artwork.ts b/packages/digital-pet-animation/src/render/positioned-artwork.ts new file mode 100644 index 0000000..f2d496f --- /dev/null +++ b/packages/digital-pet-animation/src/render/positioned-artwork.ts @@ -0,0 +1,47 @@ +import { MONSTER_FRAME_COLUMNS, MONSTER_FRAME_ROWS } from "../constants/monster-artwork.ts" +import type { MonsterAnimationOutput, MonsterAnimationResult } from "../idle/monster-animation.ts" +import { mirrorMonsterFrame } from "../idle/monster-artwork-mirror.ts" + +const positionedOutput = (animation: MonsterAnimationOutput | MonsterAnimationResult): MonsterAnimationOutput => { + if ("result" in animation) return animation + switch (animation.kind) { + case "blank": + return { kind: "blank", result: animation, offset: 0, facing: "left" } + case "frame": + return { kind: "walking", result: animation, offset: 0, facing: "left" } + case "unavailable": + return { kind: "unavailable", result: animation, offset: 0, facing: "left" } + } +} + +export const renderPositionedArtworkRows = ( + animation: MonsterAnimationOutput | MonsterAnimationResult, + viewportWidth: number, +): readonly string[] => { + const output = positionedOutput(animation) + switch (output.result.kind) { + case "blank": + return Array.from({ length: MONSTER_FRAME_ROWS }, () => "") + case "frame": { + const mirrored = output.facing === "right" ? mirrorMonsterFrame(output.result.frame) : undefined + if (mirrored?.kind === "invalid") return Array.from({ length: MONSTER_FRAME_ROWS }, () => "") + const content = mirrored?.frame.content ?? output.result.frame.content + const free = Math.max(viewportWidth - MONSTER_FRAME_COLUMNS, 0) + const left = Math.max(0, Math.min(free, Math.floor(free / 2) + output.offset)) + return content.split("\n").map((row) => `${" ".repeat(left)}${row}`) + } + case "unavailable": { + const key = output.result.sprite === "" ? "(empty)" : output.result.sprite + const message = `Artwork unavailable: ${key}` + const padding = Math.max(Math.floor((viewportWidth - message.length) / 2), 0) + return Array.from({ length: MONSTER_FRAME_ROWS }, (_, index) => + index === 3 ? `${" ".repeat(padding)}${message}` : "", + ) + } + } +} + +export const renderPositionedArtwork = ( + animation: MonsterAnimationOutput | MonsterAnimationResult, + viewportWidth: number, +): string => renderPositionedArtworkRows(animation, viewportWidth).join("\n") diff --git a/packages/digital-pet-animation/src/sequences/defeat-artwork.ts b/packages/digital-pet-animation/src/sequences/defeat-artwork.ts new file mode 100644 index 0000000..1efb5b7 --- /dev/null +++ b/packages/digital-pet-animation/src/sequences/defeat-artwork.ts @@ -0,0 +1,58 @@ +import type { MonsterFrameCatalog, MonsterFrameName } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +import { MONSTER_FRAME_COLUMNS, MONSTER_FRAME_ROWS } from "../constants/monster-artwork.ts" +import { DEFEAT_CYCLE_MS, DEFEAT_CYCLES, DEFEAT_PRE_ANIMATION_MS } from "../constants/presentation-timing.ts" +import { sleep } from "../utils/sleep.ts" + +const NORMAL_FRAME: MonsterFrameName = "walk_1" +const SAD_FRAMES: readonly MonsterFrameName[] = ["refuse", "injured_1"] + +export type DefeatArtworkScene = { + readonly sad: boolean + readonly sadAlt: boolean +} + +const frameLines = (catalog: MonsterFrameCatalog, sprite: string, frameName: MonsterFrameName): string[] => { + const frame = catalog.get(sprite, frameName) ?? catalog.get(sprite, "walk_1") + if (frame === undefined) return Array.from({ length: MONSTER_FRAME_ROWS }, () => " ".repeat(MONSTER_FRAME_COLUMNS)) + return frame.content.split("\n") +} + +const sadFrameFor = (catalog: MonsterFrameCatalog, sprite: string, sadAlt: boolean): MonsterFrameName => { + const preferred = sadAlt ? SAD_FRAMES[1] : SAD_FRAMES[0] + if (preferred !== undefined && catalog.get(sprite, preferred) !== undefined) return preferred + const fallback = SAD_FRAMES.find((frameName) => catalog.get(sprite, frameName) !== undefined) + return fallback ?? "injured_1" +} + +const centerArtwork = (rows: readonly string[], viewportWidth: number): string => { + const padding = Math.max(Math.floor((viewportWidth - MONSTER_FRAME_COLUMNS) / 2), 0) + return rows.map((row) => `${" ".repeat(padding)}${row}`).join("\n") +} + +export const renderDefeatArtwork = ( + catalog: MonsterFrameCatalog, + sprite: string, + scene: DefeatArtworkScene, + viewportWidth: number, +): string => { + const frameName = scene.sad ? sadFrameFor(catalog, sprite, scene.sadAlt) : NORMAL_FRAME + const spriteRows = frameLines(catalog, sprite, frameName) + return centerArtwork(spriteRows, viewportWidth) +} + +export const runDefeatAnimation = async ( + catalog: MonsterFrameCatalog, + sprite: string, + viewportWidth: number, + onFrame: (artwork: string) => Promise, +): Promise => { + await sleep(DEFEAT_PRE_ANIMATION_MS) + + for (let cycle = 0; cycle < DEFEAT_CYCLES; cycle += 1) { + const sad = cycle % 2 === 1 + const sadAlt = cycle % 4 >= 2 + await onFrame(renderDefeatArtwork(catalog, sprite, { sad, sadAlt }, viewportWidth)) + if (cycle < DEFEAT_CYCLES - 1) await sleep(DEFEAT_CYCLE_MS) + } +} diff --git a/packages/digital-pet-animation/src/sequences/evolution-artwork.ts b/packages/digital-pet-animation/src/sequences/evolution-artwork.ts new file mode 100644 index 0000000..c467852 --- /dev/null +++ b/packages/digital-pet-animation/src/sequences/evolution-artwork.ts @@ -0,0 +1,126 @@ +import type { MonsterFrameCatalog, MonsterFrameName } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +import { MONSTER_FRAME_COLUMNS, MONSTER_FRAME_ROWS } from "../constants/monster-artwork.ts" +import { + EVOLUTION_GLOW_MS, + EVOLUTION_MORPH_MS, + EVOLUTION_PRE_ANIMATION_MS, + EVOLUTION_REVEAL_MS, + EVOLUTION_TICK_MS, +} from "../constants/presentation-timing.ts" +import { sleep } from "../utils/sleep.ts" + +const GLOW_GLYPHS = "▄▀█░" + +export type EvolutionArtworkPhase = "glow" | "morph" | "reveal" + +export type EvolutionArtworkScene = { + readonly phase: EvolutionArtworkPhase + readonly progress: number +} + +const frameLines = (catalog: MonsterFrameCatalog, sprite: string, frameName: MonsterFrameName): string[] => { + const frame = catalog.get(sprite, frameName) ?? catalog.get(sprite, "walk_1") + if (frame === undefined) return Array.from({ length: MONSTER_FRAME_ROWS }, () => " ".repeat(MONSTER_FRAME_COLUMNS)) + return frame.content.split("\n") +} + +const padRow = (row: string, width: number): string => row.padEnd(width, " ").slice(0, width) + +const centerArtwork = (rows: readonly string[], viewportWidth: number): string => { + const padding = Math.max(Math.floor((viewportWidth - MONSTER_FRAME_COLUMNS) / 2), 0) + return rows.map((row) => `${" ".repeat(padding)}${row}`).join("\n") +} + +const glowRow = (row: string, intensity: number, tick: number): string => { + const cells = Array.from(padRow(row, MONSTER_FRAME_COLUMNS)) + return cells + .map((cell, column) => { + if (cell === " ") return cell + const wave = Math.sin((column + tick) * 0.85) * 0.5 + 0.5 + if (wave < intensity) return cell + const glyph = GLOW_GLYPHS[(column + tick) % GLOW_GLYPHS.length] ?? "█" + return glyph + }) + .join("") +} + +const morphRow = (fromRow: string, toRow: string, progress: number, rowIndex: number): string => { + const from = Array.from(padRow(fromRow, MONSTER_FRAME_COLUMNS)) + const to = Array.from(padRow(toRow, MONSTER_FRAME_COLUMNS)) + return from + .map((fromCell, column) => { + const toCell = to[column] ?? " " + const threshold = (column / MONSTER_FRAME_COLUMNS) * 0.65 + (rowIndex / MONSTER_FRAME_ROWS) * 0.35 + if (progress >= threshold) return toCell === " " ? fromCell : toCell + return fromCell + }) + .join("") +} + +const applyGlow = (rows: string[], progress: number, tick: number): string[] => + rows.map((row, rowIndex) => glowRow(row, progress * 0.75 + rowIndex * 0.02, tick + rowIndex)) + +const applyMorph = (fromRows: string[], toRows: string[], progress: number): string[] => + fromRows.map((fromRow, rowIndex) => morphRow(fromRow, toRows[rowIndex] ?? "", progress, rowIndex)) + +export const renderEvolutionArtwork = ( + catalog: MonsterFrameCatalog, + fromSprite: string, + toSprite: string, + scene: EvolutionArtworkScene, + viewportWidth: number, + tick = 0, +): string => { + const fromRows = frameLines(catalog, fromSprite, "walk_1") + const toRows = frameLines(catalog, toSprite, "walk_1") + const happyRows = frameLines(catalog, toSprite, "happy") + + let spriteRows: string[] + switch (scene.phase) { + case "glow": + spriteRows = applyGlow(fromRows, scene.progress, tick) + break + case "morph": + spriteRows = applyMorph(fromRows, toRows, scene.progress) + break + case "reveal": { + const revealBlend = scene.progress < 0.35 ? applyMorph(toRows, happyRows, scene.progress / 0.35) : happyRows + spriteRows = revealBlend + break + } + } + + return centerArtwork(spriteRows, viewportWidth) +} + +const animatePhase = async ( + catalog: MonsterFrameCatalog, + fromSprite: string, + toSprite: string, + viewportWidth: number, + phase: EvolutionArtworkPhase, + durationMs: number, + onFrame: (artwork: string) => Promise, +): Promise => { + const steps = Math.max(1, Math.ceil(durationMs / EVOLUTION_TICK_MS)) + for (let step = 0; step <= steps; step += 1) { + const progress = step / steps + const artwork = renderEvolutionArtwork(catalog, fromSprite, toSprite, { phase, progress }, viewportWidth, step) + await onFrame(artwork) + if (step < steps) await sleep(EVOLUTION_TICK_MS) + } +} + +export const runEvolutionAnimation = async ( + catalog: MonsterFrameCatalog, + fromSprite: string, + toSprite: string, + viewportWidth: number, + onFrame: (artwork: string) => Promise, +): Promise => { + await sleep(EVOLUTION_PRE_ANIMATION_MS) + await animatePhase(catalog, fromSprite, toSprite, viewportWidth, "glow", EVOLUTION_GLOW_MS, onFrame) + await animatePhase(catalog, fromSprite, toSprite, viewportWidth, "morph", EVOLUTION_MORPH_MS, onFrame) + await animatePhase(catalog, fromSprite, toSprite, viewportWidth, "reveal", EVOLUTION_REVEAL_MS, onFrame) +} diff --git a/packages/digital-pet-animation/src/sequences/evolution-battle-artwork.ts b/packages/digital-pet-animation/src/sequences/evolution-battle-artwork.ts new file mode 100644 index 0000000..c84b2b3 --- /dev/null +++ b/packages/digital-pet-animation/src/sequences/evolution-battle-artwork.ts @@ -0,0 +1,480 @@ +import type { MonsterFrameCatalog, MonsterFrameName } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +import { + BATTLE_GAP_COLUMNS, + BATTLE_SCORE_HIT_PIP, + BATTLE_SCORE_MISS_PIP, + EVOLUTION_BATTLE_HITS_TO_WIN, + EVOLUTION_BATTLE_MAX_SHOTS, +} from "../constants/evolution-battle.ts" +import { MONSTER_FRAME_COLUMNS, MONSTER_FRAME_ROWS } from "../constants/monster-artwork.ts" +import { + EVOLUTION_BATTLE_IMPACT_MS, + EVOLUTION_BATTLE_INTRO_MS, + EVOLUTION_BATTLE_OUTCOME_MS, + EVOLUTION_BATTLE_PAUSE_MS, + EVOLUTION_BATTLE_TICK_MS, + EVOLUTION_BATTLE_TRAVEL_MS, +} from "../constants/presentation-timing.ts" +import { mirrorMonsterFrame } from "../idle/monster-artwork-mirror.ts" +import { sleep } from "../utils/sleep.ts" + +/** FIGlet-style BATTLE banner. */ +export const BATTLE_FIGLET_LINES = Object.freeze([ + "██████╗ █████╗ ████████╗████████╗██╗ ███████╗", + "██╔══██╗██╔══██╗╚══██╔══╝╚══██╔══╝██║ ██╔════╝", + "██████╔╝███████║ ██║ ██║ ██║ █████╗ ", + "██╔══██╗██╔══██║ ██║ ██║ ██║ ██╔══╝ ", + "██████╔╝██║ ██║ ██║ ██║ ███████╗███████╗", + "╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚══════╝", +]) + +export const buildBattleIntroTextLines = (): readonly string[] => BATTLE_FIGLET_LINES + +/** Block-style flame sprite matching Digimon ASCII art. */ +export const FIREBALL_LINES = Object.freeze([" ▄▀▄ ", "▄█▀▀█", " ▀▀▀ "]) + +const FIREBALL_ROWS = FIREBALL_LINES.length +const FIREBALL_COLUMNS = FIREBALL_LINES[0]?.length ?? 0 +const FIREBALL_START_ROW = Math.floor((MONSTER_FRAME_ROWS - FIREBALL_ROWS) / 2) + +const IMPACT_LINES = Object.freeze([" ▄█▄ ", " █▀█ ", " ▀▀▀ "]) +const IMPACT_ROWS = IMPACT_LINES.length +const IMPACT_START_ROW = FIREBALL_START_ROW + +export type EvolutionBattleShooter = "player" | "opponent" +export type EvolutionBattleOutcome = EvolutionBattleShooter +export type BattleSpritePose = "attack" | "injured" | "happy" | "idle" +export type EvolutionBattleResultLabel = "hit" | "miss" | null + +export type EvolutionBattleShot = { + readonly shooter: EvolutionBattleShooter + readonly hit: boolean +} + +export type EvolutionBattleScene = { + readonly shooter: EvolutionBattleShooter | null + readonly progress: number + readonly playerPose: BattleSpritePose + readonly opponentPose: BattleSpritePose + readonly playerInjuredAlt: boolean + readonly opponentInjuredAlt: boolean + readonly playerHits: number + readonly opponentHits: number + readonly lastResult: EvolutionBattleResultLabel + readonly outcomeLabel: string | null +} + +const poseToFrame = (pose: BattleSpritePose, injuredAlt: boolean): MonsterFrameName => { + switch (pose) { + case "attack": + return "attack" + case "injured": + return injuredAlt ? "injured_2" : "injured_1" + case "happy": + return "happy" + default: + return "walk_1" + } +} + +const frameLines = ( + catalog: MonsterFrameCatalog, + sprite: string, + facing: "left" | "right", + pose: BattleSpritePose, + injuredAlt: boolean, +): string[] => { + const frameName = poseToFrame(pose, injuredAlt) + const frame = catalog.get(sprite, frameName) ?? catalog.get(sprite, "walk_1") + if (frame === undefined) return Array.from({ length: MONSTER_FRAME_ROWS }, () => " ".repeat(MONSTER_FRAME_COLUMNS)) + if (facing === "right") { + const mirrored = mirrorMonsterFrame(frame) + if (mirrored.kind === "mirrored") return mirrored.frame.content.split("\n") + } + return frame.content.split("\n") +} + +const padRow = (row: string, width: number): string => row.padEnd(width, " ").slice(0, width) + +const overlayAt = (base: string, overlay: string, startColumn: number): string => { + const result = base.split("") + for (let index = 0; index < overlay.length; index += 1) { + const column = startColumn + index + if (column < 0 || column >= result.length) continue + const character = overlay[index] + if (character !== undefined && character !== " ") result[column] = character + } + return result.join("") +} + +const centerText = (width: number, text: string): string => { + const start = Math.max(0, Math.floor((width - text.length) / 2)) + return `${" ".repeat(start)}${text}`.padEnd(width, " ").slice(0, width) +} + +export const buildBattleScorePips = (hits: number, total: number = EVOLUTION_BATTLE_HITS_TO_WIN): string => + Array.from({ length: total }, (_, index) => (index < hits ? BATTLE_SCORE_HIT_PIP : BATTLE_SCORE_MISS_PIP)).join("") + +export const buildBattleScoreRow = ( + playerHits: number, + opponentHits: number, + width: number = BATTLE_GAP_COLUMNS, +): string => { + const playerPips = buildBattleScorePips(playerHits) + const opponentPips = buildBattleScorePips(opponentHits) + const spacer = Math.max(1, width - playerPips.length - opponentPips.length) + return `${playerPips}${" ".repeat(spacer)}${opponentPips}`.padEnd(width, " ").slice(0, width) +} + +const mirrorFireballLine = (line: string): string => Array.from(line).reverse().join("") + +const fireballLinesForShooter = (shooter: EvolutionBattleShooter): readonly string[] => + shooter === "opponent" ? FIREBALL_LINES.map(mirrorFireballLine) : FIREBALL_LINES + +const fireballColumn = (shooter: EvolutionBattleShooter, progress: number): number => { + const travel = BATTLE_GAP_COLUMNS - FIREBALL_COLUMNS + const clamped = Math.max(0, Math.min(1, progress)) + if (shooter === "player") return Math.round(clamped * travel) + return Math.round((1 - clamped) * travel) +} + +const impactColumn = (shooter: EvolutionBattleShooter): number => { + if (shooter === "player") return BATTLE_GAP_COLUMNS - (IMPACT_LINES[0]?.length ?? 0) + return 0 +} + +const renderFireballInGap = (gapRows: string[], shooter: EvolutionBattleShooter, progress: number): void => { + const startColumn = fireballColumn(shooter, progress) + const fireballLines = fireballLinesForShooter(shooter) + for (let rowIndex = 0; rowIndex < FIREBALL_ROWS; rowIndex += 1) { + const gapRow = FIREBALL_START_ROW + rowIndex + const fireballLine = fireballLines[rowIndex] ?? "" + gapRows[gapRow] = overlayAt(gapRows[gapRow] ?? "", fireballLine, startColumn) + } +} + +const renderImpactInGap = (gapRows: string[], shooter: EvolutionBattleShooter): void => { + const startColumn = impactColumn(shooter) + for (let rowIndex = 0; rowIndex < IMPACT_ROWS; rowIndex += 1) { + const gapRow = IMPACT_START_ROW + rowIndex + const impactLine = IMPACT_LINES[rowIndex] ?? "" + gapRows[gapRow] = overlayAt(gapRows[gapRow] ?? "", impactLine, startColumn) + } +} + +const renderHudInGap = (gapRows: string[], scene: EvolutionBattleScene): void => { + gapRows[0] = buildBattleScoreRow(scene.playerHits, scene.opponentHits) + if (scene.outcomeLabel !== null) { + gapRows[MONSTER_FRAME_ROWS - 1] = centerText(BATTLE_GAP_COLUMNS, scene.outcomeLabel) + } else if (scene.lastResult !== null) { + gapRows[MONSTER_FRAME_ROWS - 1] = centerText(BATTLE_GAP_COLUMNS, scene.lastResult === "hit" ? "HIT!" : "MISS") + } +} + +const hitsFor = (shooter: EvolutionBattleShooter, playerHits: number, opponentHits: number): number => + shooter === "player" ? playerHits : opponentHits + +const rollShotHit = ( + outcome: EvolutionBattleOutcome, + shooter: EvolutionBattleShooter, + playerHits: number, + opponentHits: number, + random: () => number, +): boolean => { + const winner = outcome + const loser = outcome === "player" ? "opponent" : "player" + const winnerHits = hitsFor(winner, playerHits, opponentHits) + const loserHits = hitsFor(loser, playerHits, opponentHits) + + if (shooter === winner) { + if (winnerHits === EVOLUTION_BATTLE_HITS_TO_WIN - 1) return true + if (loserHits === EVOLUTION_BATTLE_HITS_TO_WIN - 1) return true + return random() < 0.65 + } + + if (loserHits === EVOLUTION_BATTLE_HITS_TO_WIN - 1) return false + return random() < 0.45 +} + +export const planEvolutionBattle = ( + outcome: EvolutionBattleOutcome, + random: () => number = Math.random, +): readonly EvolutionBattleShot[] => { + const shots: EvolutionBattleShot[] = [] + let playerHits = 0 + let opponentHits = 0 + let shooter: EvolutionBattleShooter = "player" + + while ( + playerHits < EVOLUTION_BATTLE_HITS_TO_WIN && + opponentHits < EVOLUTION_BATTLE_HITS_TO_WIN && + shots.length < EVOLUTION_BATTLE_MAX_SHOTS + ) { + const hit = rollShotHit(outcome, shooter, playerHits, opponentHits, random) + shots.push({ shooter, hit }) + if (hit) { + if (shooter === "player") playerHits += 1 + else opponentHits += 1 + } + shooter = shooter === "player" ? "opponent" : "player" + } + + return shots +} + +export const defaultBattleScene = (overrides: Partial = {}): EvolutionBattleScene => ({ + shooter: null, + progress: 0, + playerPose: "attack", + opponentPose: "attack", + playerInjuredAlt: false, + opponentInjuredAlt: false, + playerHits: 0, + opponentHits: 0, + lastResult: null, + outcomeLabel: null, + ...overrides, +}) + +const buildBattleIntroLines = (tick: number): string[] => { + const textLines = buildBattleIntroTextLines() + const visible = tick % 2 === 0 + return visible ? [...textLines] : textLines.map((line) => " ".repeat(line.length)) +} + +const padIntroRows = (rows: string[], viewportWidth: number): string[] => { + const width = rows[0]?.length ?? 0 + const horizontalPadding = Math.max(0, Math.floor((viewportWidth - width) / 2)) + const pad = " ".repeat(horizontalPadding) + return rows.map((row) => `${pad}${row}`) +} + +export const renderBattleIntroArtwork = (viewportWidth: number, _progress: number, tick = 0): string => { + const lines = buildBattleIntroLines(tick) + return padIntroRows(lines, viewportWidth).join("\n") +} + +export const renderEvolutionBattleArtwork = ( + catalog: MonsterFrameCatalog, + playerSprite: string, + opponentSprite: string, + scene: EvolutionBattleScene, + viewportWidth: number, +): string => { + const playerLines = frameLines(catalog, playerSprite, "right", scene.playerPose, scene.playerInjuredAlt) + const opponentLines = frameLines(catalog, opponentSprite, "left", scene.opponentPose, scene.opponentInjuredAlt) + + const gapRows = Array.from({ length: MONSTER_FRAME_ROWS }, () => " ".repeat(BATTLE_GAP_COLUMNS)) + renderHudInGap(gapRows, scene) + + if (scene.shooter !== null) { + renderFireballInGap(gapRows, scene.shooter, scene.progress) + } + if (scene.lastResult === "hit" && scene.shooter !== null) { + renderImpactInGap(gapRows, scene.shooter) + } + + const battleWidth = MONSTER_FRAME_COLUMNS + BATTLE_GAP_COLUMNS + MONSTER_FRAME_COLUMNS + const rows = Array.from({ length: MONSTER_FRAME_ROWS }, (_, rowIndex) => { + const left = padRow(playerLines[rowIndex] ?? "", MONSTER_FRAME_COLUMNS) + const gap = gapRows[rowIndex] ?? " ".repeat(BATTLE_GAP_COLUMNS) + const right = padRow(opponentLines[rowIndex] ?? "", MONSTER_FRAME_COLUMNS) + return `${left}${gap}${right}` + }) + + const padding = Math.max(Math.floor((viewportWidth - battleWidth) / 2), 0) + return rows.map((row) => `${" ".repeat(padding)}${row}`).join("\n") +} + +const postScene = async ( + catalog: MonsterFrameCatalog, + playerSprite: string, + opponentSprite: string, + viewportWidth: number, + scene: EvolutionBattleScene, + onFrame: (artwork: string) => Promise, +): Promise => { + await onFrame(renderEvolutionBattleArtwork(catalog, playerSprite, opponentSprite, scene, viewportWidth)) +} + +const animateBattleIntro = async ( + viewportWidth: number, + onFrame: (artwork: string) => Promise, +): Promise => { + const steps = Math.max(1, Math.ceil(EVOLUTION_BATTLE_INTRO_MS / EVOLUTION_BATTLE_TICK_MS)) + for (let step = 0; step <= steps; step += 1) { + const progress = step / steps + await onFrame(renderBattleIntroArtwork(viewportWidth, progress, step)) + if (step < steps) await sleep(EVOLUTION_BATTLE_TICK_MS) + } +} + +const animateShot = async ( + catalog: MonsterFrameCatalog, + playerSprite: string, + opponentSprite: string, + viewportWidth: number, + shot: EvolutionBattleShot, + playerHits: number, + opponentHits: number, + onFrame: (artwork: string) => Promise, +): Promise => { + const steps = Math.max(1, Math.ceil(EVOLUTION_BATTLE_TRAVEL_MS / EVOLUTION_BATTLE_TICK_MS)) + for (let step = 0; step <= steps; step += 1) { + const progress = step / steps + await postScene( + catalog, + playerSprite, + opponentSprite, + viewportWidth, + defaultBattleScene({ + shooter: shot.shooter, + progress, + playerHits, + opponentHits, + playerPose: "attack", + opponentPose: "attack", + }), + onFrame, + ) + if (step < steps) await sleep(EVOLUTION_BATTLE_TICK_MS) + } +} + +const animateImpact = async ( + catalog: MonsterFrameCatalog, + playerSprite: string, + opponentSprite: string, + viewportWidth: number, + shot: EvolutionBattleShot, + playerHits: number, + opponentHits: number, + onFrame: (artwork: string) => Promise, +): Promise => { + const playerPose = shot.hit && shot.shooter === "opponent" ? "injured" : "attack" + const opponentPose = shot.hit && shot.shooter === "player" ? "injured" : "attack" + const ticks = Math.max(1, Math.ceil(EVOLUTION_BATTLE_IMPACT_MS / EVOLUTION_BATTLE_TICK_MS)) + + for (let tick = 0; tick < ticks; tick += 1) { + await postScene( + catalog, + playerSprite, + opponentSprite, + viewportWidth, + defaultBattleScene({ + shooter: shot.shooter, + progress: 1, + playerHits, + opponentHits, + playerPose, + opponentPose, + playerInjuredAlt: tick % 2 === 1 && playerPose === "injured", + opponentInjuredAlt: tick % 2 === 1 && opponentPose === "injured", + lastResult: shot.hit ? "hit" : "miss", + }), + onFrame, + ) + if (tick < ticks - 1) await sleep(EVOLUTION_BATTLE_TICK_MS) + } +} + +const animatePause = async ( + catalog: MonsterFrameCatalog, + playerSprite: string, + opponentSprite: string, + viewportWidth: number, + playerHits: number, + opponentHits: number, + onFrame: (artwork: string) => Promise, +): Promise => { + await postScene( + catalog, + playerSprite, + opponentSprite, + viewportWidth, + defaultBattleScene({ + playerHits, + opponentHits, + playerPose: "attack", + opponentPose: "attack", + }), + onFrame, + ) + await sleep(EVOLUTION_BATTLE_PAUSE_MS) +} + +const animateOutcome = async ( + catalog: MonsterFrameCatalog, + playerSprite: string, + opponentSprite: string, + viewportWidth: number, + outcome: EvolutionBattleOutcome, + playerHits: number, + opponentHits: number, + onFrame: (artwork: string) => Promise, +): Promise => { + const ticks = Math.max(1, Math.ceil(EVOLUTION_BATTLE_OUTCOME_MS / EVOLUTION_BATTLE_TICK_MS)) + const playerWon = outcome === "player" + const winnerPose: BattleSpritePose = "happy" + const loserPose: BattleSpritePose = "injured" + + for (let tick = 0; tick < ticks; tick += 1) { + const loserBlink = tick % 2 === 1 ? "injured" : loserPose + await postScene( + catalog, + playerSprite, + opponentSprite, + viewportWidth, + defaultBattleScene({ + playerHits, + opponentHits, + playerPose: playerWon ? winnerPose : loserBlink, + opponentPose: playerWon ? loserBlink : winnerPose, + playerInjuredAlt: !playerWon && tick % 2 === 1, + opponentInjuredAlt: playerWon && tick % 2 === 1, + outcomeLabel: playerWon ? "WIN!" : "LOSE!", + }), + onFrame, + ) + if (tick < ticks - 1) await sleep(EVOLUTION_BATTLE_TICK_MS) + } +} + +export const runEvolutionBattleAnimation = async ( + catalog: MonsterFrameCatalog, + playerSprite: string, + opponentSprite: string, + viewportWidth: number, + outcome: EvolutionBattleOutcome, + onFrame: (artwork: string) => Promise, + random: () => number = Math.random, +): Promise => { + await animateBattleIntro(viewportWidth, onFrame) + + const shots = planEvolutionBattle(outcome, random) + let playerHits = 0 + let opponentHits = 0 + + for (let index = 0; index < shots.length; index += 1) { + const shot = shots[index] + if (shot === undefined) continue + + await animateShot(catalog, playerSprite, opponentSprite, viewportWidth, shot, playerHits, opponentHits, onFrame) + if (shot.hit) { + if (shot.shooter === "player") playerHits += 1 + else opponentHits += 1 + } + await animateImpact(catalog, playerSprite, opponentSprite, viewportWidth, shot, playerHits, opponentHits, onFrame) + + const battleOver = playerHits >= EVOLUTION_BATTLE_HITS_TO_WIN || opponentHits >= EVOLUTION_BATTLE_HITS_TO_WIN + const isLastShot = index === shots.length - 1 + if (!battleOver && !isLastShot) { + await animatePause(catalog, playerSprite, opponentSprite, viewportWidth, playerHits, opponentHits, onFrame) + } + if (battleOver) break + } + + await animateOutcome(catalog, playerSprite, opponentSprite, viewportWidth, outcome, playerHits, opponentHits, onFrame) + return outcome +} diff --git a/packages/digital-pet-animation/src/sessions/evolution-battle-session.ts b/packages/digital-pet-animation/src/sessions/evolution-battle-session.ts new file mode 100644 index 0000000..0262741 --- /dev/null +++ b/packages/digital-pet-animation/src/sessions/evolution-battle-session.ts @@ -0,0 +1,70 @@ +import type { ResolveEvolutionBattleOutcome } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { SidebarSnapshot } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import { + resolveEvolutionBattleForPartner, + type EvolutionBattleRepository, +} from "@jcendal/digital-pet-core/application/use-cases/resolve-evolution-battle.ts" +import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { MonsterFrameCatalog } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +import { runDefeatAnimation } from "../sequences/defeat-artwork.ts" +import { runEvolutionBattleAnimation, type EvolutionBattleOutcome } from "../sequences/evolution-battle-artwork.ts" +import { runEvolutionRevealSession } from "./evolution-reveal-session.ts" + +export type EvolutionBattleSessionDependencies = { + readonly frameCatalog: MonsterFrameCatalog + readonly digimonCatalog: DigimonCatalog + readonly repository: EvolutionBattleRepository + readonly random?: () => number + readonly onArtwork: (artwork: string) => Promise + readonly onResolved?: (result: ResolveEvolutionBattleOutcome) => Promise +} + +export const runEvolutionBattleSession = async ( + snapshot: SidebarSnapshot, + viewportWidth: number, + dependencies: EvolutionBattleSessionDependencies, +): Promise => { + if (snapshot.pendingEvolutionTargetId === null || snapshot.battleOpponentNodeId === null) return false + + const player = dependencies.digimonCatalog.byId.get(snapshot.currentNodeId) + const opponent = dependencies.digimonCatalog.byId.get(snapshot.battleOpponentNodeId) + if (player === undefined || opponent === undefined) return false + + const random = dependencies.random ?? Math.random + const outcome: EvolutionBattleOutcome = random() < 0.5 ? "player" : "opponent" + const postArtwork = async (artwork: string): Promise => dependencies.onArtwork(artwork) + + await runEvolutionBattleAnimation( + dependencies.frameCatalog, + player.sprite, + opponent.sprite, + viewportWidth, + outcome, + postArtwork, + random, + ) + + if (outcome === "player") { + await runEvolutionRevealSession( + { fromNodeId: snapshot.currentNodeId, toNodeId: snapshot.pendingEvolutionTargetId }, + viewportWidth, + { + frameCatalog: dependencies.frameCatalog, + digimonCatalog: dependencies.digimonCatalog, + onArtwork: postArtwork, + }, + ) + } else { + await runDefeatAnimation(dependencies.frameCatalog, player.sprite, viewportWidth, postArtwork) + } + + const result = resolveEvolutionBattleForPartner( + dependencies.repository, + outcome === "player", + dependencies.digimonCatalog.byId, + new Date().toISOString(), + ) + await dependencies.onResolved?.(result) + return true +} diff --git a/packages/digital-pet-animation/src/sessions/evolution-reveal-session.ts b/packages/digital-pet-animation/src/sessions/evolution-reveal-session.ts new file mode 100644 index 0000000..eb07f27 --- /dev/null +++ b/packages/digital-pet-animation/src/sessions/evolution-reveal-session.ts @@ -0,0 +1,26 @@ +import type { UsageEvolutionTransition } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { MonsterFrameCatalog } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +import { runEvolutionAnimation } from "../sequences/evolution-artwork.ts" + +export type EvolutionRevealSessionDependencies = { + readonly frameCatalog: MonsterFrameCatalog + readonly digimonCatalog: DigimonCatalog + readonly onArtwork: (artwork: string) => Promise +} + +export const runEvolutionRevealSession = async ( + evolution: UsageEvolutionTransition, + viewportWidth: number, + dependencies: EvolutionRevealSessionDependencies, +): Promise => { + const from = dependencies.digimonCatalog.byId.get(evolution.fromNodeId) + const to = dependencies.digimonCatalog.byId.get(evolution.toNodeId) + if (from === undefined || to === undefined) return false + + await runEvolutionAnimation(dependencies.frameCatalog, from.sprite, to.sprite, viewportWidth, async (artwork) => + dependencies.onArtwork(artwork), + ) + return true +} diff --git a/packages/digital-pet-animation/src/utils/assert-never.ts b/packages/digital-pet-animation/src/utils/assert-never.ts new file mode 100644 index 0000000..1e92484 --- /dev/null +++ b/packages/digital-pet-animation/src/utils/assert-never.ts @@ -0,0 +1,3 @@ +export const assertNever = (value: never): never => { + throw new Error(`Unexpected variant: ${JSON.stringify(value)}`) +} diff --git a/packages/digital-pet-animation/src/utils/random.ts b/packages/digital-pet-animation/src/utils/random.ts new file mode 100644 index 0000000..16dee68 --- /dev/null +++ b/packages/digital-pet-animation/src/utils/random.ts @@ -0,0 +1,6 @@ +/** Returns value in [0, 1) safe for index selection. */ +export const normalizedRandom = (random: () => number): number => { + const sample = random() + if (!Number.isFinite(sample) || sample <= 0) return 0 + return sample >= 1 ? 1 - Number.EPSILON : sample +} diff --git a/packages/digital-pet-animation/src/utils/sleep.ts b/packages/digital-pet-animation/src/utils/sleep.ts new file mode 100644 index 0000000..3df1811 --- /dev/null +++ b/packages/digital-pet-animation/src/utils/sleep.ts @@ -0,0 +1,4 @@ +export const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) diff --git a/packages/digital-pet-animation/tests/defeat-artwork.test.ts b/packages/digital-pet-animation/tests/defeat-artwork.test.ts new file mode 100644 index 0000000..617e0f9 --- /dev/null +++ b/packages/digital-pet-animation/tests/defeat-artwork.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" + +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +import { renderDefeatArtwork } from "../src/sequences/defeat-artwork.ts" + +describe("defeat artwork", () => { + test("Given normal and sad poses When rendering Then artwork alternates between them", () => { + const normal = renderDefeatArtwork(MONSTER_FRAME_CATALOG, "agumon", { sad: false, sadAlt: false }, 48) + const sad = renderDefeatArtwork(MONSTER_FRAME_CATALOG, "agumon", { sad: true, sadAlt: false }, 48) + + expect(normal).not.toEqual(sad) + }) + + test("Given defeat artwork When rendering Then the sprite is centered in the viewport", () => { + const artwork = renderDefeatArtwork(MONSTER_FRAME_CATALOG, "agumon", { sad: false, sadAlt: false }, 48) + const lines = artwork.split("\n").filter((line) => line.trim().length > 0) + + expect(lines.length).toBeGreaterThan(0) + expect(lines[0]?.startsWith(" ")).toBe(true) + }) +}) diff --git a/packages/digital-pet-animation/tests/evolution-artwork.test.ts b/packages/digital-pet-animation/tests/evolution-artwork.test.ts new file mode 100644 index 0000000..7b60ba8 --- /dev/null +++ b/packages/digital-pet-animation/tests/evolution-artwork.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test" + +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +import { renderEvolutionArtwork } from "../src/sequences/evolution-artwork.ts" + +describe("evolution artwork", () => { + test("Given glow phase When rendering Then the sprite is still the current partner", () => { + const from = renderEvolutionArtwork( + MONSTER_FRAME_CATALOG, + "agumon", + "greymon", + { phase: "glow", progress: 0.2 }, + 48, + ) + const unchanged = renderEvolutionArtwork( + MONSTER_FRAME_CATALOG, + "agumon", + "greymon", + { phase: "glow", progress: 0 }, + 48, + ) + + expect(from.length).toBeGreaterThan(0) + expect(from).not.toEqual(unchanged) + }) + + test("Given morph phase When progress advances Then artwork moves toward the evolved sprite", () => { + const early = renderEvolutionArtwork( + MONSTER_FRAME_CATALOG, + "agumon", + "greymon", + { phase: "morph", progress: 0.1 }, + 48, + ) + const late = renderEvolutionArtwork( + MONSTER_FRAME_CATALOG, + "agumon", + "greymon", + { phase: "morph", progress: 0.9 }, + 48, + ) + + expect(early).not.toEqual(late) + }) + + test("Given reveal phase When rendering Then only the evolved sprite is shown", () => { + const artwork = renderEvolutionArtwork( + MONSTER_FRAME_CATALOG, + "agumon", + "greymon", + { phase: "reveal", progress: 0.8 }, + 48, + ) + + expect(artwork.length).toBeGreaterThan(0) + expect(artwork).not.toContain("Child") + }) +}) diff --git a/packages/digital-pet-animation/tests/evolution-battle-artwork.test.ts b/packages/digital-pet-animation/tests/evolution-battle-artwork.test.ts new file mode 100644 index 0000000..11fab50 --- /dev/null +++ b/packages/digital-pet-animation/tests/evolution-battle-artwork.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "bun:test" + +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +import { EVOLUTION_BATTLE_HITS_TO_WIN } from "../src/constants/evolution-battle.ts" +import { + buildBattleIntroTextLines, + buildBattleScorePips, + buildBattleScoreRow, + defaultBattleScene, + FIREBALL_LINES, + planEvolutionBattle, + renderBattleIntroArtwork, + renderEvolutionBattleArtwork, +} from "../src/sequences/evolution-battle-artwork.ts" + +const countHits = (shots: readonly { readonly shooter: "player" | "opponent"; readonly hit: boolean }[]) => ({ + playerHits: shots.filter((shot) => shot.shooter === "player" && shot.hit).length, + opponentHits: shots.filter((shot) => shot.shooter === "opponent" && shot.hit).length, +}) + +describe("evolution battle artwork", () => { + test("Given battle intro When ticking Then FIGlet BATTLE banner blinks", () => { + const textLines = buildBattleIntroTextLines() + const on = renderBattleIntroArtwork(80, 1, 0) + const off = renderBattleIntroArtwork(80, 1, 1) + + expect(textLines).toHaveLength(6) + expect(textLines[0]).toContain("██████╗") + expect(on).toContain("███████╗") + expect(off).not.toContain("█") + expect(on).not.toEqual(off) + }) + + test("Given a fireball shot When rendering Then the flame uses block-style sprite art", () => { + const artwork = renderEvolutionBattleArtwork( + MONSTER_FRAME_CATALOG, + "agumon", + "gabumon", + defaultBattleScene({ shooter: "player", progress: 0.5 }), + 80, + ) + + for (const line of FIREBALL_LINES) { + for (const character of line.replaceAll(" ", "")) { + expect(artwork).toContain(character) + } + } + }) + + test("Given opponent shot When rendering Then the fireball faces the opposite direction", () => { + const playerShot = renderEvolutionBattleArtwork( + MONSTER_FRAME_CATALOG, + "agumon", + "gabumon", + defaultBattleScene({ shooter: "player", progress: 0.5 }), + 80, + ) + const opponentShot = renderEvolutionBattleArtwork( + MONSTER_FRAME_CATALOG, + "agumon", + "gabumon", + defaultBattleScene({ shooter: "opponent", progress: 0.5 }), + 80, + ) + + expect(playerShot).toContain("▄█▀▀█") + expect(opponentShot).toContain("█▀▀█▄") + expect(playerShot).not.toEqual(opponentShot) + }) + + test("Given player and opponent shots When rendering travel Then fireballs move from opposite sides", () => { + const playerStart = renderEvolutionBattleArtwork( + MONSTER_FRAME_CATALOG, + "agumon", + "gabumon", + defaultBattleScene({ shooter: "player", progress: 0 }), + 80, + ) + const playerEnd = renderEvolutionBattleArtwork( + MONSTER_FRAME_CATALOG, + "agumon", + "gabumon", + defaultBattleScene({ shooter: "player", progress: 1 }), + 80, + ) + const opponentStart = renderEvolutionBattleArtwork( + MONSTER_FRAME_CATALOG, + "agumon", + "gabumon", + defaultBattleScene({ shooter: "opponent", progress: 0 }), + 80, + ) + + expect(playerStart).not.toEqual(playerEnd) + expect(playerStart).not.toEqual(opponentStart) + expect(EVOLUTION_BATTLE_HITS_TO_WIN).toBe(3) + }) + + test("Given battle progress When rendering Then visual score pips and hit result are shown", () => { + const artwork = renderEvolutionBattleArtwork( + MONSTER_FRAME_CATALOG, + "agumon", + "gabumon", + defaultBattleScene({ playerHits: 2, opponentHits: 1, lastResult: "hit" }), + 80, + ) + + expect(buildBattleScorePips(2)).toBe("██░") + expect(buildBattleScorePips(1)).toBe("█░░") + expect(buildBattleScoreRow(2, 1)).toContain("██░") + expect(buildBattleScoreRow(2, 1)).toContain("█░░") + expect(artwork).toContain("HIT!") + }) + + test("Given planned player victory When battle ends Then player reaches three hits first", () => { + const shots = planEvolutionBattle("player", () => 0.42) + const { playerHits, opponentHits } = countHits(shots) + + expect(playerHits).toBe(EVOLUTION_BATTLE_HITS_TO_WIN) + expect(opponentHits).toBeLessThan(EVOLUTION_BATTLE_HITS_TO_WIN) + }) + + test("Given planned opponent victory When battle ends Then opponent reaches three hits first", () => { + const shots = planEvolutionBattle("opponent", () => 0.42) + const { playerHits, opponentHits } = countHits(shots) + + expect(opponentHits).toBe(EVOLUTION_BATTLE_HITS_TO_WIN) + expect(playerHits).toBeLessThan(EVOLUTION_BATTLE_HITS_TO_WIN) + }) +}) diff --git a/packages/digital-pet-animation/tests/evolution-battle-session.test.ts b/packages/digital-pet-animation/tests/evolution-battle-session.test.ts new file mode 100644 index 0000000..b8cf436 --- /dev/null +++ b/packages/digital-pet-animation/tests/evolution-battle-session.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, mock, test } from "bun:test" + +import type { ResolveEvolutionBattleOutcome } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { EvolutionBattleRepository } from "@jcendal/digital-pet-core/application/use-cases/resolve-evolution-battle.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +mock.module("../src/utils/sleep.ts", () => ({ + sleep: async () => {}, +})) + +import { runEvolutionBattleSession } from "../src/sessions/evolution-battle-session.ts" +import { battleSidebarSnapshot } from "./session-fixtures.ts" + +const pendingBattleSnapshot = battleSidebarSnapshot() + +const createRepository = (outcome: "won" | "lost"): EvolutionBattleRepository => ({ + getActivePartner: () => ({ + partnerId: "partner-1", + generation: 1, + currentNodeId: pendingBattleSnapshot.currentNodeId, + gauge: pendingBattleSnapshot.gauge, + isTerminal: false, + createdAt: "2026-01-01T00:00:00.000Z", + retiredAt: null, + pendingEvolutionTargetId: pendingBattleSnapshot.pendingEvolutionTargetId, + battleOpponentNodeId: pendingBattleSnapshot.battleOpponentNodeId, + }), + resolveEvolutionBattle: () => + outcome === "won" ? { kind: "won", evolution: { fromNodeId: "3-001", toNodeId: "4-017" } } : { kind: "lost" }, +}) + +describe("evolution battle session", () => { + test("Given a snapshot without a pending battle When running the session Then it is a no-op", async () => { + const frames: string[] = [] + const battled = await runEvolutionBattleSession( + { + currentNodeId: "0-001", + gauge: 0, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }, + 80, + { + frameCatalog: MONSTER_FRAME_CATALOG, + digimonCatalog: DIGIMON_CATALOG, + repository: { + getActivePartner: () => null, + resolveEvolutionBattle: () => ({ kind: "no_pending_battle" }), + }, + onArtwork: async (artwork) => { + frames.push(artwork) + }, + }, + ) + + expect(battled).toBeFalse() + expect(frames).toEqual([]) + }) + + test("Given a pending battle When the player wins Then it plays battle and reveal artwork and resolves won", async () => { + const frames: string[] = [] + let resolved: ResolveEvolutionBattleOutcome | undefined + const battled = await runEvolutionBattleSession(pendingBattleSnapshot, 80, { + frameCatalog: MONSTER_FRAME_CATALOG, + digimonCatalog: DIGIMON_CATALOG, + repository: createRepository("won"), + random: () => 0, + onArtwork: async (artwork) => { + frames.push(artwork) + }, + onResolved: async (result) => { + resolved = result + }, + }) + + expect(battled).toBeTrue() + expect(frames.length).toBeGreaterThan(0) + expect(resolved).toEqual({ kind: "won", evolution: { fromNodeId: "3-001", toNodeId: "4-017" } }) + }) + + test("Given a pending battle When the player loses Then it plays battle and defeat artwork and resolves lost", async () => { + const frames: string[] = [] + let resolved: ResolveEvolutionBattleOutcome | undefined + const battled = await runEvolutionBattleSession(pendingBattleSnapshot, 80, { + frameCatalog: MONSTER_FRAME_CATALOG, + digimonCatalog: DIGIMON_CATALOG, + repository: createRepository("lost"), + random: () => 0.99, + onArtwork: async (artwork) => { + frames.push(artwork) + }, + onResolved: async (result) => { + resolved = result + }, + }) + + expect(battled).toBeTrue() + expect(frames.length).toBeGreaterThan(0) + expect(resolved).toEqual({ kind: "lost" }) + }) + + test("Given unknown catalog nodes When running the session Then it is a no-op", async () => { + const frames: string[] = [] + const battled = await runEvolutionBattleSession( + { + ...pendingBattleSnapshot, + currentNodeId: "missing-partner", + battleOpponentNodeId: "missing-opponent", + }, + 80, + { + frameCatalog: MONSTER_FRAME_CATALOG, + digimonCatalog: DIGIMON_CATALOG, + repository: createRepository("won"), + onArtwork: async (artwork) => { + frames.push(artwork) + }, + }, + ) + + expect(battled).toBeFalse() + expect(frames).toEqual([]) + }) +}) diff --git a/packages/digital-pet-animation/tests/evolution-reveal-session.test.ts b/packages/digital-pet-animation/tests/evolution-reveal-session.test.ts new file mode 100644 index 0000000..7d15224 --- /dev/null +++ b/packages/digital-pet-animation/tests/evolution-reveal-session.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test" + +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +import { runEvolutionRevealSession } from "../src/sessions/evolution-reveal-session.ts" + +describe("evolution reveal session", () => { + test("Given a valid evolution transition When running the session Then it renders evolution artwork", async () => { + const frames: string[] = [] + const revealed = await runEvolutionRevealSession({ fromNodeId: "0-001", toNodeId: "1-001" }, 80, { + frameCatalog: MONSTER_FRAME_CATALOG, + digimonCatalog: DIGIMON_CATALOG, + onArtwork: async (artwork) => { + frames.push(artwork) + }, + }) + + expect(revealed).toBeTrue() + expect(frames.length).toBeGreaterThan(0) + }) + + test("Given an unknown node When running the session Then it is a no-op", async () => { + const frames: string[] = [] + const revealed = await runEvolutionRevealSession({ fromNodeId: "missing", toNodeId: "1-001" }, 80, { + frameCatalog: MONSTER_FRAME_CATALOG, + digimonCatalog: DIGIMON_CATALOG, + onArtwork: async (artwork) => { + frames.push(artwork) + }, + }) + + expect(revealed).toBeFalse() + expect(frames).toEqual([]) + }) +}) diff --git a/tests/monster-animation-actions.test.ts b/packages/digital-pet-animation/tests/monster-animation-actions.test.ts similarity index 84% rename from tests/monster-animation-actions.test.ts rename to packages/digital-pet-animation/tests/monster-animation-actions.test.ts index 01a72a7..b85d46a 100644 --- a/tests/monster-animation-actions.test.ts +++ b/packages/digital-pet-animation/tests/monster-animation-actions.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test" -import type { MonsterFrameName } from "../src/data/monster-frame-catalog.ts" -import { MonsterAnimationController } from "../src/tui/monster-animation.ts" +import type { MonsterFrameName } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +import { MonsterAnimationController } from "../src/idle/monster-animation.ts" import { catalogFor, controlledClock, @@ -174,6 +175,35 @@ describe("MonsterAnimationController actions and inactivity", () => { expect(moved).toMatchObject({ kind: "walking", offset: -2, facing: "left" }) }) + test("Given a walking partner with eat frames When feed is dispatched Then it plays the eat clip", () => { + const controller = new MonsterAnimationController( + catalogFor(new Map([["agumon", ["eat_1", "eat_2", "walk_1", "walk_2"]]])), + sequenceRandom(0).random, + controlledClock().nowMs, + ) + controller.dispatch(partnerChanged({ sprite: "agumon", isDigitama: false })) + controller.dispatch({ kind: "viewport_resized", width: 40 }) + + const eating = controller.dispatch({ kind: "feed" }) + const secondBite = controller.dispatch({ kind: "tick" }) + + expect(eating).toMatchObject({ kind: "action" }) + expect([frameContent(eating), frameContent(secondBite)]).toEqual(["agumon:eat_1", "agumon:eat_2"]) + }) + + test("Given Digitama mode When feed is dispatched Then the frame stays unchanged", () => { + const controller = new MonsterAnimationController( + catalogFor(new Map([["egg", ["eat_1", "eat_2", "walk_1", "walk_2"]]])), + sequenceRandom(0).random, + controlledClock().nowMs, + ) + controller.dispatch(partnerChanged({ sprite: "egg", isDigitama: true })) + + const fed = controller.dispatch({ kind: "feed" }) + + expect(fed.kind).toBe("digitama") + }) + test("Given Digitama mode When time, activity, viewport, and ticks advance Then clock and RNG sleep/action behavior remain unused", () => { const source = sequenceRandom(0) const clock = controlledClock() diff --git a/tests/monster-animation-test-utils.ts b/packages/digital-pet-animation/tests/monster-animation-test-utils.ts similarity index 90% rename from tests/monster-animation-test-utils.ts rename to packages/digital-pet-animation/tests/monster-animation-test-utils.ts index e34c0d2..a72166a 100644 --- a/tests/monster-animation-test-utils.ts +++ b/packages/digital-pet-animation/tests/monster-animation-test-utils.ts @@ -1,10 +1,15 @@ -import { MonsterFrame, MonsterFrameCatalog, type MonsterFrameName } from "../src/data/monster-frame-catalog.ts" +import { + MonsterFrame, + MonsterFrameCatalog, + type MonsterFrameName, +} from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + import { MonsterAnimationController, type MonsterAnimationEvent, type MonsterAnimationIdentity, type MonsterAnimationOutput, -} from "../src/tui/monster-animation.ts" +} from "../src/idle/monster-animation.ts" export const catalogFor = (sprites: ReadonlyMap): MonsterFrameCatalog => new MonsterFrameCatalog( diff --git a/tests/monster-animation-walking.test.ts b/packages/digital-pet-animation/tests/monster-animation-walking.test.ts similarity index 99% rename from tests/monster-animation-walking.test.ts rename to packages/digital-pet-animation/tests/monster-animation-walking.test.ts index 140ab83..8cec612 100644 --- a/tests/monster-animation-walking.test.ts +++ b/packages/digital-pet-animation/tests/monster-animation-walking.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" -import { MonsterAnimationController } from "../src/tui/monster-animation.ts" -import { resizeWalkingPolicy, type WalkingPolicyState } from "../src/tui/monster-walking-policy.ts" +import { MonsterAnimationController } from "../src/idle/monster-animation.ts" +import { resizeWalkingPolicy, type WalkingPolicyState } from "../src/idle/monster-walking-policy.ts" import { catalogFor, frameContent, diff --git a/packages/digital-pet-animation/tests/monster-animation.test.ts b/packages/digital-pet-animation/tests/monster-animation.test.ts new file mode 100644 index 0000000..3991d9c --- /dev/null +++ b/packages/digital-pet-animation/tests/monster-animation.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test" + +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +import { MONSTER_SLEEP_AFTER_MS } from "../src/constants/presentation-timing.ts" +import { MonsterAnimationController } from "../src/idle/monster-animation.ts" + +describe("monster animation", () => { + test("returns blank when partner is cleared", () => { + const controller = new MonsterAnimationController(MONSTER_FRAME_CATALOG) + controller.dispatch({ kind: "partner_changed", partner: { sprite: "agumon", isDigitama: false } }) + const output = controller.dispatch({ kind: "partner_changed", partner: undefined }) + expect(output.kind).toBe("blank") + expect(output.result.kind).toBe("blank") + }) + + test("dispatches partner_changed into a walking frame", () => { + const controller = new MonsterAnimationController(MONSTER_FRAME_CATALOG) + const output = controller.dispatch({ + kind: "partner_changed", + partner: { sprite: "agumon", isDigitama: false }, + }) + expect(output.kind).toBe("walking") + expect(output.result.kind).toBe("frame") + }) + + test("uses digitama state for stage zero partners", () => { + const controller = new MonsterAnimationController(MONSTER_FRAME_CATALOG) + const output = controller.dispatch({ + kind: "partner_changed", + partner: { sprite: "agumon", isDigitama: true }, + }) + expect(output.kind).toBe("digitama") + }) + + test("marks unknown sprites as unavailable", () => { + const controller = new MonsterAnimationController(MONSTER_FRAME_CATALOG) + const output = controller.dispatch({ + kind: "partner_changed", + partner: { sprite: "missing-sprite", isDigitama: false }, + }) + expect(output.kind).toBe("unavailable") + expect(output.result.kind).toBe("unavailable") + }) + + test("keeps state when the same partner is dispatched again", () => { + const controller = new MonsterAnimationController(MONSTER_FRAME_CATALOG) + const first = controller.dispatch({ + kind: "partner_changed", + partner: { sprite: "agumon", isDigitama: false }, + }) + const second = controller.dispatch({ + kind: "partner_changed", + partner: { sprite: "agumon", isDigitama: false }, + }) + expect(second).toEqual(first) + }) + + test("advances walking animation on tick", () => { + const controller = new MonsterAnimationController(MONSTER_FRAME_CATALOG, () => 0.5) + controller.dispatch({ kind: "partner_changed", partner: { sprite: "agumon", isDigitama: false } }) + const before = controller.output() + const after = controller.dispatch({ kind: "tick" }) + expect(after.kind).toBe("walking") + expect(after.result.kind).toBe("frame") + if (before.result.kind === "frame" && after.result.kind === "frame") { + expect(after !== before || after.offset !== before.offset || after.facing !== before.facing).toBe(true) + } + }) + + test("resizes walking policy on viewport changes", () => { + const controller = new MonsterAnimationController(MONSTER_FRAME_CATALOG, () => 0.5) + controller.dispatch({ kind: "partner_changed", partner: { sprite: "agumon", isDigitama: false } }) + const resized = controller.dispatch({ kind: "viewport_resized", width: 80 }) + expect(resized.kind).toBe("walking") + }) + + test("enters sleeping state after inactivity timeout", () => { + let now = 0 + const controller = new MonsterAnimationController( + MONSTER_FRAME_CATALOG, + () => 0.5, + () => now, + ) + controller.dispatch({ kind: "partner_changed", partner: { sprite: "agumon", isDigitama: false } }) + now = MONSTER_SLEEP_AFTER_MS + const output = controller.dispatch({ kind: "tick" }) + expect(output.kind).toBe("sleeping") + }) + + test("wakes from sleeping on activity", () => { + let now = 0 + const controller = new MonsterAnimationController( + MONSTER_FRAME_CATALOG, + () => 0.5, + () => now, + ) + controller.dispatch({ kind: "partner_changed", partner: { sprite: "agumon", isDigitama: false } }) + now = MONSTER_SLEEP_AFTER_MS + controller.dispatch({ kind: "tick" }) + now = MONSTER_SLEEP_AFTER_MS + 1 + const output = controller.dispatch({ kind: "activity" }) + expect(output.kind).toBe("walking") + }) + + test("setSprite helper maps to partner_changed", () => { + const controller = new MonsterAnimationController(MONSTER_FRAME_CATALOG) + controller.setSprite("agumon") + expect(controller.get().kind).toBe("frame") + controller.setSprite(undefined) + expect(controller.get().kind).toBe("blank") + }) +}) diff --git a/tests/monster-artwork-mirror.test.ts b/packages/digital-pet-animation/tests/monster-artwork-mirror.test.ts similarity index 95% rename from tests/monster-artwork-mirror.test.ts rename to packages/digital-pet-animation/tests/monster-artwork-mirror.test.ts index 7882f0e..26ea8dc 100644 --- a/tests/monster-artwork-mirror.test.ts +++ b/packages/digital-pet-animation/tests/monster-artwork-mirror.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from "bun:test" -import { MonsterFrame, MONSTER_FRAME_CATALOG } from "../src/data/monster-frame-catalog.ts" -import { MONSTER_FRAME_DATA } from "../src/data/monster-frame-data.ts" -import { mirrorMonsterFrame } from "../src/tui/monster-artwork-mirror.ts" +import { MonsterFrame, MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" +import { MONSTER_FRAME_DATA } from "@jcendal/digital-pet-core/data/monster-frame-data.ts" + +import { mirrorMonsterFrame } from "../src/idle/monster-artwork-mirror.ts" const AGUMON_WALK_1_MIRROR = " ▄▄▄▄▄ \n ▄▀ ▄▄ █▄ \n ▄▀ ██▄ ▀▀▄ \n █ ▀▀█▀ \n █ ▄ ▀▀██▀ \n █ ▄▄█ █▄█ \n █ ▀▄▄▄▄▀▀▄▄ \n█▄█▄█▄█ █▄▄█▄█ " diff --git a/packages/digital-pet-animation/tests/session-fixtures.ts b/packages/digital-pet-animation/tests/session-fixtures.ts new file mode 100644 index 0000000..b133880 --- /dev/null +++ b/packages/digital-pet-animation/tests/session-fixtures.ts @@ -0,0 +1,12 @@ +import type { SidebarSnapshot } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" + +export const battleSidebarSnapshot = (): SidebarSnapshot => ({ + currentNodeId: "3-001", + gauge: 50, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 500, + pendingEvolutionTargetId: "4-017", + battleOpponentNodeId: "3-051", +}) diff --git a/packages/digital-pet-animation/tsconfig.json b/packages/digital-pet-animation/tsconfig.json new file mode 100644 index 0000000..902f38d --- /dev/null +++ b/packages/digital-pet-animation/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "target": "ESNext", + "lib": ["ESNext"], + "types": ["bun"], + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src", "tests"] +} diff --git a/packages/digital-pet-core/package.json b/packages/digital-pet-core/package.json new file mode 100644 index 0000000..a19961a --- /dev/null +++ b/packages/digital-pet-core/package.json @@ -0,0 +1,26 @@ +{ + "name": "@jcendal/digital-pet-core", + "version": "0.2.0-dev.0", + "private": true, + "type": "module", + "engines": { + "node": "24.11.0", + "bun": "1.3.5" + }, + "exports": { + "./domain/*": "./src/domain/*", + "./application/*": "./src/application/*", + "./data/*": "./src/data/*", + "./config/*": "./src/config/*", + "./view-models/*": "./src/view-models/*", + "./ports/*": "./src/ports/*", + "./adapters/*": "./src/adapters/*" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "typescript": "5.9.3" + } +} diff --git a/src/adapters/sqlite/app-data-path.ts b/packages/digital-pet-core/src/adapters/sqlite/app-data-path.ts similarity index 59% rename from src/adapters/sqlite/app-data-path.ts rename to packages/digital-pet-core/src/adapters/sqlite/app-data-path.ts index bef3d9d..eab43e1 100644 --- a/src/adapters/sqlite/app-data-path.ts +++ b/packages/digital-pet-core/src/adapters/sqlite/app-data-path.ts @@ -1,7 +1,9 @@ +import { existsSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" -const APP_DIRECTORY_NAME = "opencode-vpet" +const APP_DIRECTORY_NAME = "opencode-digital-pet" +const LEGACY_APP_DIRECTORY_NAME = "opencode-vpet" const DATABASE_FILE_NAME = "pet.db" export type HostPathOptions = { @@ -22,7 +24,16 @@ const resolveDefaultAppDataRoot = (): string => { } export const resolveHostDataDirectory = (options: HostPathOptions = {}): string => { - return join(options.appDataRoot ?? resolveDefaultAppDataRoot(), APP_DIRECTORY_NAME) + const root = options.appDataRoot ?? resolveDefaultAppDataRoot() + const currentDirectory = join(root, APP_DIRECTORY_NAME) + const legacyDirectory = join(root, LEGACY_APP_DIRECTORY_NAME) + if ( + !existsSync(join(currentDirectory, DATABASE_FILE_NAME)) && + existsSync(join(legacyDirectory, DATABASE_FILE_NAME)) + ) { + return legacyDirectory + } + return currentDirectory } export const resolveHostDatabasePath = (options: HostPathOptions = {}): string => { diff --git a/src/adapters/sqlite/sqlite-vpet-schema.ts b/packages/digital-pet-core/src/adapters/sqlite/sqlite-digital-pet-schema.ts similarity index 85% rename from src/adapters/sqlite/sqlite-vpet-schema.ts rename to packages/digital-pet-core/src/adapters/sqlite/sqlite-digital-pet-schema.ts index 3b79c48..42a4893 100644 --- a/src/adapters/sqlite/sqlite-vpet-schema.ts +++ b/packages/digital-pet-core/src/adapters/sqlite/sqlite-digital-pet-schema.ts @@ -1,5 +1,9 @@ import type { Partner } from "../../domain/partner.ts" -import type { PersistedPartnerEvent, PersistedPartnerEventKind, UsageReceiptRecord } from "./sqlite-vpet-types.ts" +import type { + PersistedPartnerEvent, + PersistedPartnerEventKind, + UsageReceiptRecord, +} from "./sqlite-digital-pet-types.ts" export type PersistedPartnerRow = { readonly partner_id: string @@ -7,6 +11,8 @@ export type PersistedPartnerRow = { readonly current_node_id: string readonly gauge: number readonly is_terminal: number + readonly pending_evolution_target_id: string | null + readonly battle_opponent_node_id: string | null readonly created_at: string readonly retired_at: string | null } @@ -40,15 +46,21 @@ export type MigrationRow = { readonly version: number } -export const ACTIVE_PARTNER_SELECT = ` - SELECT +const PARTNER_COLUMNS = ` partner_id, generation, current_node_id, gauge, is_terminal, + pending_evolution_target_id, + battle_opponent_node_id, created_at, retired_at +` + +export const ACTIVE_PARTNER_SELECT = ` + SELECT + ${PARTNER_COLUMNS} FROM partners WHERE retired_at IS NULL LIMIT 1 @@ -56,39 +68,21 @@ export const ACTIVE_PARTNER_SELECT = ` export const PARTNERS_SELECT = ` SELECT - partner_id, - generation, - current_node_id, - gauge, - is_terminal, - created_at, - retired_at + ${PARTNER_COLUMNS} FROM partners ORDER BY generation ASC, created_at ASC, partner_id ASC ` export const ARCHIVE_PARTNERS_SELECT = ` SELECT - partner_id, - generation, - current_node_id, - gauge, - is_terminal, - created_at, - retired_at + ${PARTNER_COLUMNS} FROM partners ORDER BY partner_id ASC ` export const PARTNER_BY_GENERATION_SELECT = ` SELECT - partner_id, - generation, - current_node_id, - gauge, - is_terminal, - created_at, - retired_at + ${PARTNER_COLUMNS} FROM partners WHERE generation = ? LIMIT 1 @@ -138,6 +132,8 @@ export const toPartner = (row: PersistedPartnerRow): Partner => { currentNodeId: row.current_node_id, gauge: row.gauge, isTerminal: row.is_terminal === 1, + pendingEvolutionTargetId: row.pending_evolution_target_id, + battleOpponentNodeId: row.battle_opponent_node_id, createdAt: row.created_at, retiredAt: row.retired_at, } diff --git a/src/adapters/sqlite/sqlite-vpet-types.ts b/packages/digital-pet-core/src/adapters/sqlite/sqlite-digital-pet-types.ts similarity index 79% rename from src/adapters/sqlite/sqlite-vpet-types.ts rename to packages/digital-pet-core/src/adapters/sqlite/sqlite-digital-pet-types.ts index 615b170..3d4e7c6 100644 --- a/src/adapters/sqlite/sqlite-vpet-types.ts +++ b/packages/digital-pet-core/src/adapters/sqlite/sqlite-digital-pet-types.ts @@ -1,9 +1,13 @@ import type { Partner, PartnerProgression } from "../../domain/partner.ts" import type { SpawnPartnerInput } from "../../application/models/spawn-partner.ts" -import type { ApplyUsageReceiptOutcome, UsageReceiptMetadata } from "../../application/models/usage.ts" +import type { + ApplyUsageReceiptOutcome, + ResolveEvolutionBattleOutcome, + UsageReceiptMetadata, +} from "../../application/models/usage.ts" import type { PartnerLifecycle } from "../../application/ports/partner-lifecycle.ts" import type { UsageLedger } from "../../application/ports/usage-ledger.ts" -import type { VpetControl } from "../../application/ports/vpet-control.ts" +import type { DigitalPetControl } from "../../application/ports/digital-pet-control.ts" export type PersistedPartner = Partner @@ -35,9 +39,9 @@ export type TrainerState = { export type PersistedPartnerSummary = Partner -export type SqliteVpetWriteStore = PartnerLifecycle & +export type SqliteDigitalPetWriteStore = PartnerLifecycle & UsageLedger & - VpetControl & { + DigitalPetControl & { close: () => Promise readonly databasePath: string getAppliedMigrations: () => readonly number[] @@ -47,6 +51,7 @@ export type SqliteVpetWriteStore = PartnerLifecycle & getPartnerByGeneration: (generation: number) => PersistedPartnerSummary | null listPartnerEvents: (partnerId: string) => readonly PersistedPartnerEvent[] listUsageReceipts: () => readonly UsageReceiptRecord[] + resolveEvolutionBattle: (nextState: PartnerProgression, createdAt: string) => ResolveEvolutionBattleOutcome } export type { ApplyUsageReceiptOutcome, PartnerProgression, SpawnPartnerInput, UsageReceiptMetadata } diff --git a/src/adapters/sqlite/sqlite-vpet-write-store.ts b/packages/digital-pet-core/src/adapters/sqlite/sqlite-digital-pet-write-store.ts similarity index 69% rename from src/adapters/sqlite/sqlite-vpet-write-store.ts rename to packages/digital-pet-core/src/adapters/sqlite/sqlite-digital-pet-write-store.ts index a8504fb..234d9b6 100644 --- a/src/adapters/sqlite/sqlite-vpet-write-store.ts +++ b/packages/digital-pet-core/src/adapters/sqlite/sqlite-digital-pet-write-store.ts @@ -1,8 +1,7 @@ import type { SpawnPartnerInput } from "../../application/models/spawn-partner.ts" -import type { UsageReceiptMetadata } from "../../application/models/usage.ts" +import type { ResolveEvolutionBattleOutcome, UsageReceiptMetadata } from "../../application/models/usage.ts" import type { Partner, PartnerProgression } from "../../domain/partner.ts" -import { resolveHostDatabasePath, type HostPathOptions } from "./app-data-path.ts" -import { createExecutor, openWritableDatabase } from "./bun-sqlite-driver.ts" +import type { SqliteExecutor } from "../../ports/sqlite-executor.ts" import { runMigrations } from "./sqlite-migrations.ts" import { ACTIVE_PARTNER_SELECT, @@ -17,10 +16,14 @@ import { toPartner, toPersistedPartnerEvent, toUsageReceiptRecord, -} from "./sqlite-vpet-schema.ts" -import type { SqliteVpetWriteStore } from "./sqlite-vpet-types.ts" +} from "./sqlite-digital-pet-schema.ts" +import type { SqliteDigitalPetWriteStore } from "./sqlite-digital-pet-types.ts" -export type CreateSqliteVpetRepositoryOptions = HostPathOptions & { readonly databasePath?: string } +export type CreateSqliteDigitalPetWriteStoreOptions = { + readonly databasePath: string + readonly executor: SqliteExecutor + readonly close: () => Promise | void +} type ControlStateRow = { readonly frozen: number @@ -31,21 +34,16 @@ type ReceiptLedgerRow = { readonly receipt_key: string } -export { resolveHostDatabasePath } -export type { PersistedPartnerEvent, SqliteVpetWriteStore } from "./sqlite-vpet-types.ts" - -export const createSqliteVpetRepository = async ( - options: CreateSqliteVpetRepositoryOptions = {}, -): Promise => { - const databasePath = options.databasePath ?? resolveHostDatabasePath(options) - const database = openWritableDatabase(databasePath) - const executor = createExecutor(database) +export const createSqliteDigitalPetWriteStore = ( + options: CreateSqliteDigitalPetWriteStoreOptions, +): SqliteDigitalPetWriteStore => { + const { databasePath, executor, close } = options const appliedMigrations = runMigrations(executor) const store = { databasePath, async close() { - database.close() + await close() }, getAppliedMigrations() { return appliedMigrations @@ -71,7 +69,7 @@ export const createSqliteVpetRepository = async ( const controlState = executor.get( "SELECT frozen, cheat_node_id FROM vpet_control_state WHERE control_id = 1", ) - if (controlState === null) throw new Error("VPet control state row is missing") + if (controlState === null) throw new Error("Digital Pet control state row is missing") if (controlState.frozen === 1) return { kind: "already_frozen" } as const executor.run("UPDATE vpet_control_state SET frozen = 1 WHERE control_id = 1") return { kind: "frozen" } as const @@ -82,7 +80,7 @@ export const createSqliteVpetRepository = async ( const controlState = executor.get( "SELECT frozen, cheat_node_id FROM vpet_control_state WHERE control_id = 1", ) - if (controlState === null) throw new Error("VPet control state row is missing") + if (controlState === null) throw new Error("Digital Pet control state row is missing") if (controlState.frozen === 0) return { kind: "already_unfrozen" } as const executor.run("UPDATE vpet_control_state SET frozen = 0 WHERE control_id = 1") return { kind: "unfrozen" } as const @@ -93,7 +91,7 @@ export const createSqliteVpetRepository = async ( const controlState = executor.get( "SELECT frozen, cheat_node_id FROM vpet_control_state WHERE control_id = 1", ) - if (controlState === null) throw new Error("VPet control state row is missing") + if (controlState === null) throw new Error("Digital Pet control state row is missing") if (controlState.cheat_node_id === cheatNodeId) return { kind: "already_set", cheatNodeId } as const executor.run("UPDATE vpet_control_state SET cheat_node_id = ? WHERE control_id = 1", [cheatNodeId]) return { kind: "set", cheatNodeId } as const @@ -127,7 +125,7 @@ export const createSqliteVpetRepository = async ( if (generationRow === null) throw new Error("Generation allocation failed") const partnerId = `partner-${generationRow.generation}` executor.run( - "INSERT INTO partners (partner_id, generation, current_node_id, gauge, is_terminal, created_at, retired_at) VALUES (?, ?, ?, ?, ?, ?, NULL)", + "INSERT INTO partners (partner_id, generation, current_node_id, gauge, is_terminal, pending_evolution_target_id, battle_opponent_node_id, created_at, retired_at) VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, NULL)", [partnerId, generationRow.generation, input.currentNodeId, input.gauge, input.isTerminal, input.createdAt], ) executor.run( @@ -141,7 +139,14 @@ export const createSqliteVpetRepository = async ( input.createdAt, ], ) - return { partnerId, generation: generationRow.generation, ...input, retiredAt: null } + return { + partnerId, + generation: generationRow.generation, + ...input, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + retiredAt: null, + } }) }, listPartnerEvents(partnerId: string) { @@ -161,7 +166,7 @@ export const createSqliteVpetRepository = async ( const controlState = executor.get( "SELECT frozen, cheat_node_id FROM vpet_control_state WHERE control_id = 1", ) - if (controlState === null) throw new Error("VPet control state row is missing") + if (controlState === null) throw new Error("Digital Pet control state row is missing") if (controlState.cheat_node_id !== null) { executor.run( "INSERT INTO vpet_control_receipts (receipt_key, mode, token_delta, cost, created_at) VALUES (?, 'cheat', ?, ?, ?)", @@ -183,12 +188,17 @@ export const createSqliteVpetRepository = async ( if (activePartnerRow === null) return { kind: "no_active_partner" } as const const activePartner = toPartner(activePartnerRow) const nextState = evolve(activePartner) - executor.run("UPDATE partners SET current_node_id = ?, gauge = ?, is_terminal = ? WHERE partner_id = ?", [ - nextState.currentNodeId, - nextState.gauge, - nextState.isTerminal, - activePartner.partnerId, - ]) + executor.run( + "UPDATE partners SET current_node_id = ?, gauge = ?, is_terminal = ?, pending_evolution_target_id = ?, battle_opponent_node_id = ? WHERE partner_id = ?", + [ + nextState.currentNodeId, + nextState.gauge, + nextState.isTerminal, + nextState.pendingEvolutionTargetId, + nextState.battleOpponentNodeId, + activePartner.partnerId, + ], + ) executor.run( "INSERT INTO partner_events (event_id, partner_id, kind, current_node_id, gauge, is_terminal, token_delta, receipt_key, created_at) VALUES (?, ?, 'usage_applied', ?, ?, ?, ?, ?, ?)", [ @@ -222,7 +232,50 @@ export const createSqliteVpetRepository = async ( listUsageReceipts() { return executor.all(USAGE_RECEIPTS_SELECT).map(toUsageReceiptRecord) }, - } satisfies SqliteVpetWriteStore + resolveEvolutionBattle(nextState: PartnerProgression, createdAt: string): ResolveEvolutionBattleOutcome { + return executor.transaction(() => { + const activePartnerRow = executor.get(ACTIVE_PARTNER_SELECT) + if (activePartnerRow === null) return { kind: "no_pending_battle" } as const + + const activePartner = toPartner(activePartnerRow) + if (activePartner.pendingEvolutionTargetId === null || activePartner.battleOpponentNodeId === null) { + return { kind: "no_pending_battle" } as const + } + + const fromNodeId = activePartner.currentNodeId + executor.run( + "UPDATE partners SET current_node_id = ?, gauge = ?, is_terminal = ?, pending_evolution_target_id = ?, battle_opponent_node_id = ? WHERE partner_id = ?", + [ + nextState.currentNodeId, + nextState.gauge, + nextState.isTerminal, + nextState.pendingEvolutionTargetId, + nextState.battleOpponentNodeId, + activePartner.partnerId, + ], + ) + executor.run( + "INSERT INTO partner_events (event_id, partner_id, kind, current_node_id, gauge, is_terminal, token_delta, receipt_key, created_at) VALUES (?, ?, 'usage_applied', ?, ?, ?, NULL, NULL, ?)", + [ + `event-battle-${createdAt}`, + activePartner.partnerId, + nextState.currentNodeId, + nextState.gauge, + nextState.isTerminal, + createdAt, + ], + ) + + if (nextState.currentNodeId !== fromNodeId) { + return { + kind: "won", + evolution: { fromNodeId, toNodeId: nextState.currentNodeId }, + } as const + } + return { kind: "lost" } as const + }) + }, + } satisfies SqliteDigitalPetWriteStore return store } diff --git a/src/adapters/sqlite/sqlite-migrations.ts b/packages/digital-pet-core/src/adapters/sqlite/sqlite-migrations.ts similarity index 92% rename from src/adapters/sqlite/sqlite-migrations.ts rename to packages/digital-pet-core/src/adapters/sqlite/sqlite-migrations.ts index cfb2312..47878cb 100644 --- a/src/adapters/sqlite/sqlite-migrations.ts +++ b/packages/digital-pet-core/src/adapters/sqlite/sqlite-migrations.ts @@ -1,10 +1,6 @@ -type QueryValue = string | number | null +import type { SqliteExecutor } from "../../ports/sqlite-executor.ts" -type SqliteMigrationExecutor = { - run: (sql: string, params?: readonly QueryValue[]) => void - all: >(sql: string, params?: readonly QueryValue[]) => readonly TRow[] - transaction: (operation: () => TReturn) => TReturn -} +type SqliteMigrationExecutor = Pick type Migration = { readonly version: number @@ -110,6 +106,13 @@ const MIGRATIONS = [ `, ], }, + { + version: 4, + sql: [ + "ALTER TABLE partners ADD COLUMN pending_evolution_target_id TEXT", + "ALTER TABLE partners ADD COLUMN battle_opponent_node_id TEXT", + ], + }, ] as const satisfies readonly Migration[] type MigrationRow = { diff --git a/src/application/models/vpet-archive.ts b/packages/digital-pet-core/src/application/models/digital-pet-archive.ts similarity index 54% rename from src/application/models/vpet-archive.ts rename to packages/digital-pet-core/src/application/models/digital-pet-archive.ts index 8eb00db..23d0ce9 100644 --- a/src/application/models/vpet-archive.ts +++ b/packages/digital-pet-core/src/application/models/digital-pet-archive.ts @@ -1,18 +1,18 @@ -export type VpetArchiveEvent = { +export type DigitalPetArchiveEvent = { readonly eventId: string readonly currentNodeId: string readonly createdAt: string } -export type VpetArchivePartner = { +export type DigitalPetArchivePartner = { readonly partnerId: string readonly generation: number readonly createdAt: string readonly retiredAt: string | null - readonly events: readonly VpetArchiveEvent[] + readonly events: readonly DigitalPetArchiveEvent[] } -export type VpetArchiveResult = - | { readonly kind: "available"; readonly partners: readonly VpetArchivePartner[] } +export type DigitalPetArchiveResult = + | { readonly kind: "available"; readonly partners: readonly DigitalPetArchivePartner[] } | { readonly kind: "empty" } | { readonly kind: "unavailable"; readonly message: string } diff --git a/packages/digital-pet-core/src/application/models/digital-pet-control.ts b/packages/digital-pet-core/src/application/models/digital-pet-control.ts new file mode 100644 index 0000000..087ba3d --- /dev/null +++ b/packages/digital-pet-core/src/application/models/digital-pet-control.ts @@ -0,0 +1,7 @@ +export type FreezeDigitalPetOutcome = { readonly kind: "frozen" } | { readonly kind: "already_frozen" } + +export type UnfreezeDigitalPetOutcome = { readonly kind: "unfrozen" } | { readonly kind: "already_unfrozen" } + +export type SetCheatNodeOutcome = + | { readonly kind: "set"; readonly cheatNodeId: string } + | { readonly kind: "already_set"; readonly cheatNodeId: string } diff --git a/src/application/models/sidebar-card-inputs.ts b/packages/digital-pet-core/src/application/models/sidebar-card-inputs.ts similarity index 89% rename from src/application/models/sidebar-card-inputs.ts rename to packages/digital-pet-core/src/application/models/sidebar-card-inputs.ts index 57c1c11..cc50a18 100644 --- a/src/application/models/sidebar-card-inputs.ts +++ b/packages/digital-pet-core/src/application/models/sidebar-card-inputs.ts @@ -10,4 +10,5 @@ export type SidebarCardInputs = readonly frozen: boolean readonly isSetOverride: boolean readonly trainerTotalTokens: number + readonly evolutionBattlePending: boolean } diff --git a/src/application/models/spawn-partner.ts b/packages/digital-pet-core/src/application/models/spawn-partner.ts similarity index 100% rename from src/application/models/spawn-partner.ts rename to packages/digital-pet-core/src/application/models/spawn-partner.ts diff --git a/src/application/models/usage.ts b/packages/digital-pet-core/src/application/models/usage.ts similarity index 67% rename from src/application/models/usage.ts rename to packages/digital-pet-core/src/application/models/usage.ts index e881ce5..4779c6b 100644 --- a/src/application/models/usage.ts +++ b/packages/digital-pet-core/src/application/models/usage.ts @@ -16,11 +16,22 @@ export type UsageEvolutionTransition = { readonly toNodeId: string } +export type EvolutionBattlePending = { + readonly opponentNodeId: string + readonly targetNodeId: string +} + export type UsageProcessingResult = | { readonly kind: "applied" readonly receiptKey: string readonly evolution?: UsageEvolutionTransition + readonly evolutionBattlePending?: EvolutionBattlePending } | { readonly kind: "duplicate"; readonly receiptKey: string } | { readonly kind: "no_active_partner"; readonly receiptKey: string } + +export type ResolveEvolutionBattleOutcome = + | { readonly kind: "won"; readonly evolution: UsageEvolutionTransition } + | { readonly kind: "lost" } + | { readonly kind: "no_pending_battle" } diff --git a/packages/digital-pet-core/src/application/ports/digital-pet-archive.ts b/packages/digital-pet-core/src/application/ports/digital-pet-archive.ts new file mode 100644 index 0000000..a50c84a --- /dev/null +++ b/packages/digital-pet-core/src/application/ports/digital-pet-archive.ts @@ -0,0 +1,5 @@ +import type { DigitalPetArchiveResult } from "../models/digital-pet-archive.ts" + +export type DigitalPetArchiveReader = { + getArchive: () => DigitalPetArchiveResult +} diff --git a/packages/digital-pet-core/src/application/ports/digital-pet-control.ts b/packages/digital-pet-core/src/application/ports/digital-pet-control.ts new file mode 100644 index 0000000..dcc1592 --- /dev/null +++ b/packages/digital-pet-core/src/application/ports/digital-pet-control.ts @@ -0,0 +1,11 @@ +import type { + FreezeDigitalPetOutcome, + SetCheatNodeOutcome, + UnfreezeDigitalPetOutcome, +} from "../models/digital-pet-control.ts" + +export type DigitalPetControl = { + freeze: () => FreezeDigitalPetOutcome + unfreeze: () => UnfreezeDigitalPetOutcome + setCheatNode: (validatedNodeId: string) => SetCheatNodeOutcome +} diff --git a/src/application/ports/partner-lifecycle.ts b/packages/digital-pet-core/src/application/ports/partner-lifecycle.ts similarity index 100% rename from src/application/ports/partner-lifecycle.ts rename to packages/digital-pet-core/src/application/ports/partner-lifecycle.ts diff --git a/src/application/ports/sidebar-snapshot.ts b/packages/digital-pet-core/src/application/ports/sidebar-snapshot.ts similarity index 76% rename from src/application/ports/sidebar-snapshot.ts rename to packages/digital-pet-core/src/application/ports/sidebar-snapshot.ts index 0bc7f77..dc7f581 100644 --- a/src/application/ports/sidebar-snapshot.ts +++ b/packages/digital-pet-core/src/application/ports/sidebar-snapshot.ts @@ -5,6 +5,8 @@ export type SidebarSnapshot = { readonly frozen: boolean readonly isSetOverride: boolean readonly trainerTotalTokens: number + readonly pendingEvolutionTargetId: string | null + readonly battleOpponentNodeId: string | null } export type SidebarSnapshotReader = { diff --git a/src/application/ports/usage-ledger.ts b/packages/digital-pet-core/src/application/ports/usage-ledger.ts similarity index 100% rename from src/application/ports/usage-ledger.ts rename to packages/digital-pet-core/src/application/ports/usage-ledger.ts diff --git a/packages/digital-pet-core/src/application/use-cases/freeze-digital-pet.ts b/packages/digital-pet-core/src/application/use-cases/freeze-digital-pet.ts new file mode 100644 index 0000000..17bd015 --- /dev/null +++ b/packages/digital-pet-core/src/application/use-cases/freeze-digital-pet.ts @@ -0,0 +1,5 @@ +import type { FreezeDigitalPetOutcome } from "../models/digital-pet-control.ts" +import type { DigitalPetControl } from "../ports/digital-pet-control.ts" + +export const freezeDigitalPet = (control: Pick): FreezeDigitalPetOutcome => + control.freeze() diff --git a/src/application/use-cases/get-sidebar-card-inputs.ts b/packages/digital-pet-core/src/application/use-cases/get-sidebar-card-inputs.ts similarity index 87% rename from src/application/use-cases/get-sidebar-card-inputs.ts rename to packages/digital-pet-core/src/application/use-cases/get-sidebar-card-inputs.ts index d1771c4..3e92724 100644 --- a/src/application/use-cases/get-sidebar-card-inputs.ts +++ b/packages/digital-pet-core/src/application/use-cases/get-sidebar-card-inputs.ts @@ -19,5 +19,6 @@ export const getSidebarCardInputs = (reader: SidebarSnapshotReader, catalog: Dig frozen: snapshot.frozen, isSetOverride: snapshot.isSetOverride, trainerTotalTokens: snapshot.trainerTotalTokens, + evolutionBattlePending: snapshot.pendingEvolutionTargetId !== null && snapshot.battleOpponentNodeId !== null, } } diff --git a/src/application/use-cases/reconcile-usage.ts b/packages/digital-pet-core/src/application/use-cases/reconcile-usage.ts similarity index 83% rename from src/application/use-cases/reconcile-usage.ts rename to packages/digital-pet-core/src/application/use-cases/reconcile-usage.ts index bb1a404..5c8196b 100644 --- a/src/application/use-cases/reconcile-usage.ts +++ b/packages/digital-pet-core/src/application/use-cases/reconcile-usage.ts @@ -8,6 +8,7 @@ export type ReconcileUsageInput = { readonly usages: readonly CompletedUsage[] readonly ledger: UsageLedger readonly digimonById: ReadonlyMap + readonly catalogNodes: readonly DigimonNode[] readonly selector: EvolutionSelector readonly thresholds: StageThresholds } @@ -16,12 +17,13 @@ export const reconcileUsage = ({ usages, ledger, digimonById, + catalogNodes, selector, thresholds, }: ReconcileUsageInput): readonly UsageProcessingResult[] => { const outcomes: UsageProcessingResult[] = [] for (const usage of usages) { - outcomes.push(recordUsage({ usage, ledger, digimonById, selector, thresholds })) + outcomes.push(recordUsage({ usage, ledger, digimonById, catalogNodes, selector, thresholds })) } return outcomes } diff --git a/src/application/use-cases/record-usage.ts b/packages/digital-pet-core/src/application/use-cases/record-usage.ts similarity index 58% rename from src/application/use-cases/record-usage.ts rename to packages/digital-pet-core/src/application/use-cases/record-usage.ts index 718e073..ef5d8fd 100644 --- a/src/application/use-cases/record-usage.ts +++ b/packages/digital-pet-core/src/application/use-cases/record-usage.ts @@ -9,6 +9,7 @@ export type RecordUsageInput = { readonly usage: CompletedUsage readonly ledger: UsageLedger readonly digimonById: ReadonlyMap + readonly catalogNodes: readonly DigimonNode[] readonly selector: EvolutionSelector readonly thresholds: StageThresholds } @@ -17,32 +18,65 @@ export const recordUsage = ({ usage, ledger, digimonById, + catalogNodes, selector, thresholds, }: RecordUsageInput): UsageProcessingResult => { let evolution: UsageEvolutionTransition | undefined + let evolutionBattlePending: { readonly opponentNodeId: string; readonly targetNodeId: string } | undefined + const outcome = ledger.applyUsageReceipt(usage, (partner) => { const current = digimonById.get(partner.currentNodeId) if (current === undefined) throw new Error(`Persisted partner node ${partner.currentNodeId} is missing from the catalog`) + const hadPendingBattle = partner.pendingEvolutionTargetId != null && partner.battleOpponentNodeId != null + const next = applyTokenProgress( - { current, gauge: partner.gauge, isTerminal: partner.isTerminal }, + { + current, + gauge: partner.gauge, + isTerminal: partner.isTerminal, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, + }, usage.tokenDelta, selector, digimonById, + catalogNodes, thresholds, ) + if ( + !hadPendingBattle && + next.pendingEvolutionTargetId !== null && + next.battleOpponentNodeId !== null && + next.current.id === current.id + ) { + evolutionBattlePending = { + opponentNodeId: next.battleOpponentNodeId, + targetNodeId: next.pendingEvolutionTargetId, + } + } + if (next.current.id !== current.id) { evolution = { fromNodeId: current.id, toNodeId: next.current.id } } - return { currentNodeId: next.current.id, gauge: next.gauge, isTerminal: next.isTerminal } + return { + currentNodeId: next.current.id, + gauge: next.gauge, + isTerminal: next.isTerminal, + pendingEvolutionTargetId: next.pendingEvolutionTargetId, + battleOpponentNodeId: next.battleOpponentNodeId, + } }) switch (outcome.kind) { case "applied": + if (evolutionBattlePending !== undefined) { + return { kind: "applied", receiptKey: usage.receiptKey, evolutionBattlePending } + } return evolution === undefined ? { kind: "applied", receiptKey: usage.receiptKey } : { kind: "applied", receiptKey: usage.receiptKey, evolution } diff --git a/packages/digital-pet-core/src/application/use-cases/resolve-evolution-battle.ts b/packages/digital-pet-core/src/application/use-cases/resolve-evolution-battle.ts new file mode 100644 index 0000000..a3a163c --- /dev/null +++ b/packages/digital-pet-core/src/application/use-cases/resolve-evolution-battle.ts @@ -0,0 +1,54 @@ +import type { DigimonNode } from "../../domain/digimon-node.ts" +import { resolveEvolutionBattle } from "../../domain/evolution.ts" +import type { Partner } from "../../domain/partner.ts" +import type { ResolveEvolutionBattleOutcome } from "../models/usage.ts" + +export type EvolutionBattleRepository = { + getActivePartner: () => Partner | null + resolveEvolutionBattle: ( + nextState: import("../../domain/partner.ts").PartnerProgression, + createdAt: string, + ) => ResolveEvolutionBattleOutcome +} + +export const resolveEvolutionBattleForPartner = ( + repository: EvolutionBattleRepository, + won: boolean, + digimonById: ReadonlyMap, + createdAt: string, +): ResolveEvolutionBattleOutcome => { + const partner = repository.getActivePartner() + if (partner === null) return { kind: "no_pending_battle" } + + const current = digimonById.get(partner.currentNodeId) + if (current === undefined) { + throw new Error(`Persisted partner node ${partner.currentNodeId} is missing from the catalog`) + } + + if (partner.pendingEvolutionTargetId === null || partner.battleOpponentNodeId === null) { + return { kind: "no_pending_battle" } + } + + const next = resolveEvolutionBattle( + { + current, + gauge: partner.gauge, + isTerminal: partner.isTerminal, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, + }, + won, + digimonById, + ) + + return repository.resolveEvolutionBattle( + { + currentNodeId: next.current.id, + gauge: next.gauge, + isTerminal: next.isTerminal, + pendingEvolutionTargetId: next.pendingEvolutionTargetId, + battleOpponentNodeId: next.battleOpponentNodeId, + }, + createdAt, + ) +} diff --git a/packages/digital-pet-core/src/application/use-cases/set-digital-pet-cheat-node.ts b/packages/digital-pet-core/src/application/use-cases/set-digital-pet-cheat-node.ts new file mode 100644 index 0000000..2b93712 --- /dev/null +++ b/packages/digital-pet-core/src/application/use-cases/set-digital-pet-cheat-node.ts @@ -0,0 +1,7 @@ +import type { SetCheatNodeOutcome } from "../models/digital-pet-control.ts" +import type { DigitalPetControl } from "../ports/digital-pet-control.ts" + +export const setDigitalPetCheatNode = ( + control: Pick, + validatedNodeId: string, +): SetCheatNodeOutcome => control.setCheatNode(validatedNodeId) diff --git a/src/application/use-cases/spawn-partner.ts b/packages/digital-pet-core/src/application/use-cases/spawn-partner.ts similarity index 100% rename from src/application/use-cases/spawn-partner.ts rename to packages/digital-pet-core/src/application/use-cases/spawn-partner.ts diff --git a/packages/digital-pet-core/src/application/use-cases/unfreeze-digital-pet.ts b/packages/digital-pet-core/src/application/use-cases/unfreeze-digital-pet.ts new file mode 100644 index 0000000..735bfae --- /dev/null +++ b/packages/digital-pet-core/src/application/use-cases/unfreeze-digital-pet.ts @@ -0,0 +1,5 @@ +import type { UnfreezeDigitalPetOutcome } from "../models/digital-pet-control.ts" +import type { DigitalPetControl } from "../ports/digital-pet-control.ts" + +export const unfreezeDigitalPet = (control: Pick): UnfreezeDigitalPetOutcome => + control.unfreeze() diff --git a/src/config/defaults.ts b/packages/digital-pet-core/src/config/defaults.ts similarity index 57% rename from src/config/defaults.ts rename to packages/digital-pet-core/src/config/defaults.ts index ab6eb1c..2389254 100644 --- a/src/config/defaults.ts +++ b/packages/digital-pet-core/src/config/defaults.ts @@ -1,4 +1,4 @@ -import type { ResolvedVpetSettings, StageLabels, StageThresholdSettings, VpetStageLabels } from "./types.ts" +import type { ResolvedDigitalPetSettings, StageLabels, StageThresholdSettings, DigitalPetStageLabels } from "./types.ts" const EN_STAGE_LABELS = Object.freeze({ egg: "DigiEgg", @@ -22,25 +22,25 @@ const JP_STAGE_LABELS = Object.freeze({ superUltimate: "SuperUltimate", } as const satisfies StageLabels) -export const VPET_STAGE_LABELS = Object.freeze({ +export const DIGITAL_PET_STAGE_LABELS = Object.freeze({ en: EN_STAGE_LABELS, jp: JP_STAGE_LABELS, -} as const satisfies VpetStageLabels) +} as const satisfies DigitalPetStageLabels) export const DEFAULT_STAGE_THRESHOLDS = Object.freeze({ - egg: 500_000, - babyI: 1_000_000, - babyII: 2_000_000, - child: 4_000_000, - adult: 7_500_000, - perfect: 12_500_000, - ultimate: 20_000_000, - superUltimate: 30_000_000, + egg: 5_000_000, + babyI: 10_000_000, + babyII: 20_000_000, + child: 40_000_000, + adult: 75_000_000, + perfect: 125_000_000, + ultimate: 200_000_000, + superUltimate: 300_000_000, } as const satisfies StageThresholdSettings) -export const DEFAULT_VPET_SETTINGS = Object.freeze({ +export const DEFAULT_DIGITAL_PET_SETTINGS = Object.freeze({ language: "jp", notifications: true, - stageLabels: VPET_STAGE_LABELS, + stageLabels: DIGITAL_PET_STAGE_LABELS, stageThresholds: DEFAULT_STAGE_THRESHOLDS, -} as const satisfies ResolvedVpetSettings) +} as const satisfies ResolvedDigitalPetSettings) diff --git a/src/config/normalize.ts b/packages/digital-pet-core/src/config/normalize.ts similarity index 70% rename from src/config/normalize.ts rename to packages/digital-pet-core/src/config/normalize.ts index cef8ed4..c565340 100644 --- a/src/config/normalize.ts +++ b/packages/digital-pet-core/src/config/normalize.ts @@ -1,11 +1,11 @@ -import { DEFAULT_STAGE_THRESHOLDS, DEFAULT_VPET_SETTINGS } from "./defaults.ts" -import type { ResolvedVpetSettings, StageThresholdKey, VpetLanguage } from "./types.ts" +import { DEFAULT_STAGE_THRESHOLDS, DEFAULT_DIGITAL_PET_SETTINGS } from "./defaults.ts" +import type { ResolvedDigitalPetSettings, StageThresholdKey, DigitalPetLanguage } from "./types.ts" const isRecord = (value: unknown): value is Readonly> => { return typeof value === "object" && value !== null && !Array.isArray(value) } -const isLanguage = (value: unknown): value is VpetLanguage => value === "en" || value === "jp" +const isLanguage = (value: unknown): value is DigitalPetLanguage => value === "en" || value === "jp" const isBoolean = (value: unknown): value is boolean => typeof value === "boolean" @@ -26,9 +26,9 @@ const normalizeThreshold = (input: Readonly>, key: Stage return isThreshold(value) ? value : DEFAULT_STAGE_THRESHOLDS[key] } -export const normalizeVpetSettings = (input: unknown): ResolvedVpetSettings => { +export const normalizeDigitalPetSettings = (input: unknown): ResolvedDigitalPetSettings => { try { - if (!isRecord(input)) return DEFAULT_VPET_SETTINGS + if (!isRecord(input)) return DEFAULT_DIGITAL_PET_SETTINGS const thresholdInput = read(input, "stageThresholds") const thresholds = isRecord(thresholdInput) ? thresholdInput : {} @@ -46,12 +46,12 @@ export const normalizeVpetSettings = (input: unknown): ResolvedVpetSettings => { const language = read(input, "language") const notifications = read(input, "notifications") return Object.freeze({ - language: isLanguage(language) ? language : DEFAULT_VPET_SETTINGS.language, - notifications: isBoolean(notifications) ? notifications : DEFAULT_VPET_SETTINGS.notifications, - stageLabels: DEFAULT_VPET_SETTINGS.stageLabels, + language: isLanguage(language) ? language : DEFAULT_DIGITAL_PET_SETTINGS.language, + notifications: isBoolean(notifications) ? notifications : DEFAULT_DIGITAL_PET_SETTINGS.notifications, + stageLabels: DEFAULT_DIGITAL_PET_SETTINGS.stageLabels, stageThresholds, }) } catch { - return DEFAULT_VPET_SETTINGS + return DEFAULT_DIGITAL_PET_SETTINGS } } diff --git a/src/config/types.ts b/packages/digital-pet-core/src/config/types.ts similarity index 56% rename from src/config/types.ts rename to packages/digital-pet-core/src/config/types.ts index d0932b3..202ecb9 100644 --- a/src/config/types.ts +++ b/packages/digital-pet-core/src/config/types.ts @@ -1,6 +1,6 @@ -export const VPET_LANGUAGES = Object.freeze(["en", "jp"] as const) +export const DIGITAL_PET_LANGUAGES = Object.freeze(["en", "jp"] as const) -export type VpetLanguage = (typeof VPET_LANGUAGES)[number] +export type DigitalPetLanguage = (typeof DIGITAL_PET_LANGUAGES)[number] export const STAGE_THRESHOLD_KEYS = Object.freeze([ "egg", @@ -19,11 +19,11 @@ export type StageThresholdSettings = Readonly> export type StageLabels = Readonly> -export type VpetStageLabels = Readonly> +export type DigitalPetStageLabels = Readonly> -export type ResolvedVpetSettings = Readonly<{ - readonly language: VpetLanguage +export type ResolvedDigitalPetSettings = Readonly<{ + readonly language: DigitalPetLanguage readonly notifications: boolean - readonly stageLabels: VpetStageLabels + readonly stageLabels: DigitalPetStageLabels readonly stageThresholds: StageThresholdSettings }> diff --git a/src/data/catalog.ts b/packages/digital-pet-core/src/data/catalog.ts similarity index 100% rename from src/data/catalog.ts rename to packages/digital-pet-core/src/data/catalog.ts diff --git a/src/data/digimon-data.ts b/packages/digital-pet-core/src/data/digimon-data.ts similarity index 100% rename from src/data/digimon-data.ts rename to packages/digital-pet-core/src/data/digimon-data.ts diff --git a/src/data/monster-frame-catalog.ts b/packages/digital-pet-core/src/data/monster-frame-catalog.ts similarity index 100% rename from src/data/monster-frame-catalog.ts rename to packages/digital-pet-core/src/data/monster-frame-catalog.ts diff --git a/src/data/monster-frame-data.ts b/packages/digital-pet-core/src/data/monster-frame-data.ts similarity index 100% rename from src/data/monster-frame-data.ts rename to packages/digital-pet-core/src/data/monster-frame-data.ts diff --git a/src/data/stages.ts b/packages/digital-pet-core/src/data/stages.ts similarity index 100% rename from src/data/stages.ts rename to packages/digital-pet-core/src/data/stages.ts diff --git a/src/domain/digimon-node.ts b/packages/digital-pet-core/src/domain/digimon-node.ts similarity index 100% rename from src/domain/digimon-node.ts rename to packages/digital-pet-core/src/domain/digimon-node.ts diff --git a/packages/digital-pet-core/src/domain/evolution-battle.ts b/packages/digital-pet-core/src/domain/evolution-battle.ts new file mode 100644 index 0000000..db43ddd --- /dev/null +++ b/packages/digital-pet-core/src/domain/evolution-battle.ts @@ -0,0 +1,37 @@ +import type { DigimonNode } from "./digimon-node.ts" +import type { EvolutionSelector } from "./evolution.ts" + +export const pickEvolutionTarget = (current: DigimonNode, selector: EvolutionSelector): string => { + if (current.nextEvolutions.length === 0) { + throw new Error("Evolution target selection failed: partner has no evolution options") + } + + const selection = selector() + if (!Number.isFinite(selection) || selection < 0 || selection >= 1) { + throw new Error(`Evolution selector must return a finite number in [0, 1), received ${selection}`) + } + + const targetId = current.nextEvolutions[Math.floor(selection * current.nextEvolutions.length)] + if (targetId === undefined) throw new Error("Evolution target selection failed") + return targetId +} + +export const pickRandomSameStageOpponent = ( + current: DigimonNode, + catalogNodes: readonly DigimonNode[], + selector: EvolutionSelector, +): string => { + const candidates = catalogNodes.filter((node) => node.stage === current.stage && node.id !== current.id) + if (candidates.length === 0) { + return current.id + } + + const selection = selector() + if (!Number.isFinite(selection) || selection < 0 || selection >= 1) { + throw new Error(`Evolution selector must return a finite number in [0, 1), received ${selection}`) + } + + const opponent = candidates[Math.floor(selection * candidates.length)] + if (opponent === undefined) throw new Error("Same-stage opponent selection failed") + return opponent.id +} diff --git a/packages/digital-pet-core/src/domain/evolution.ts b/packages/digital-pet-core/src/domain/evolution.ts new file mode 100644 index 0000000..f8899d1 --- /dev/null +++ b/packages/digital-pet-core/src/domain/evolution.ts @@ -0,0 +1,126 @@ +import type { DigimonNode } from "./digimon-node.ts" +import { pickEvolutionTarget, pickRandomSameStageOpponent } from "./evolution-battle.ts" +import { DIGIMON_STAGES, type DigimonStage } from "./stage.ts" + +export type StageThresholds = Readonly> + +export const STAGE_GAUGE_THRESHOLDS = Object.freeze({ + 0: 5_000_000, + 1: 10_000_000, + 2: 20_000_000, + 3: 40_000_000, + 4: 75_000_000, + 5: 125_000_000, + 6: 200_000_000, + 7: 300_000_000, +} as const satisfies StageThresholds) + +export type PartnerEvolutionState = { + readonly current: DigimonNode + readonly gauge: number + readonly isTerminal: boolean + readonly pendingEvolutionTargetId: string | null + readonly battleOpponentNodeId: string | null +} + +export type EvolutionSelector = () => number + +const isRecord = (value: unknown): value is Readonly> => { + return typeof value === "object" && value !== null +} + +const isStageThresholds = (thresholds: unknown): thresholds is StageThresholds => { + if (!isRecord(thresholds) || !Object.isFrozen(thresholds)) return false + + return DIGIMON_STAGES.every((stage) => { + const threshold = thresholds[stage] + return typeof threshold === "number" && Number.isFinite(threshold) && threshold > 0 + }) +} + +const hasPendingBattle = (state: PartnerEvolutionState): boolean => + state.pendingEvolutionTargetId != null && state.battleOpponentNodeId != null + +export const applyTokenProgress = ( + state: PartnerEvolutionState, + tokenDelta: number, + selector: EvolutionSelector, + digimonById: ReadonlyMap, + catalogNodes: readonly DigimonNode[], + thresholds: unknown, +): PartnerEvolutionState => { + if (!isStageThresholds(thresholds)) { + throw new Error("Evolution thresholds must be a complete frozen policy of positive finite numbers") + } + if (state.isTerminal) return state + if (hasPendingBattle(state)) return state + + const threshold = thresholds[state.current.stage] + const gauge = state.gauge + tokenDelta + if (gauge < threshold) return { ...state, gauge } + if (state.current.nextEvolutions.length === 0) { + return { ...state, gauge: 0, isTerminal: true, pendingEvolutionTargetId: null, battleOpponentNodeId: null } + } + + const pendingEvolutionTargetId = pickEvolutionTarget(state.current, selector) + const target = digimonById.get(pendingEvolutionTargetId) + if (target === undefined) { + throw new Error(`Evolution target ${pendingEvolutionTargetId} is missing from the catalog`) + } + + if (state.current.stage === 0) { + return { + current: target, + gauge: 0, + isTerminal: target.nextEvolutions.length === 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + } + } + + const battleOpponentNodeId = pickRandomSameStageOpponent(state.current, catalogNodes, selector) + if (digimonById.get(battleOpponentNodeId) === undefined) { + throw new Error(`Battle opponent ${battleOpponentNodeId} is missing from the catalog`) + } + + return { + current: state.current, + gauge: threshold, + isTerminal: false, + pendingEvolutionTargetId, + battleOpponentNodeId, + } +} + +export const resolveEvolutionBattle = ( + state: PartnerEvolutionState, + won: boolean, + digimonById: ReadonlyMap, +): PartnerEvolutionState => { + if (!hasPendingBattle(state)) { + throw new Error("Cannot resolve evolution battle without a pending battle") + } + + if (!won) { + return { + current: state.current, + gauge: 0, + isTerminal: state.isTerminal, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + } + } + + const targetId = state.pendingEvolutionTargetId + if (targetId === null) throw new Error("Pending evolution target is missing") + const current = digimonById.get(targetId) + if (current === undefined) throw new Error(`Evolution target ${targetId} is missing from the catalog`) + + return { + current, + gauge: 0, + isTerminal: current.nextEvolutions.length === 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + } +} diff --git a/src/domain/partner.ts b/packages/digital-pet-core/src/domain/partner.ts similarity index 65% rename from src/domain/partner.ts rename to packages/digital-pet-core/src/domain/partner.ts index d153747..5e2ed9b 100644 --- a/src/domain/partner.ts +++ b/packages/digital-pet-core/src/domain/partner.ts @@ -4,6 +4,8 @@ export type Partner = { readonly currentNodeId: string readonly gauge: number readonly isTerminal: boolean + readonly pendingEvolutionTargetId: string | null + readonly battleOpponentNodeId: string | null readonly createdAt: string readonly retiredAt: string | null } @@ -12,4 +14,6 @@ export type PartnerProgression = { readonly currentNodeId: string readonly gauge: number readonly isTerminal: boolean + readonly pendingEvolutionTargetId: string | null + readonly battleOpponentNodeId: string | null } diff --git a/src/domain/stage.ts b/packages/digital-pet-core/src/domain/stage.ts similarity index 100% rename from src/domain/stage.ts rename to packages/digital-pet-core/src/domain/stage.ts diff --git a/packages/digital-pet-core/src/ports/sqlite-executor.ts b/packages/digital-pet-core/src/ports/sqlite-executor.ts new file mode 100644 index 0000000..7c71934 --- /dev/null +++ b/packages/digital-pet-core/src/ports/sqlite-executor.ts @@ -0,0 +1,8 @@ +export type QueryValue = string | number | boolean | null + +export type SqliteExecutor = { + run: (sql: string, params?: readonly QueryValue[]) => void + get: >(sql: string, params?: readonly QueryValue[]) => TRow | null + all: >(sql: string, params?: readonly QueryValue[]) => readonly TRow[] + transaction: (operation: () => TReturn) => TReturn +} diff --git a/src/tui/dex-view-model.ts b/packages/digital-pet-core/src/view-models/dex-view-model.ts similarity index 88% rename from src/tui/dex-view-model.ts rename to packages/digital-pet-core/src/view-models/dex-view-model.ts index 2173605..491a91c 100644 --- a/src/tui/dex-view-model.ts +++ b/packages/digital-pet-core/src/view-models/dex-view-model.ts @@ -1,5 +1,5 @@ -import type { VpetArchiveResult } from "../application/models/vpet-archive.ts" -import type { ResolvedVpetSettings } from "../config/types.ts" +import type { DigitalPetArchiveResult } from "../application/models/digital-pet-archive.ts" +import type { ResolvedDigitalPetSettings } from "../config/types.ts" import type { DigimonCatalog } from "../data/catalog.ts" import { getStageLabel } from "../data/stages.ts" import { DIGIMON_STAGES } from "../domain/stage.ts" @@ -36,7 +36,7 @@ const compareCatalogNodes = (left: DigimonNode, right: DigimonNode): number => { } const discoveredNodeIds = ( - archive: Extract, + archive: Extract, ): ReadonlySet => { const nodeIds = new Set() for (const partner of archive.partners) { @@ -46,9 +46,9 @@ const discoveredNodeIds = ( } export const buildDexViewModel = ( - archive: VpetArchiveResult, + archive: DigitalPetArchiveResult, catalog: DigimonCatalog, - settings: ResolvedVpetSettings, + settings: ResolvedDigitalPetSettings, ): DexViewModel => { switch (archive.kind) { case "available": { diff --git a/src/tui/history-view-model.ts b/packages/digital-pet-core/src/view-models/history-view-model.ts similarity index 77% rename from src/tui/history-view-model.ts rename to packages/digital-pet-core/src/view-models/history-view-model.ts index 6eb6b2a..e1ba63a 100644 --- a/src/tui/history-view-model.ts +++ b/packages/digital-pet-core/src/view-models/history-view-model.ts @@ -1,5 +1,9 @@ -import type { VpetArchiveEvent, VpetArchivePartner, VpetArchiveResult } from "../application/models/vpet-archive.ts" -import type { ResolvedVpetSettings } from "../config/types.ts" +import type { + DigitalPetArchiveEvent, + DigitalPetArchivePartner, + DigitalPetArchiveResult, +} from "../application/models/digital-pet-archive.ts" +import type { ResolvedDigitalPetSettings } from "../config/types.ts" import type { DigimonCatalog } from "../data/catalog.ts" export type HistoryGeneration = { @@ -19,7 +23,7 @@ const assertNever = (value: never): never => { throw new Error(`Unexpected archive result: ${JSON.stringify(value)}`) } -const comparePartners = (left: VpetArchivePartner, right: VpetArchivePartner): number => { +const comparePartners = (left: DigitalPetArchivePartner, right: DigitalPetArchivePartner): number => { const createdAtComparison = right.createdAt.localeCompare(left.createdAt) if (createdAtComparison !== 0) return createdAtComparison const generationDifference = right.generation - left.generation @@ -27,8 +31,8 @@ const comparePartners = (left: VpetArchivePartner, right: VpetArchivePartner): n return right.partnerId.localeCompare(left.partnerId) } -const dedupeAdjacentEvents = (events: readonly VpetArchiveEvent[]): readonly VpetArchiveEvent[] => { - const distinctEvents: VpetArchiveEvent[] = [] +const dedupeAdjacentEvents = (events: readonly DigitalPetArchiveEvent[]): readonly DigitalPetArchiveEvent[] => { + const distinctEvents: DigitalPetArchiveEvent[] = [] for (const event of events) { const previousEvent = distinctEvents.at(-1) if (previousEvent?.currentNodeId !== event.currentNodeId) distinctEvents.push(event) @@ -36,16 +40,16 @@ const dedupeAdjacentEvents = (events: readonly VpetArchiveEvent[]): readonly Vpe return distinctEvents } -const displayNodeId = (nodeId: string, catalog: DigimonCatalog, settings: ResolvedVpetSettings): string => { +const displayNodeId = (nodeId: string, catalog: DigimonCatalog, settings: ResolvedDigitalPetSettings): string => { const node = catalog.byId.get(nodeId) if (node === undefined) return nodeId return `${node.id} ${settings.language === "en" ? node.nameEn : node.nameJp}` } export const buildHistoryViewModel = ( - archive: VpetArchiveResult, + archive: DigitalPetArchiveResult, catalog: DigimonCatalog, - settings: ResolvedVpetSettings, + settings: ResolvedDigitalPetSettings, ): HistoryViewModel => { switch (archive.kind) { case "available": diff --git a/src/tui/sidebar-view-model.ts b/packages/digital-pet-core/src/view-models/sidebar-view-model.ts similarity index 80% rename from src/tui/sidebar-view-model.ts rename to packages/digital-pet-core/src/view-models/sidebar-view-model.ts index c345e99..497d9b9 100644 --- a/src/tui/sidebar-view-model.ts +++ b/packages/digital-pet-core/src/view-models/sidebar-view-model.ts @@ -1,6 +1,6 @@ import type { SidebarCardInputs } from "../application/models/sidebar-card-inputs.ts" -import { DEFAULT_VPET_SETTINGS } from "../config/defaults.ts" -import type { ResolvedVpetSettings } from "../config/types.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "../config/defaults.ts" +import type { ResolvedDigitalPetSettings } from "../config/types.ts" import { getStageKey, getStageLabel } from "../data/stages.ts" export type SidebarCardModel = @@ -17,11 +17,12 @@ export type SidebarCardModel = readonly isTerminal: boolean readonly frozen: boolean readonly isSetOverride: boolean + readonly evolutionBattlePending: boolean } export const buildSidebarCardModel = ( inputs: SidebarCardInputs, - settings: ResolvedVpetSettings = DEFAULT_VPET_SETTINGS, + settings: ResolvedDigitalPetSettings = DEFAULT_DIGITAL_PET_SETTINGS, ): SidebarCardModel => { if (inputs.kind === "no_partner") return { kind: "no_partner", messageLine: "No active partner" } @@ -38,5 +39,6 @@ export const buildSidebarCardModel = ( isTerminal: inputs.isTerminal, frozen: inputs.frozen, isSetOverride: inputs.isSetOverride, + evolutionBattlePending: inputs.evolutionBattlePending, } } diff --git a/packages/digital-pet-core/tsconfig.json b/packages/digital-pet-core/tsconfig.json new file mode 100644 index 0000000..dad5ed4 --- /dev/null +++ b/packages/digital-pet-core/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "target": "ESNext", + "lib": ["ESNext"], + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/packages/opencode-digital-pet/CHANGELOG.md b/packages/opencode-digital-pet/CHANGELOG.md new file mode 100644 index 0000000..e274a81 --- /dev/null +++ b/packages/opencode-digital-pet/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed + +- Renamed the product and packages to Digital Pet under the `jcendal` namespace. +- Kept existing `opencode-vpet` settings and database paths readable so current partners remain available. +- Updated release metadata for the `jcendal/digital-pet` repository and credited Sergio Bugallo as the original author. + +## [0.1.1] - 2026-08-30 + +### Fixed + +- Exported the OpenCode server plugin entrypoint so VPet commands and the sidebar load correctly. +- Updated `init` and `update` to delegate to OpenCode's native global plugin installer with the + installed exact package version, avoiding stale bare or `@latest` cache reuse. + +## [0.1.0] - 2026-08-30 + +### Added + +- Server and TUI plugins that add a Digimon virtual pet to the OpenCode sidebar. +- Progression from completed assistant-message token usage, including reconciliation of completed + usage from concurrent sessions. +- Toast notifications for spawning, progression controls, and Digimon evolutions. +- A 650-Digimon catalog spanning eight evolution stages, with evolution paths and Japanese and + English names. +- Partner generations that can be spawned, evolved, frozen, unfrozen, or set to a catalog ID. +- Sidebar progress and status display, including frozen and manually set partner states, with + animated walking, action, and sleep behavior. +- Dex and History dialogs for browsing discovered Digimon and current or retired partner + generations, with links to external encyclopedia entries. +- `/vpet-spawn`, `/vpet-freeze`, `/vpet-unfreeze`, `/vpet-set `, `/vpet-dex`, and + `/vpet-history` commands. +- SQLite persistence for partners, progression events, usage receipts, trainer totals, and control + state. +- Global settings for language, evolution thresholds, and notifications. +- `npx @sbugallo/opencode-vpet init` and `npx @sbugallo/opencode-vpet update` commands that register + server and TUI plugins while preserving unrelated JSON and JSONC configuration. diff --git a/packages/opencode-digital-pet/LICENSE b/packages/opencode-digital-pet/LICENSE new file mode 100644 index 0000000..3d6428f --- /dev/null +++ b/packages/opencode-digital-pet/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object + form, made available under the License, as indicated by a copyright + notice that is included in or attached to the work (an example is + provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Sergio Bugallo Enjamio + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/opencode-digital-pet/README.md b/packages/opencode-digital-pet/README.md new file mode 100644 index 0000000..3d69938 --- /dev/null +++ b/packages/opencode-digital-pet/README.md @@ -0,0 +1,843 @@ +
+ OpenCode Digital Pet logo +

OpenCode Digital Pet

+

+ A Digimon virtual pet for OpenCode that evolves with usage. +

+

+ About | + Evolution | + Quick start | + Commands | + Settings | + Storage | + Partner list | + License | + Attributions + +

+
+ +--- + +## About + +
+ Illustrative OpenCode Digital Pet overview +
+ +`@jcendal/opencode-digital-pet` is an OpenCode v1 plugin that adds a Digimon virtual pet to your OpenCode TUI +sidebar. Your Digimon evolves as you use OpenCode, and you can browse your discoveries and +partner history. + +The plugin supports multiple concurrent sessions, so every token counts toward your partner's evolution. + +There are more than 600 Digimon to discover, and the plugin is designed to be extensible so that new Digimon can be added in future releases. + +## Evolution + +Your active partner gains experience from the tokens reported for completed assistant +messages in OpenCode. This includes input, output, reasoning, and cache tokens. +Progress is shared across concurrent OpenCode sessions, so completed work in each +session contributes to the same partner. + +Each stage has a configurable experience threshold. When your partner reaches the +threshold for its current stage, it evolves and its experience gauge resets for the +next stage. Use `/digital-pet-freeze` to pause partner progression; usage while frozen is +not applied to your partner. + +The bundled evolution lines were extracted from the original V-Pets. When a Digimon +reaches an evolution threshold, the plugin randomly chooses one of the evolution +options in that Digimon's line. + +## Quick start + +Requirements: + +- OpenCode `>=1.18.10` +- Node `24.11.0` to run the installation CLI with `npx` +- Bun `1.3.5` for local development and tests; OpenCode runs the plugin with Bun + + +1. Install the OpenCode plugin: + + ```sh + npx @jcendal/opencode-digital-pet init + ``` + + The CLI delegates to `opencode plugin @jcendal/opencode-digital-pet@ --global --force`. + It uses the package's installed exact version so OpenCode uses a version-specific cache key rather + than reusing a stale bare or `@latest` cache entry. `--force` replaces matching plugin + configuration registrations. + +2. Restart OpenCode when the command finishes. + +3. In OpenCode, run `/digital-pet-spawn` to hatch a partner. + +After that, use `/digital-pet-dex` to browse discoveries and `/digital-pet-history` to browse current and retired generations. + +--- + +## Commands + +| Command | Description | +| --- | --- | +| `/digital-pet-spawn` | Spawns a new egg. | +| `/digital-pet-freeze` | Pauses partner progression. | +| `/digital-pet-unfreeze` | Resumes partner progression. | +| `/digital-pet-set ` | Overrides the partner with the specified catalog ID. | +| `/digital-pet-dex` | Opens the partner Dex. | +| `/digital-pet-history` | Opens the partner History. | + +> [!NOTE] +> Using `/digital-pet-set` does not count for dex completion. + +## Update + +To update the plugin, run: + +```sh +npx @jcendal/opencode-digital-pet update +``` + +Like `init`, `update` delegates to OpenCode's native global plugin installer with the installed exact +package version. Remove any old `@sbugallo/opencode-vpet` or unscoped `opencode-vpet` +registration from your OpenCode configuration to avoid loading both plugins. + +## Settings + +Settings are stored in `opencode-digital-pet.json` and control naming, notifications, and evolution +thresholds. + +The location of the settings file depends on your operating system: + +- Linux, WSL, and macOS: `~/.config/opencode-digital-pet.json` +- Windows: `%APPDATA%\opencode-digital-pet.json`, or `%USERPROFILE%\AppData\Roaming\opencode-digital-pet.json` + when `APPDATA` isn't set. + +An existing `opencode-vpet.json` remains in use until you create the new file. + +Available settings are: + +| Setting | Description | +| --- | --- | +| `notifications` | Enables or disables notifications. | +| `language` | Sets the naming convention. Valid values are `jp` (Japanese, e.g., Omegamon, Child, Adult...) and `en` (English, e.g., Omnimon, Rookie, Champion...). | +| `stageThresholds` | Defines the experience thresholds for each evolution stage. | + +Default values are: + +```json +{ + "notifications": true, + "language": "jp", + "stageThresholds": { + "egg": 5000000, + "babyI": 10000000, + "babyII": 20000000, + "child": 40000000, + "adult": 75000000, + "perfect": 125000000, + "ultimate": 200000000, + "superUltimate": 300000000 + } +} +``` + +## Storage + +To persist your partner, history, and control state, Digital Pet uses a SQLite database named `pet.db`. The location of the database depends on your operating system: + +- Linux, WSL: `~/.local/share/opencode-digital-pet/pet.db` +- macOS: `~/Library/Application Support/opencode-digital-pet/pet.db` +- Windows: `%APPDATA%\opencode-digital-pet\pet.db`, or `%USERPROFILE%\AppData\Roaming\opencode-digital-pet\pet.db` + when `APPDATA` isn't set. + +If an existing `opencode-vpet/pet.db` is present and the new database does not exist, +Digital Pet continues using it. Internal SQLite table names are retained for compatibility. + +--- + +## Partner list + +| ID | Japanese name | English name | +| --- | --- | --- | +| 0-001 | Digitama | Digiegg | +| 1-001 | Algomon | Argomon | +| 1-002 | Botamon | Botamon | +| 1-003 | Chicomon | Chibomon | +| 1-004 | Choromon | Choromon | +| 1-005 | Cocomon | Conomon | +| 1-006 | Cotsucomon | Cotsucomon | +| 1-007 | Dodomon | Dodomon | +| 1-008 | Fufumon | Fufumon | +| 1-009 | Kiimon | Keemon | +| 1-010 | Ketomon | Ketomon | +| 1-011 | Kuramon | Kuramon | +| 1-012 | Mokumon | Mokumon | +| 1-013 | Nyokimon | Nyokimon | +| 1-014 | Bubbmon | Pabumon | +| 1-015 | Petitmon | Petitmon | +| 1-016 | Pitchmon | Pichimon | +| 1-017 | Poyomon | Poyomon | +| 1-018 | Punimon | Punimon | +| 1-019 | Pururumon | Pururumon | +| 1-020 | Puttimon | Puttimon | +| 1-021 | Sakumon | Sakumon | +| 1-022 | Tsubumon | Tsubumon | +| 1-023 | Yukimibotamon | YukimiBotamon | +| 1-024 | Yuramon | Yuramon | +| 1-025 | Zerimon | Zerimon | +| 1-026 | Zurumon | Zurumon | +| 1-027 | Chibickmon | Chibickmon | +| 1-028 | Dokimon | Dokimon | +| 1-029 | Jyarimon | Jyarimon | +| 1-030 | Relemon | Relemon | +| 1-031 | Sunamon | Sandmon | +| 2-001 | Algomon | Argomon | +| 2-002 | Babydmon | Bebydomon | +| 2-003 | Budmon | Budmon | +| 2-004 | Pukamon | Bukamon | +| 2-005 | PetiMeramon | DemiMeramon | +| 2-006 | Chibimon | DemiVeemon | +| 2-007 | Dorimon | Dorimon | +| 2-008 | Gummymon | Gummymon | +| 2-009 | Hopmon | Hopmon | +| 2-010 | Kakkinmon | Kakkinmon | +| 2-011 | Capromon | Kapurimon | +| 2-012 | Chocomon | Kokomon | +| 2-013 | Koromon | Koromon | +| 2-014 | Kyokyomon | Kyokyomon | +| 2-015 | Mochimon | Motimon | +| 2-016 | Nyaromon | Nyaromon | +| 2-017 | Pagumon | Pagumon | +| 2-018 | Poromon | Poromon | +| 2-019 | Sakuttomon | Sakuttomon | +| 2-020 | Tanemon | Tanemon | +| 2-021 | Tokomon | Tokomon | +| 2-022 | Tokomon X | Tokomon X | +| 2-023 | Tsumemon | Tsumemon | +| 2-024 | Tunomon | Tsunomon | +| 2-025 | Upamon | Upamon | +| 2-026 | Wanyamon | Wanyamon | +| 2-027 | Yarmon | Yaamon | +| 2-028 | Pyocomon | Yokomon | +| 2-029 | Bibimon | Bibimon | +| 2-030 | Gigimon | Gigimon | +| 2-031 | Goromon | Tumblemon | +| 2-032 | Hiyarimon | Hiyarimon | +| 2-033 | Minomon | Minomon | +| 2-034 | Moonmon | Moonmon | +| 2-035 | Pickmon | Pickmon | +| 2-036 | Pokomon | Viximon | +| 2-037 | Sunmon | Sunmon | +| 3-001 | Agumon | Agumon | +| 3-002 | Agumon (Black) X | Agumon (Black) X | +| 3-003 | Agumon Hakase | Agumon Expert | +| 3-004 | Agumon X | Agumon X | +| 3-005 | Algomon | Argomon | +| 3-006 | Armadimon | Armadillomon | +| 3-007 | Betamon | Betamon | +| 3-008 | Piyomon | Biyomon | +| 3-009 | Blucomon | Bulucomon | +| 3-010 | BushiAgumon | BushiAgumon | +| 3-011 | Candmon | Candlemon | +| 3-012 | Coronamon | Coronamon | +| 3-013 | Ganimon | Crabmon | +| 3-014 | PicoDevimon | DemiDevimon | +| 3-015 | DORUmon | Dorumon | +| 3-016 | Dracomon | Dracomon | +| 3-017 | Dracomon X | Dracomon X | +| 3-018 | Duskmon | Duskmon | +| 3-019 | Elecmon | Elecmon | +| 3-020 | Falcomon | Falcomon | +| 3-021 | Floramon | Floramon | +| 3-022 | Gabumon | Gabumon | +| 3-023 | Gabumon X | Gabumon X | +| 3-024 | Gaomon | Gaomon | +| 3-025 | Tailmon | Gatomon | +| 3-026 | Gazimon | Gazimon | +| 3-027 | Ghostmon | Ghostmon | +| 3-028 | Gizamon | Gizamon | +| 3-029 | Gomamon | Gomamon | +| 3-030 | Gomamon X | Gomamon X | +| 3-031 | Gottsumon | Gotsumon | +| 3-032 | Guilmon | Guilmon | +| 3-033 | Hagurumon | Hagurumon | +| 3-034 | Hawkmon | Hawkmon | +| 3-035 | Herissmon | Herissmon | +| 3-036 | Hackmon | Huckmon | +| 3-037 | Impmon | Impmon | +| 3-038 | Impmon X | Impmon X | +| 3-039 | Jazamon | Jazamon | +| 3-040 | Junkmon | Junkmon | +| 3-041 | Keramon X | Keramon X | +| 3-042 | Kokuwamon | Kokuwamon | +| 3-043 | Kokuwamon X | Kokuwamon X | +| 3-044 | Kunemon | Kunemon | +| 3-045 | Labramon | Labramon | +| 3-046 | Lalamon | Lalamon | +| 3-047 | Lopmon | Lopmon | +| 3-048 | Lopmon X | Lopmon X | +| 3-049 | Ludomon | Ludomon | +| 3-050 | Lunamon | Lunamon | +| 3-051 | Monodramon | Monodramon | +| 3-052 | Morphomon | Morphomon | +| 3-053 | Mushmon | Mushroomon | +| 3-054 | Otamamon | Otamamon | +| 3-055 | Otamamon X | Otamamon X | +| 3-056 | Palmon | Palmon | +| 3-057 | Palmon X | Palmon X | +| 3-058 | Patamon | Patamon | +| 3-059 | Phascomon | Phascomon | +| 3-060 | Pomumon | Pomumon | +| 3-061 | Renamon X | Renamon X | +| 3-062 | Ryudamon | Ryudamon | +| 3-063 | Plotmon | Salamon | +| 3-064 | Plotmon X | Salamon X | +| 3-065 | Sangomon | Sangomon | +| 3-066 | Shoutmon | Shoutmon | +| 3-067 | Sistermon Blanc | Sistermon Blanc | +| 3-068 | Sunarizamon | Sunarizamon | +| 3-069 | Swimmon | Swimmon | +| 3-070 | Shakomon | Syakomon | +| 3-071 | Shakomon X | Syakomon X | +| 3-072 | Bakumon | Tapirmon | +| 3-073 | Tentomon | Tentomon | +| 3-074 | Terriermon | Terriermon | +| 3-075 | Terriermon X | Terriermon X | +| 3-076 | Tinkermon | Tinkermon | +| 3-077 | ToyAgumon | ToyAgumon | +| 3-078 | V-mon | Veemon | +| 3-079 | Vorvomon | Vorvomon | +| 3-080 | Wormmon | Wormmon | +| 3-081 | Zubamon | Zubamon | +| 3-082 | Agumon (Black) | Agumon (Black) | +| 3-083 | Dracumon | Dracmon | +| 3-084 | Pulsemon | Pulsemon | +| 3-085 | Renamon | Renamon | +| 4-001 | Agnimon | Agunimon | +| 4-002 | Airdramon | Airdramon | +| 4-003 | Allomon X | Allomon X | +| 4-004 | Angemon | Angemon | +| 4-005 | Ankylomon | Ankylomon | +| 4-006 | Hanumon | Apemon | +| 4-007 | Aquilamon | Aquilamon | +| 4-008 | Algomon | Argomon | +| 4-009 | Arresterdramon | Arresterdramon | +| 4-010 | Baboongamon | Baboongamon | +| 4-011 | Bakemon | Bakemon | +| 4-012 | Baluchimon | Baluchimon | +| 4-013 | BaoHuckmon | BaoHuckmon | +| 4-014 | Birdramon | Birdramon | +| 4-015 | Centalmon | Centarumon | +| 4-016 | Clockmon | Clockmon | +| 4-017 | Coelamon | Coelamon | +| 4-018 | Coredramon (Blue) | Coredramon (Blue) | +| 4-019 | Coredramon (Green) | Coredramon (Green) | +| 4-020 | Cyclomon | Cyclonemon | +| 4-021 | Damemon | Damemon | +| 4-022 | Darcmon | Darcmon | +| 4-023 | DarkTyranomon | DarkTyrannomon | +| 4-024 | DarkTyranomon X | DarkTyrannomon X | +| 4-025 | Deltamon | Deltamon | +| 4-026 | Revolmon | Deputymon | +| 4-027 | Devidramon | Devidramon | +| 4-028 | Devimon | Devimon | +| 4-029 | Diatrymon | Diatrymon | +| 4-030 | Dobermon | Dobermon | +| 4-031 | Dokugumon | Dokugumon | +| 4-032 | Rukamon | Dolphmon | +| 4-033 | DORUgamon | Dorugamon | +| 4-034 | Drimogemon | Drimogemon | +| 4-035 | Duskmon | Duskmon | +| 4-036 | Ebidramon | Ebidramon | +| 4-037 | Eosmon | Eosmon | +| 4-038 | XV-mon | ExVeemon | +| 4-039 | Filmon | Filmon | +| 4-040 | Firamon | Firamon | +| 4-041 | FlareLizamon | Flarerizamon | +| 4-042 | Flymon | Flymon | +| 4-043 | Yukidarumon | Frigimon | +| 4-044 | Gaogamon | Gaogamon | +| 4-045 | Galgomon | Gargomon | +| 4-046 | Garurumon | Garurumon | +| 4-047 | Tailmon | Gatomon | +| 4-048 | Tailmon X | Gatomon X | +| 4-049 | Gekomon | Gekomon | +| 4-050 | GeoGreymon | GeoGreymon | +| 4-051 | Gesomon | Gesomon | +| 4-052 | Ginryumon | Ginryumon | +| 4-053 | Golemon | Golemon | +| 4-054 | Greymon | Greymon | +| 4-055 | Growmon | Growlmon | +| 4-056 | Growmon X | Growlmon X | +| 4-057 | Guardromon | Guardromon | +| 4-058 | Hookmon | Hookmon | +| 4-059 | Hudiemon | Hudiemon | +| 4-060 | Ikkakumon | Ikkakumon | +| 4-061 | Jazardmon | Jazardmon | +| 4-062 | Kabuterimon | Kabuterimon | +| 4-063 | Kiwimon | Kiwimon | +| 4-064 | Cockatrimon | Kokatorimon | +| 4-065 | Komondomon | Komondomon | +| 4-066 | Kuwagamon | Kuwagamon | +| 4-067 | Kuwagamon X | Kuwagamon X | +| 4-068 | Lavorvomon | Lavorvomon | +| 4-069 | Lekismon | Lekismon | +| 4-070 | Leomon | Leomon | +| 4-071 | Leomon X | Leomon X | +| 4-072 | Wolfmon | Lobomon | +| 4-073 | Machmon | Machmon | +| 4-074 | MadLeomon | MadLeomon | +| 4-075 | Manbomon | Manbomon | +| 4-076 | Mantaraymon X | Mantaraymon X | +| 4-077 | Meicoomon | Meicoomon | +| 4-078 | Mechanorimon | Mekanorimon | +| 4-079 | Meramon | Meramon | +| 4-080 | Meramon X | Meramon X | +| 4-081 | Mimicmon | Mimicmon | +| 4-082 | Minotaurmon | Minotarumon | +| 4-083 | Mojyamon | Mojyamon | +| 4-084 | Monochromon | Monochromon | +| 4-085 | Nanimon | Nanimon | +| 4-086 | Nefertimon X | Nefertimon X | +| 4-087 | Igamon | Ninjamon | +| 4-088 | Numemon | Numemon | +| 4-089 | Numemon X | Numemon X | +| 4-090 | Octmon | Octomon | +| 4-091 | Orgemon | Ogremon | +| 4-092 | Orgemon X | Ogremon X | +| 4-093 | Omekamon | Omekamon | +| 4-094 | Paledramon | Paledramon | +| 4-095 | Parasaurmon | Parasaurmon | +| 4-096 | Peckmon | Peckmon | +| 4-097 | Pegasmon X | Pegasusmon X | +| 4-098 | Petermon | Petermon | +| 4-099 | Porcupamon | Porcupamon | +| 4-100 | Pteranomon X | Pteramon X | +| 4-101 | Raremon | Raremon | +| 4-102 | RedVegimon | RedVegiemon | +| 4-103 | Reppamon | Reppamon | +| 4-104 | Rhinomon X | Rhinomon X | +| 4-105 | Gokimon | Roachmon | +| 4-106 | Sangloupmon | Sangloupmon | +| 4-107 | Seadramon | Seadramon | +| 4-108 | Seadramon X | Seadramon X | +| 4-109 | Siesamon X | Seasarmon X | +| 4-110 | Shadramon | Shadramon | +| 4-111 | Shellmon | Shellmon | +| 4-112 | KaratukiNumemon | ShellNumemon | +| 4-113 | Shoutmon X3 | Shoutmon X3 | +| 4-114 | Sistermon Ciel | Sistermon Ciel | +| 4-115 | Starmon | Starmon | +| 4-116 | Stingmon | Stingmon | +| 4-117 | Strikedramon | Strikedramon | +| 4-118 | Submarimon | Submarimon | +| 4-119 | Scumon | Sukamon | +| 4-120 | Sunflowmon | Sunflowmon | +| 4-121 | Tankmon | Tankmon | +| 4-122 | Targetmon | Targetmon | +| 4-123 | Thunderballmon | Thundermon | +| 4-124 | TiaLudomon | TiaLudomon | +| 4-125 | Tobiumon | Tobiumon | +| 4-126 | Tobucatmon | TobuCatmon | +| 4-127 | Togemon | Togemon | +| 4-128 | Togemon X | Togemon X | +| 4-129 | Tortamon | Tortomon | +| 4-130 | Troopmon | Troopmon | +| 4-131 | Turuiemon | Turuiemon | +| 4-132 | Tuskmon | Tuskmon | +| 4-133 | Tylomon | Tylomon | +| 4-134 | Tylomon X | Tylomon X | +| 4-135 | Tyranomon | Tyrannomon | +| 4-136 | Tyranomon X | Tyrannomon X | +| 4-137 | Unimon | Unimon | +| 4-138 | V-dramon | Veedramon | +| 4-139 | Vegimon | Vegiemon | +| 4-140 | Velgrmon | Velgrmon | +| 4-141 | Whamon | Whamon | +| 4-142 | Witchmon | Witchmon | +| 4-143 | Wizarmon | Wizardmon | +| 4-144 | Wizarmon X | Wizardmon X | +| 4-145 | Woodmon | Woodmon | +| 4-146 | Zubaeagermon | ZubaEagermon | +| 4-147 | Bulkmon | Bulkmon | +| 4-148 | Fugamon | Fugamon | +| 4-149 | Hyougamon | Hyogamon | +| 4-150 | Pidmon | Piddomon | +| 4-151 | Rhinomon | Rhinomon | +| 4-152 | Saberdramon | Saberdramon | +| 5-001 | AeroV-dramon | AeroVeedramon | +| 5-002 | Aldamon | Aldamon | +| 5-003 | Andromon | Andromon | +| 5-004 | Angewomon | Angewomon | +| 5-005 | Angewomon X | Angewomon X | +| 5-006 | Andiramon | Antylamon | +| 5-007 | Algomon | Argomon | +| 5-008 | Archnemon | Arukenimon | +| 5-009 | Astamon | Astamon | +| 5-010 | Asuramon | Asuramon | +| 5-011 | Baalmon | Baalmon | +| 5-012 | Beowolfmon | Beowolfmon | +| 5-013 | BigMamemon | BigMamemon | +| 5-014 | Blossomon | Blossomon | +| 5-015 | Valvemon | Bulbmon | +| 5-016 | Vritramon | BurningGreymon | +| 5-017 | CannonBeemon | CannonBeemon | +| 5-018 | Cerberumon | Cerberusmon | +| 5-019 | Cerberumon X | Cerberusmon X | +| 5-020 | Jyureimon | Cherrymon | +| 5-021 | Cho-Hakkaimon | Cho-Hakkaimon | +| 5-022 | Crescemon | Crescemon | +| 5-023 | Yatagaramon | Crowmon | +| 5-024 | CrysPaledramon | CrysPaledramon | +| 5-025 | Cyberdramon | Cyberdramon | +| 5-026 | Cyberdramon X | Cyberdramon X | +| 5-027 | DarkKnightmon | DarkKnightmon | +| 5-028 | Nanomon | Datamon | +| 5-029 | Delumon | Deramon | +| 5-030 | Digitamamon | Digitamamon | +| 5-031 | Dinobeemon | Dinobeemon | +| 5-032 | Hangyomon | Divermon | +| 5-033 | NeoDevimon | DoneDevimon | +| 5-034 | DORUguremon | DoruGreymon | +| 5-035 | Dagomon | Dragomon | +| 5-036 | Duramon | Duramon | +| 5-037 | Entmon | Entmon | +| 5-038 | Eosmon | Eosmon | +| 5-039 | Etemon | Etemon | +| 5-040 | Ex-Tyranomon | ExTyrannomon | +| 5-041 | Flaremon | Flaremon | +| 5-042 | Gerbemon | Garbagemon | +| 5-043 | Garudamon | Garudamon | +| 5-044 | Garudamon X | Garudamon X | +| 5-045 | Giromon | Giromon | +| 5-046 | Gogmamon | Gogmamon | +| 5-047 | Grademon | Grademon | +| 5-048 | Groundramon | Groundramon | +| 5-049 | Gusokumon | Gusokumon | +| 5-050 | Hippogriffomon | HippoGryphonmon | +| 5-051 | Hisyaryumon | Hisyaryumon | +| 5-052 | Jyagamon | Jagamon | +| 5-053 | KaiserLeomon | JagerLoweemon | +| 5-054 | Jazarichmon | Jazarichmon | +| 5-055 | Jewelbeemon | JewelBeemon | +| 5-056 | Garummon | KendoGarurumon | +| 5-057 | Chimairamon | Kimeramon | +| 5-058 | Knightmon | Knightmon | +| 5-059 | LadyDevimon | LadyDevimon | +| 5-060 | LadyDevimon X | LadyDevimon X | +| 5-061 | Lavogaritamon | Lavogaritamon | +| 5-062 | Lilamon | Lilamon | +| 5-063 | Lilimon | Lillymon | +| 5-064 | Lilimon X | Lillymon X | +| 5-065 | LoaderLeomon | LoaderLeomon | +| 5-066 | Locomon | Locomon | +| 5-067 | Lucemon: Falldown Mode | Lucemon: Chaos Mode | +| 5-068 | MachGaogamon | MachGaogamon | +| 5-069 | HolyAngemon | MagnaAngemon | +| 5-070 | Mamemon | Mamemon | +| 5-071 | Mamemon X | Mamemon X | +| 5-072 | Mametyramon | Mametyramon | +| 5-073 | Mammon | Mammothmon | +| 5-074 | Mammon X | Mammothmon X | +| 5-075 | Manticoremon | Manticoremon | +| 5-076 | MarinChimairamon | MarineChimairamon | +| 5-077 | MarinDevimon | MarineDevimon | +| 5-078 | Meicrackmon | Maycrackmon | +| 5-079 | Meicrackmon: Vicious Mode | Maycrackmon: Vicious Mode | +| 5-080 | Megadramon | Megadramon | +| 5-081 | AtlurKabuterimon | MegaKabuterimon (Red) | +| 5-082 | MegaSeadramon | MegaSeadramon | +| 5-083 | MegaSeadramon X | MegaSeadramon X | +| 5-084 | Mephismon X | Mephistomon X | +| 5-085 | Mermaimon | Mermaimon | +| 5-086 | MetalGreymon | MetalGreymon | +| 5-087 | MetalGreymon (Vaccine) | MetalGreymon (Vaccine) | +| 5-088 | MetalGreymon (Virus) | MetalGreymon (Virus) | +| 5-089 | MetalGreymon (Virus) X | MetalGreymon (Virus) X | +| 5-090 | MetalGreymon X | MetalGreymon X | +| 5-091 | MetalMamemon | MetalMamemon | +| 5-092 | MetalFantomon | MetalPhantomon | +| 5-093 | MetalTyranomon | MetalTyrannomon | +| 5-094 | MetalTyranomon X | MetalTyrannomon X | +| 5-095 | Monzaemon | Monzaemon | +| 5-096 | Monzaemon X | Monzaemon X | +| 5-097 | Mummymon | Mummymon | +| 5-098 | Vamdemon | Myotismon | +| 5-099 | Vamdemon X | Myotismon X | +| 5-100 | NeoDevimon | NeoDevimon | +| 5-101 | Okuwamon | Okuwamon | +| 5-102 | Okuwamon X | Okuwamon X | +| 5-103 | OmegaShoutmon X | OmniShoutmon X | +| 5-104 | Orochimon | Orochimon | +| 5-105 | Paildramon | Paildramon | +| 5-106 | Parrotmon | Parrotmon | +| 5-107 | Fantomon | Phantomon | +| 5-108 | Piranimon | Piranimon | +| 5-109 | Piccolomon | Piximon | +| 5-110 | Pumpmon | Pumpkinmon | +| 5-111 | RaijiLudomon | RaijiLudomon | +| 5-112 | Rapidmon | Rapidmon | +| 5-113 | Rebellimon | Rebellimon | +| 5-114 | Raihimon | Rhihimon | +| 5-115 | RizeGreymon | RizeGreymon | +| 5-116 | RizeGreymon X | RizeGreymon X | +| 5-117 | SaviorHackmon | SaviorHuckmon | +| 5-118 | Anomalocarimon | Scorpiomon | +| 5-119 | Anomalocarimon X | Scorpiomon X | +| 5-120 | Shakkoumon | Shakkoumon | +| 5-121 | TonosamaGekomon | ShogunGekomon | +| 5-122 | Shoutmon X5 | Shoutmon X5 | +| 5-123 | Silphymon | Silphymon | +| 5-124 | Sirenmon | Sirenmon | +| 5-125 | SkullBaluchimon | SkullBaluchimon | +| 5-126 | SkullGreymon | SkullGreymon | +| 5-127 | DeathMeramon | SkullMeramon | +| 5-128 | Stiffilmon | Stefilmon | +| 5-129 | Tekkamon | Tekkamon | +| 5-130 | Toropiamon | Toropiamon | +| 5-131 | Triceramon | Triceramon | +| 5-132 | Triceramon X | Triceramon X | +| 5-133 | Vademon | Vademon | +| 5-134 | Velgrmon | Velgrmon | +| 5-135 | MegaloGrowmon | WarGrowlmon | +| 5-136 | MegaloGrowmon X | WarGrowlmon X | +| 5-137 | WaruMonzaemon | WaruMonzaemon | +| 5-138 | WaruSeadramon | WaruSeadramon | +| 5-139 | WereGarurumon | WereGarurumon | +| 5-140 | WereGarurumon X | WereGarurumon X | +| 5-141 | Whamon | Whamon | +| 5-142 | Wingdramon | Wingdramon | +| 5-143 | Zudomon | Zudomon | +| 5-144 | Boutmon | Boutmon | +| 5-145 | DarkSuperStarmon | DarkSuperStarmon | +| 5-146 | DORUguremon | Doruguremon | +| 5-147 | Pandamon | Pandamon | +| 5-148 | Scorpiomon | SkullScorpiomon | +| 5-149 | SuperStarmon | SuperStarmon | +| 6-001 | Aegisdramon | Aegisdramon | +| 6-002 | Alphamon | Alphamon | +| 6-003 | AncientBeatmon | AncientBeetlemon | +| 6-004 | AncientGarurumon | AncientGarurumon | +| 6-005 | AncientGreymon | AncientGreymon | +| 6-006 | AncientIrismon | AncientKazemon | +| 6-007 | AncientMegatheriumon | AncientMegatheriummon | +| 6-008 | AncientMermaimon | AncientMermaimon | +| 6-009 | AncientSphinxmon | AncientSphinxmon | +| 6-010 | AncientTroiamon | AncientTroymon | +| 6-011 | AncientVolcamon | AncientVolcanomon | +| 6-012 | AncientWisemon | AncientWisemon | +| 6-013 | Anubimon | Anubismon | +| 6-014 | Apoclymon | Apocalymon | +| 6-015 | Apollomon | Apollomon | +| 6-016 | Algomon | Argomon | +| 6-017 | Armagemon | Armageddemon | +| 6-018 | Qinglongmon | Azulongmon | +| 6-019 | Bagramon | Bagramon | +| 6-020 | Baihumon | Baihumon | +| 6-021 | BanchoLeomon | BanchoLeomon | +| 6-022 | BanchoMamemon | BanchoMamemon | +| 6-023 | Barbamon | Barbamon | +| 6-024 | BeelStarmon X | BeelStarmon X | +| 6-025 | Beelzebumon | Beelzemon | +| 6-026 | Belphemon: Rage Mode | Belphemon: Rage Mode | +| 6-027 | BlackWarGreymon X | BlackWarGreymon X | +| 6-028 | BlitzGreymon | BlitzGreymon | +| 6-029 | Boltmon | Boltmon | +| 6-030 | Breakdramon | Breakdramon | +| 6-031 | BryweLudramon | BryweLudramon | +| 6-032 | Chaosdramon X | Chaosdramon X | +| 6-033 | Cherubimon (Virtue) | Cherubimon (Virtue) | +| 6-034 | Cherubimon (Virtue) X | Cherubimon (Virtue) X | +| 6-035 | Cherubimon (Vice) X | Cherubimon (Vice) X | +| 6-036 | Craniummon | Craniamon | +| 6-037 | Demon | Creepymon | +| 6-038 | CresGarurumon | CresGarurumon | +| 6-039 | Darkdramon | Darkdramon | +| 6-040 | DarkKnightmon X | DarkKnightmon X | +| 6-041 | Dianamon | Dianamon | +| 6-042 | Dinorexmon | Dinorexmon | +| 6-043 | Dinotigermon | Dinotigermon | +| 6-044 | Durandamon | Durandamon | +| 6-045 | Dynasmon | Dynasmon | +| 6-046 | Ebemon X | Ebemon X | +| 6-047 | Xuanwumon | Ebonwumon | +| 6-048 | KaiserGreymon | EmperorGreymon | +| 6-049 | Eosmon | Eosmon | +| 6-050 | Huanglongmon | Fanglongmon | +| 6-051 | Gaioumon | Gaiomon | +| 6-052 | Dukemon | Gallantmon | +| 6-053 | Dukemon X | Gallantmon X | +| 6-054 | Gankoomon | Gankoomon | +| 6-055 | Deathmon | Ghoulmon | +| 6-056 | GigaSeadramon | GigaSeadramon | +| 6-057 | Goddramon | Goldramon | +| 6-058 | Goddramon X | Goldramon X | +| 6-059 | GrandisKuwagamon | GrandisKuwagamon | +| 6-060 | GrandDracumon | GranDracmon | +| 6-061 | GranKuwagamon | GranKuwagamon | +| 6-062 | GrandLocomon | GroundLocomon | +| 6-063 | Griffomon | Gryphonmon | +| 6-064 | Gundramon | Gundramon | +| 6-065 | HeavyLeomon | HeavyLeomon | +| 6-066 | HerakleKabuterimon | HerculesKabuterimon | +| 6-067 | Hexeblaumon | Hexeblaumon | +| 6-068 | HiAndromon | HiAndromon | +| 6-069 | Jesmon | Jesmon | +| 6-070 | Justimon X | Justimon X | +| 6-071 | Justimon: Blitz Arm | Justimon: Blitz Arm | +| 6-072 | Sleipmon | Kentaurosmon | +| 6-073 | KingEtemon | KingEtemon | +| 6-074 | Duftmon | Leopardmon | +| 6-075 | Leviamon | Leviamon | +| 6-076 | Lilithmon | Lilithmon | +| 6-077 | LordKnightmon | LordKnightmon | +| 6-078 | Lotusmon | Lotosmon | +| 6-079 | Lucemon: Satan Mode | Lucemon: Satan Mode | +| 6-080 | Mugendramon | Machinedramon | +| 6-081 | Holydramon | Magnadramon | +| 6-082 | Holydramon X | Magnadramon X | +| 6-083 | MagnaGarurumon | MagnaGarurumon | +| 6-084 | Magnamon | Magnamon | +| 6-085 | BelialVamdemon | MaloMyotismon | +| 6-086 | MarinAngemon | MarineAngemon | +| 6-087 | SaintGalgomon | MegaGargomon | +| 6-088 | Megidramon | Megidramon | +| 6-089 | Megidramon X | Megidramon X | +| 6-090 | MetalEtemon | MetalEtemon | +| 6-091 | MetalGarurumon | MetalGarurumon | +| 6-092 | MetalGarurumon X | MetalGarurumon X | +| 6-093 | Metallicdramon | Metallicdramon | +| 6-094 | MetalPiranimon | MetalPiranimon | +| 6-095 | MetalSeadramon | MetalSeadramon | +| 6-096 | MirageGaogamon | MirageGaogamon | +| 6-097 | Murmukusmon | Murmukusmon | +| 6-098 | Nidhoggmon | Nidhoggmon | +| 6-099 | NoblePumpmon | NoblePumpkinmon | +| 6-100 | Ofanimon | Ophanimon | +| 6-101 | Ofanimon X | Ophanimon X | +| 6-102 | Ofanimon: Falldown Mode X | Ophanimon: Falldown Mode X | +| 6-103 | Ornismon | Ornismon | +| 6-104 | Ouryumon | Ouryumon | +| 6-105 | Hououmon | Phoenixmon | +| 6-106 | Hououmon X | Phoenixmon X | +| 6-107 | Piemon | Piedmon | +| 6-108 | PlatinumNumemon | PlatinumNumemon | +| 6-109 | Plesiomon | Plesiomon | +| 6-110 | Plesiomon X | Plesiomon X | +| 6-111 | PrinceMamemon X | PrinceMamemon X | +| 6-112 | Pukumon | Pukumon | +| 6-113 | Pinochimon | Puppetmon | +| 6-114 | Rafflesimon | Rafflesimon | +| 6-115 | Raguelmon | Raguelmon | +| 6-116 | Rapidmon X | Rapidmon X | +| 6-117 | Rasenmon | Rasenmon | +| 6-118 | Rasenmon: Fury Mode | Rasenmon: Fury Mode | +| 6-119 | Rasielmon | Rasielmon | +| 6-120 | Ravmon | Ravemon | +| 6-121 | Regalecusmon | Regalecusmon | +| 6-122 | Raihimon | Rhihimon | +| 6-123 | Rosemon | Rosemon | +| 6-124 | Rosemon X | Rosemon X | +| 6-125 | RustTyranomon | RustTyrannomon | +| 6-126 | SaberLeomon | SaberLeomon | +| 6-127 | Sakuyamon X | Sakuyamon X | +| 6-128 | Seraphimon | Seraphimon | +| 6-129 | ShineGreymon | ShineGreymon | +| 6-130 | SkullMammon | SkullMammothmon | +| 6-131 | SkullMammon X | SkullMammothmon X | +| 6-132 | SlashAngemon | SlashAngemon | +| 6-133 | Slayerdramon | Slayerdramon | +| 6-134 | Susanoomon | Susanomon | +| 6-135 | TigerVespamon | TigerVespamon | +| 6-136 | Titamon | Titamon | +| 6-137 | UlforceV-dramon | UlforceVeedramon | +| 6-138 | UltimateBrachiomon | UltimateBrachiomon | +| 6-139 | Valkyrimon | Valkyrimon | +| 6-140 | Valdurmon | Varodurumon | +| 6-141 | VenomVamdemon | VenomMyotismon | +| 6-142 | Volcanicdramon | Volcanicdramon | +| 6-143 | WarGreymon | WarGreymon | +| 6-144 | WarGreymon X | WarGreymon X | +| 6-145 | Zhuqiaomon | Zhuqiaomon | +| 6-146 | Vikemon | Vikemon | +| 6-147 | ShinMonzaemon | ShinMonzaemon | +| 6-148 | Kazuchimon | Kazuchimon | +| 6-149 | BanchoLilimon | BanchoLillymon | +| 6-150 | DORUgoramon | Dorugoramon | +| 7-001 | Alphamon: Ouryuken | Alphamon: Ouryuken | +| 7-002 | Armagemon | Armageddemon | +| 7-003 | Barbamon X | Barbamon X | +| 7-004 | Beelzebumon X | Beelzemon X | +| 7-005 | Beelzebumon: Blast Mode | Beelzemon: Blast Mode | +| 7-006 | Belphemon X | Belphemon X | +| 7-007 | Voltobautamon | Boltboutamon | +| 7-008 | Chaosmon | Chaosmon | +| 7-009 | Craniummon X | Craniamon X | +| 7-010 | Demon X | Creepymon X | +| 7-011 | DarknessBagramon | DarknessBagramon | +| 7-012 | Diablomon X | Diaboromon X | +| 7-013 | Dynasmon X | Dynasmon X | +| 7-014 | Examon | Examon | +| 7-015 | Examon X | Examon X | +| 7-016 | Dukemon X | Gallantmon X | +| 7-017 | Gankoomon X | Gankoomon X | +| 7-018 | GraceNovamon | GraceNovamon | +| 7-019 | Imperialdramon: Fighter Mode | Imperialdramon: Fighter Mode | +| 7-020 | Imperialdramon: Paladin Mode | Imperialdramon: Paladin Mode | +| 7-021 | Jesmon X | Jesmon X | +| 7-022 | Sleipmon X | Kentaurosmon X | +| 7-023 | Duftmon X | Leopardmon X | +| 7-024 | Leviamon X | Leviamon X | +| 7-025 | Lilithmon X | Lilithmon X | +| 7-026 | LordKnightmon X | LordKnightmon X | +| 7-027 | Lucemon X | Lucemon X | +| 7-028 | Magnamon X | Magnamon X | +| 7-029 | Mastemon | Mastemon | +| 7-030 | Minervamon X | Minervamon X | +| 7-031 | Ogudomon | Ogudomon | +| 7-032 | Omegamon | Omnimon | +| 7-033 | Omegamon Alter-S | Omnimon Alter-S | +| 7-034 | Omegamon X | Omnimon X | +| 7-035 | Ordinemon | Ordinemon | +| 7-036 | Rafflesimon | Rafflesimon | +| 7-037 | RagnaLordmon | RagnaLoardmon | +| 7-038 | Rasenmon | Rasenmon | +| 7-039 | RustTyranomon | RustTyrannomon | +| 7-040 | UlforceV-dramon X | UlforceVeedramon X | +| 7-041 | Agumon (Bond of Bravery) | Agumon (Bond of Bravery) | +| 7-042 | Gabumon (Bond of Friendship) | Gabumon (Bond of Friendship) | +| 7-043 | Jesmon GX | Jesmon GX | +| 7-044 | Ogudomon X | Ogudomon X | +| 7-045 | Chaosdramon | Chaosdramon | + +Check out the [Digimon Data List](https://github.com/jcendal/digital-pet/blob/main/packages/digital-pet-core/src/data/digimon-data.ts) for more information about evolution +lines. + +## Maintainer and contributors + +- [Jorge Cendal](https://github.com/jcendal), maintainer +- [Sergio Bugallo](https://github.com/sbugallo), original author and contributor + +## License + +This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details. + +## Attributions + +The bundled sprites were derived from the following sources and were later edited and +processed for this project: + +- [sundeth/omnipet](https://github.com/sundeth/omnipet/tree/main) +- [Tortoiseshel's Full Color Digimon Dot Sprites post](https://withthewill.net/threads/full-color-digimon-dot-sprites.25843/) on the [With the Will forum](https://withthewill.net/members/tortoiseshel.4767/) diff --git a/packages/opencode-digital-pet/bunfig.toml b/packages/opencode-digital-pet/bunfig.toml new file mode 100644 index 0000000..215183d --- /dev/null +++ b/packages/opencode-digital-pet/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["@opentui/solid/preload"] diff --git a/packages/opencode-digital-pet/esbuild.mjs b/packages/opencode-digital-pet/esbuild.mjs new file mode 100644 index 0000000..abf6913 --- /dev/null +++ b/packages/opencode-digital-pet/esbuild.mjs @@ -0,0 +1,107 @@ +import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises" +import { createRequire } from "node:module" +import { dirname, join, relative, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { spawnSync } from "node:child_process" + +import { build } from "esbuild" + +const packageRoot = dirname(fileURLToPath(import.meta.url)) +const distRoot = resolve(packageRoot, "dist") +const require = createRequire(import.meta.url) +const tscPath = require.resolve("typescript/bin/tsc") + +const stageSources = async (packageName) => { + const source = resolve(packageRoot, `../${packageName}/src`) + const target = resolve(packageRoot, `build/${packageName}-src`) + await rm(target, { recursive: true, force: true }) + await cp(source, target, { recursive: true }) +} + +const runTsc = (config) => { + const result = spawnSync(process.execPath, [tscPath, "-p", config], { + cwd: packageRoot, + stdio: "inherit", + }) + if (result.error) throw result.error + if (result.status !== 0) throw new Error(`Type declarations failed: ${config}`) +} + +const declarationFiles = async (directory) => { + const entries = await readdir(directory, { withFileTypes: true }) + const files = [] + for (const entry of entries) { + const path = join(directory, entry.name) + if (entry.isDirectory()) files.push(...(await declarationFiles(path))) + else if (entry.name.endsWith(".d.ts")) files.push(path) + } + return files +} + +const toJs = (specifier) => specifier.replace(/\.tsx?$/, ".js") + +const rewriteDeclarations = async () => { + for (const file of await declarationFiles(distRoot)) { + const source = await readFile(file, "utf8") + let rewritten = source.replace( + /(["'])(@jcendal\/digital-pet-(core|animation)\/([^"']+))(["'])/g, + (_match, opening, _specifier, packageName, subpath, closing) => { + const target = join(distRoot, `digital-pet-${packageName}`, toJs(subpath)) + const path = relative(dirname(file), target).replaceAll("\\", "/") + return `${opening}${path.startsWith(".") ? path : `./${path}`}${closing}` + }, + ) + rewritten = rewritten.replace( + /(["'])(\.\.?\/[^"']+\.tsx?)(["'])/g, + (_match, opening, path, closing) => `${opening}${toJs(path)}${closing}`, + ) + if (rewritten !== source) await writeFile(file, rewritten) + } +} + +await stageSources("digital-pet-core") +await stageSources("digital-pet-animation") +await rm(distRoot, { recursive: true, force: true }) +await mkdir(distRoot, { recursive: true }) + +await build({ + entryPoints: [ + resolve(packageRoot, "src/index.ts"), + resolve(packageRoot, "src/tui.tsx"), + resolve(packageRoot, "src/dev/attach-dev-tools.ts"), + ], + outbase: resolve(packageRoot, "src"), + outdir: distRoot, + bundle: true, + platform: "node", + target: "node24", + format: "esm", + splitting: false, + sourcemap: false, + external: [ + "bun:*", + "@opencode-ai/plugin", + "@opencode-ai/sdk", + "@opentui/core", + "@opentui/keymap", + "@opentui/solid", + "solid-js", + ], + logLevel: "info", +}) + +await build({ + entryPoints: [resolve(packageRoot, "src/cli.ts")], + outfile: resolve(distRoot, "cli.js"), + bundle: true, + platform: "node", + target: "node24", + format: "esm", + sourcemap: false, + logLevel: "info", +}) + +runTsc("tsconfig.build.json") +runTsc("tsconfig.digital-pet-core-declarations.json") +runTsc("tsconfig.digital-pet-animation-declarations.json") +await rewriteDeclarations() diff --git a/packages/opencode-digital-pet/package.json b/packages/opencode-digital-pet/package.json new file mode 100644 index 0000000..49134a0 --- /dev/null +++ b/packages/opencode-digital-pet/package.json @@ -0,0 +1,108 @@ +{ + "name": "@jcendal/opencode-digital-pet", + "version": "0.2.0-dev.0", + "description": "An OpenCode virtual-pet plugin with /digital-pet-spawn, /digital-pet-freeze, /digital-pet-unfreeze, /digital-pet-set, /digital-pet-dex, and /digital-pet-history commands.", + "author": "Jorge Cendal", + "contributors": [ + "Sergio Bugallo" + ], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/jcendal/digital-pet.git", + "directory": "packages/opencode-digital-pet" + }, + "homepage": "https://github.com/jcendal/digital-pet#readme", + "bugs": { + "url": "https://github.com/jcendal/digital-pet/issues" + }, + "keywords": [ + "opencode", + "plugin", + "virtual-pet", + "digital-pet", + "tui" + ], + "publishConfig": { + "access": "public" + }, + "oc-plugin": [ + "server", + "tui" + ], + "packageManager": "npm@11.6.1", + "engines": { + "bun": "1.3.5", + "node": "24.11.0", + "opencode": ">=1.18.10" + }, + "main": "./dist/index.js", + "bin": { + "opencode-digital-pet": "./dist/cli.js" + }, + "dependencies": { + "@jcendal/digital-pet-animation": "0.2.0-dev.0", + "@jcendal/digital-pet-core": "0.2.0-dev.0", + "solid-js": "1.9.12" + }, + "devDependencies": { + "@biomejs/biome": "2.5.10", + "@opencode-ai/plugin": "^1.18.10", + "@opencode-ai/sdk": "^1.18.10", + "@opentui/core": "^0.4.5", + "@opentui/keymap": "^0.4.5", + "@opentui/solid": "^0.4.5", + "@types/bun": "1.3.14", + "@types/node": "24.10.1", + "@types/semver": "7.7.1", + "semver": "7.7.4", + "typescript": "5.9.3" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.18.10 <2", + "@opentui/core": "^0.4.5", + "@opentui/keymap": "^0.4.5", + "@opentui/solid": "^0.4.5" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./server": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./tui": { + "types": "./dist/tui.d.ts", + "import": "./dist/tui.js", + "default": "./dist/tui.js" + } + }, + "files": [ + "dist", + "!dist/**/*.map", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "scripts": { + "test": "bun test", + "test:persistence": "bun test ./tests/persistence/sqlite-migrations.persistence.ts ./tests/persistence/sqlite-digital-pet-repository.persistence.ts ./tests/persistence/sqlite-digital-pet-control.persistence.ts ./tests/persistence/sqlite-usage-event-toast.persistence.test.ts", + "format": "biome format --write src tests scripts package.json", + "format:check": "biome format src tests scripts package.json", + "lint": "biome lint src tests scripts package.json", + "check": "biome ci src tests scripts package.json && npm run typecheck && npm run typecheck:scripts && npm run typecheck:tests", + "typecheck": "tsc --noEmit", + "typecheck:scripts": "tsc -p tsconfig.scripts.json --noEmit", + "typecheck:tests": "tsc -p tsconfig.test.json --noEmit", + "build": "node esbuild.mjs", + "prepack": "node --experimental-strip-types scripts/prepack.ts", + "postpack": "node --experimental-strip-types scripts/postpack.ts", + "release:check": "npm run check && npm run test && npm run test:persistence && npm pack --dry-run --json" + }, + "type": "module", + "types": "./dist/index.d.ts" +} diff --git a/packages/opencode-digital-pet/scripts/postpack.ts b/packages/opencode-digital-pet/scripts/postpack.ts new file mode 100644 index 0000000..d401f30 --- /dev/null +++ b/packages/opencode-digital-pet/scripts/postpack.ts @@ -0,0 +1,14 @@ +import { readFile, rm, stat } from "node:fs/promises" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +import { restorePackageJson } from "./strip-internal-deps.ts" + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const backupPath = resolve(packageRoot, ".package.json.publish.bak") + +if ((await stat(backupPath).catch(() => undefined)) !== undefined) { + const originalSource = await readFile(backupPath, "utf8") + await restorePackageJson(packageRoot, originalSource) + await rm(backupPath, { force: true }) +} diff --git a/packages/opencode-digital-pet/scripts/prepack.ts b/packages/opencode-digital-pet/scripts/prepack.ts new file mode 100644 index 0000000..cd2cfb2 --- /dev/null +++ b/packages/opencode-digital-pet/scripts/prepack.ts @@ -0,0 +1,73 @@ +import { spawnSync } from "node:child_process" +import { readFile, rm, stat, writeFile } from "node:fs/promises" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +import { restorePackageJson, stripInternalDependencies } from "./strip-internal-deps.ts" + +type JsonObject = { readonly [key: string]: unknown } + +const isJsonObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value) + +const collectPaths = (value: unknown, paths: Set): void => { + if (typeof value === "string") { + paths.add(value) + return + } + if (!isJsonObject(value)) return + for (const nestedValue of Object.values(value)) collectPaths(nestedValue, paths) +} + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const backupPath = resolve(packageRoot, ".package.json.publish.bak") +const exists = async (path: string): Promise => (await stat(path).catch(() => undefined)) !== undefined + +if (await exists(backupPath)) { + await restorePackageJson(packageRoot, await readFile(backupPath, "utf8")) + await rm(backupPath, { force: true }) +} + +const originalSource = await stripInternalDependencies(packageRoot) +await writeFile(backupPath, originalSource) + +try { + const build = spawnSync(process.execPath, ["esbuild.mjs"], { cwd: packageRoot, stdio: "inherit" }) + if (build.error) throw build.error + if (build.status !== 0) throw new Error("OpenCode build failed during prepack.") + + const packageJson: unknown = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8")) + if (!isJsonObject(packageJson)) throw new Error("package.json must contain an object.") + + const publishedPaths = new Set() + collectPaths(packageJson.main, publishedPaths) + collectPaths(packageJson.types, publishedPaths) + collectPaths(packageJson.exports, publishedPaths) + collectPaths(packageJson.bin, publishedPaths) + + for (const publishedPath of publishedPaths) { + const artifactPath = resolve(packageRoot, publishedPath) + const artifact = await stat(artifactPath).catch(() => undefined) + if (artifact === undefined || !artifact.isFile() || artifact.size === 0) { + throw new Error(`Published artifact is missing or empty: ${publishedPath}`) + } + } + + const binPaths = new Set() + collectPaths(packageJson.bin, binPaths) + const cliPath = binPaths.values().next().value + if (typeof cliPath !== "string") throw new Error("Package metadata does not define a bin path.") + + const cliResult = spawnSync(process.execPath, [resolve(packageRoot, cliPath), "--help"], { + cwd: packageRoot, + encoding: "utf8", + }) + if (cliResult.error) throw cliResult.error + if (cliResult.status !== 0) { + throw new Error(`Node CLI help failed: ${cliPath}\n${cliResult.stderr}`) + } +} catch (error) { + await restorePackageJson(packageRoot, originalSource) + await rm(backupPath, { force: true }) + throw error +} diff --git a/packages/opencode-digital-pet/scripts/strip-internal-deps.ts b/packages/opencode-digital-pet/scripts/strip-internal-deps.ts new file mode 100644 index 0000000..481b461 --- /dev/null +++ b/packages/opencode-digital-pet/scripts/strip-internal-deps.ts @@ -0,0 +1,37 @@ +import { readFile, writeFile } from "node:fs/promises" +import { resolve } from "node:path" + +type JsonObject = { readonly [key: string]: unknown } + +const isJsonObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value) + +const INTERNAL_PACKAGES = new Set(["@jcendal/digital-pet-core", "@jcendal/digital-pet-animation"]) + +const withoutInternalDependencies = (dependencies: unknown): JsonObject | undefined => { + if (!isJsonObject(dependencies)) return undefined + const filtered = Object.fromEntries(Object.entries(dependencies).filter(([name]) => !INTERNAL_PACKAGES.has(name))) + return Object.keys(filtered).length > 0 ? filtered : undefined +} + +export const stripInternalDependencies = async (packageRoot: string): Promise => { + const packageJsonPath = resolve(packageRoot, "package.json") + const originalSource = await readFile(packageJsonPath, "utf8") + const packageMetadata = JSON.parse(originalSource) as JsonObject + if (!isJsonObject(packageMetadata)) throw new Error("package.json must contain an object.") + + const dependencies = withoutInternalDependencies(packageMetadata["dependencies"]) + const publishMetadata = { ...packageMetadata } as Record + if (dependencies === undefined) { + delete publishMetadata["dependencies"] + } else { + publishMetadata["dependencies"] = dependencies + } + + await writeFile(packageJsonPath, `${JSON.stringify(publishMetadata, null, 2)}\n`) + return originalSource +} + +export const restorePackageJson = async (packageRoot: string, originalSource: string): Promise => { + await writeFile(resolve(packageRoot, "package.json"), originalSource) +} diff --git a/src/adapters/opencode/create-server-hooks.ts b/packages/opencode-digital-pet/src/adapters/opencode/create-server-hooks.ts similarity index 56% rename from src/adapters/opencode/create-server-hooks.ts rename to packages/opencode-digital-pet/src/adapters/opencode/create-server-hooks.ts index ea95956..94f49b8 100644 --- a/src/adapters/opencode/create-server-hooks.ts +++ b/packages/opencode-digital-pet/src/adapters/opencode/create-server-hooks.ts @@ -1,46 +1,53 @@ import type { Hooks } from "@opencode-ai/plugin" -import { runVpetFreezeCommand } from "../../commands/vpet-freeze.ts" -import { runVpetSpawnCommand } from "../../commands/vpet-spawn.ts" -import { runVpetSetCommand } from "../../commands/vpet-set.ts" -import { runVpetUnfreezeCommand } from "../../commands/vpet-unfreeze.ts" -import type { VpetCommandEvent } from "../../commands/vpet-command-result.ts" -import { loadDigimonCatalog } from "../../data/catalog.ts" -import type { VpetLanguage } from "../../config/types.ts" -import type { DigimonCatalog } from "../../data/catalog.ts" -import type { PartnerLifecycle } from "../../application/ports/partner-lifecycle.ts" -import type { UsageLedger } from "../../application/ports/usage-ledger.ts" -import type { VpetControl } from "../../application/ports/vpet-control.ts" -import type { UsageProcessingResult } from "../../application/models/usage.ts" -import { reconcileUsage } from "../../application/use-cases/reconcile-usage.ts" -import { recordUsage } from "../../application/use-cases/record-usage.ts" -import { STAGE_GAUGE_THRESHOLDS, type EvolutionSelector, type StageThresholds } from "../../domain/evolution.ts" +import { runDigitalPetFreezeCommand } from "../../commands/digital-pet-freeze.ts" +import { runDigitalPetSpawnCommand } from "../../commands/digital-pet-spawn.ts" +import { runDigitalPetSetCommand } from "../../commands/digital-pet-set.ts" +import { runDigitalPetUnfreezeCommand } from "../../commands/digital-pet-unfreeze.ts" +import type { DigitalPetCommandEvent } from "../../commands/digital-pet-command-result.ts" +import { loadDigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { DigitalPetLanguage } from "@jcendal/digital-pet-core/config/types.ts" +import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { PartnerLifecycle } from "@jcendal/digital-pet-core/application/ports/partner-lifecycle.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" +import type { UsageProcessingResult } from "@jcendal/digital-pet-core/application/models/usage.ts" +import { reconcileUsage } from "@jcendal/digital-pet-core/application/use-cases/reconcile-usage.ts" +import { recordUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import { + STAGE_GAUGE_THRESHOLDS, + type EvolutionSelector, + type StageThresholds, +} from "@jcendal/digital-pet-core/domain/evolution.ts" import type { SessionMessagesFetcher } from "./session-messages.ts" import { toCompletedUsageFromEvent, toCompletedUsageFromMessage } from "./usage-event-mapper.ts" -import { formatVpetToast, type VpetToastEvent, type VpetToastNotifier } from "./vpet-toast.ts" +import { formatDigitalPetToast, type DigitalPetToastEvent, type DigitalPetToastNotifier } from "./digital-pet-toast.ts" export type ServerResource = { close(): Promise } export type ServerHookDependencies = { - readonly repository: PartnerLifecycle & UsageLedger & VpetControl + readonly repository: PartnerLifecycle & UsageLedger & DigitalPetControl readonly resource: ServerResource readonly evolutionSelector?: EvolutionSelector readonly evolutionThresholds?: StageThresholds readonly fetchMessages?: SessionMessagesFetcher - readonly language?: VpetLanguage + readonly language?: DigitalPetLanguage readonly notificationsEnabled?: boolean readonly loadCatalog?: () => Promise - readonly notify?: VpetToastNotifier + readonly notify?: DigitalPetToastNotifier } -const noOpNotifier: VpetToastNotifier = async () => {} +const noOpNotifier: DigitalPetToastNotifier = async () => {} const EMPTY_CATALOG: DigimonCatalog = Object.freeze({ nodes: Object.freeze([]), byId: new Map() }) -const toToastEvent = (result: UsageProcessingResult): VpetToastEvent | undefined => { +const toToastEvent = (result: UsageProcessingResult): DigitalPetToastEvent | undefined => { switch (result.kind) { case "applied": + if (result.evolutionBattlePending !== undefined) { + return { kind: "evolution_battle", opponentNodeId: result.evolutionBattlePending.opponentNodeId } + } return result.evolution === undefined ? undefined : { kind: "evolution", ...result.evolution } case "duplicate": case "no_active_partner": @@ -52,7 +59,7 @@ const toToastEvent = (result: UsageProcessingResult): VpetToastEvent | undefined } } -const toCommandToastEvent = (event: VpetCommandEvent): VpetToastEvent => { +const toCommandToastEvent = (event: DigitalPetCommandEvent): DigitalPetToastEvent => { switch (event.kind) { case "spawned": return { kind: "spawn", nodeId: event.nodeId, generation: event.generation } @@ -65,23 +72,25 @@ const toCommandToastEvent = (event: VpetCommandEvent): VpetToastEvent => { } } -const requiresCatalog = (event: VpetToastEvent): boolean => { +const requiresCatalog = (event: DigitalPetToastEvent): boolean => { switch (event.kind) { case "spawn": case "set": case "evolution": + case "evolution_battle": return true case "freeze": case "unfreeze": + case "defeat": return false } } export const createCommandConfig = () => ({ - "vpet-spawn": { template: "Spawn a new virtual pet." }, - "vpet-freeze": { template: "Freeze virtual pet progression." }, - "vpet-unfreeze": { template: "Unfreeze virtual pet progression." }, - "vpet-set": { template: "Set the virtual pet to a Digimon ID: $ARGUMENTS" }, + "digital-pet-spawn": { template: "Spawn a new virtual pet." }, + "digital-pet-freeze": { template: "Freeze virtual pet progression." }, + "digital-pet-unfreeze": { template: "Unfreeze virtual pet progression." }, + "digital-pet-set": { template: "Set the virtual pet to a Digimon ID: $ARGUMENTS" }, }) export const createServerHooks = ({ @@ -97,12 +106,25 @@ export const createServerHooks = ({ }: ServerHookDependencies): Hooks => { let closePromise: Promise | undefined - const notifyEvent = async (event: VpetToastEvent, catalog?: DigimonCatalog): Promise => { + const notifyUsageResult = async (result: UsageProcessingResult, catalog: DigimonCatalog): Promise => { + if (result.kind !== "applied") return + + if (result.evolutionBattlePending !== undefined) { + const battleToast = toToastEvent(result) + if (battleToast !== undefined) await notifyEvent(battleToast, catalog) + return + } + + const toastEvent = toToastEvent(result) + if (toastEvent !== undefined) await notifyEvent(toastEvent, catalog) + } + + const notifyEvent = async (event: DigitalPetToastEvent, catalog?: DigimonCatalog): Promise => { if (!notificationsEnabled) return await Promise.resolve() .then(async () => { const resolvedCatalog = catalog ?? (requiresCatalog(event) ? await loadCatalog() : EMPTY_CATALOG) - const payload = formatVpetToast(event, language, resolvedCatalog) + const payload = formatDigitalPetToast(event, language, resolvedCatalog) if (payload !== undefined) await notify(payload) }) .then( @@ -119,11 +141,11 @@ export const createServerHooks = ({ } }, async "command.execute.before"(input, output) { - const messageID = `vpet-${input.command}-${input.sessionID}-${crypto.randomUUID()}` + const messageID = `digital-pet-${input.command}-${input.sessionID}-${crypto.randomUUID()}` switch (input.command) { - case "vpet-spawn": + case "digital-pet-spawn": { - const result = await runVpetSpawnCommand(repository, { + const result = await runDigitalPetSpawnCommand(repository, { sessionID: input.sessionID, messageID, createdAt: new Date().toISOString(), @@ -132,25 +154,25 @@ export const createServerHooks = ({ if (result.event !== undefined) await notifyEvent(toCommandToastEvent(result.event)) } return - case "vpet-freeze": + case "digital-pet-freeze": { - const result = await runVpetFreezeCommand(repository, { sessionID: input.sessionID, messageID }) + const result = await runDigitalPetFreezeCommand(repository, { sessionID: input.sessionID, messageID }) output.parts.push(...result.parts) if (result.event !== undefined) await notifyEvent(toCommandToastEvent(result.event)) } return - case "vpet-unfreeze": + case "digital-pet-unfreeze": { - const result = await runVpetUnfreezeCommand(repository, { sessionID: input.sessionID, messageID }) + const result = await runDigitalPetUnfreezeCommand(repository, { sessionID: input.sessionID, messageID }) output.parts.push(...result.parts) if (result.event !== undefined) await notifyEvent(toCommandToastEvent(result.event)) } return - case "vpet-set": + case "digital-pet-set": { let catalog: DigimonCatalog | undefined const loadCommandCatalog = async (): Promise => (catalog ??= await loadCatalog()) - const result = await runVpetSetCommand( + const result = await runDigitalPetSetCommand( repository, { sessionID: input.sessionID, messageID, arguments: input.arguments }, loadCommandCatalog, @@ -174,11 +196,11 @@ export const createServerHooks = ({ usage, ledger: repository, digimonById: catalog.byId, + catalogNodes: catalog.nodes, selector: evolutionSelector, thresholds: evolutionThresholds, }) - const toastEvent = toToastEvent(result) - if (toastEvent !== undefined) await notifyEvent(toastEvent, catalog) + await notifyUsageResult(result, catalog) return } case "session.idle": { @@ -193,12 +215,12 @@ export const createServerHooks = ({ usages, ledger: repository, digimonById: catalog.byId, + catalogNodes: catalog.nodes, selector: evolutionSelector, thresholds: evolutionThresholds, }) for (const result of results) { - const toastEvent = toToastEvent(result) - if (toastEvent !== undefined) await notifyEvent(toastEvent, catalog) + await notifyUsageResult(result, catalog) } return } diff --git a/src/adapters/opencode/vpet-toast.ts b/packages/opencode-digital-pet/src/adapters/opencode/digital-pet-toast.ts similarity index 50% rename from src/adapters/opencode/vpet-toast.ts rename to packages/opencode-digital-pet/src/adapters/opencode/digital-pet-toast.ts index beda2a6..10ad33f 100644 --- a/src/adapters/opencode/vpet-toast.ts +++ b/packages/opencode-digital-pet/src/adapters/opencode/digital-pet-toast.ts @@ -1,29 +1,31 @@ -import type { VpetLanguage } from "../../config/types.ts" -import type { DigimonCatalog, DigimonNode } from "../../data/catalog.ts" +import type { DigitalPetLanguage } from "@jcendal/digital-pet-core/config/types.ts" +import type { DigimonCatalog, DigimonNode } from "@jcendal/digital-pet-core/data/catalog.ts" -export type VpetToastPayload = Readonly<{ +export type DigitalPetToastPayload = Readonly<{ readonly title: string readonly message: string readonly variant: "info" | "success" readonly duration: number }> -export type VpetToastEvent = +export type DigitalPetToastEvent = | Readonly<{ readonly kind: "spawn"; readonly nodeId: string; readonly generation: number }> | Readonly<{ readonly kind: "freeze" }> | Readonly<{ readonly kind: "unfreeze" }> | Readonly<{ readonly kind: "set"; readonly nodeId: string }> | Readonly<{ readonly kind: "evolution"; readonly fromNodeId: string; readonly toNodeId: string }> + | Readonly<{ readonly kind: "evolution_battle"; readonly opponentNodeId: string }> + | Readonly<{ readonly kind: "defeat" }> -export type VpetToastNotifier = (payload: VpetToastPayload) => Promise +export type DigitalPetToastNotifier = (payload: DigitalPetToastPayload) => Promise -export type VpetToastTransport = (payload: VpetToastPayload) => boolean | Promise +export type DigitalPetToastTransport = (payload: DigitalPetToastPayload) => boolean | Promise const assertNever = (value: never): never => { - throw new Error(`Unhandled VPet toast event: ${JSON.stringify(value)}`) + throw new Error(`Unhandled Digital Pet toast event: ${JSON.stringify(value)}`) } -const selectName = (node: DigimonNode, language: VpetLanguage): string => { +const selectName = (node: DigimonNode, language: DigitalPetLanguage): string => { switch (language) { case "en": return node.nameEn @@ -34,39 +36,39 @@ const selectName = (node: DigimonNode, language: VpetLanguage): string => { } } -const namedPayload = (nodeId: string, language: VpetLanguage, catalog: DigimonCatalog): string | undefined => { +const namedPayload = (nodeId: string, language: DigitalPetLanguage, catalog: DigimonCatalog): string | undefined => { const node = catalog.byId.get(nodeId) return node === undefined ? undefined : selectName(node, language) } -export const formatVpetToast = ( - event: VpetToastEvent, - language: VpetLanguage, +export const formatDigitalPetToast = ( + event: DigitalPetToastEvent, + language: DigitalPetLanguage, catalog: DigimonCatalog, -): VpetToastPayload | undefined => { +): DigitalPetToastPayload | undefined => { switch (event.kind) { case "spawn": { const name = namedPayload(event.nodeId, language, catalog) return name === undefined ? undefined : { - title: "VPet", + title: "Digital Pet", message: `Spawned ${name} (Generation ${event.generation}).`, variant: "success", duration: 5_000, } } case "freeze": - return { title: "VPet", message: "Digimon progression frozen.", variant: "info", duration: 3_000 } + return { title: "Digital Pet", message: "Digimon progression frozen.", variant: "info", duration: 3_000 } case "unfreeze": - return { title: "VPet", message: "Digimon progression resumed.", variant: "info", duration: 3_000 } + return { title: "Digital Pet", message: "Digimon progression resumed.", variant: "info", duration: 3_000 } case "set": { const name = namedPayload(event.nodeId, language, catalog) return name === undefined ? undefined : { - title: "VPet", - message: `VPet set to ${name} (${event.nodeId}).`, + title: "Digital Pet", + message: `Digital Pet set to ${name} (${event.nodeId}).`, variant: "info", duration: 3_000, } @@ -83,12 +85,32 @@ export const formatVpetToast = ( duration: 5_000, } } + case "evolution_battle": { + const opponentName = namedPayload(event.opponentNodeId, language, catalog) + return opponentName === undefined + ? undefined + : { + title: "Evolution battle", + message: `Battle against ${opponentName}! Win to evolve.`, + variant: "info", + duration: 5_000, + } + } + case "defeat": + return { + title: "Defeat", + message: "Defeat! You lost all tokens for this stage.", + variant: "info", + duration: 5_000, + } default: return assertNever(event) } } -export const createBestEffortVpetToastNotifier = (transport: VpetToastTransport): VpetToastNotifier => { +export const createBestEffortDigitalPetToastNotifier = ( + transport: DigitalPetToastTransport, +): DigitalPetToastNotifier => { return async (payload) => await Promise.resolve() .then(() => transport(payload)) diff --git a/src/adapters/opencode/session-messages.ts b/packages/opencode-digital-pet/src/adapters/opencode/session-messages.ts similarity index 100% rename from src/adapters/opencode/session-messages.ts rename to packages/opencode-digital-pet/src/adapters/opencode/session-messages.ts diff --git a/src/adapters/opencode/usage-event-mapper.ts b/packages/opencode-digital-pet/src/adapters/opencode/usage-event-mapper.ts similarity index 91% rename from src/adapters/opencode/usage-event-mapper.ts rename to packages/opencode-digital-pet/src/adapters/opencode/usage-event-mapper.ts index fc90b92..a8f4445 100644 --- a/src/adapters/opencode/usage-event-mapper.ts +++ b/packages/opencode-digital-pet/src/adapters/opencode/usage-event-mapper.ts @@ -1,6 +1,6 @@ import type { AssistantMessage, Event, Message } from "@opencode-ai/sdk" -import type { CompletedUsage } from "../../application/use-cases/record-usage.ts" +import type { CompletedUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" const isCompletedAssistantMessage = (message: Message): message is AssistantMessage => { return message.role === "assistant" && "time" in message && message.time.completed !== undefined diff --git a/src/adapters/sqlite/bun-sqlite-driver.ts b/packages/opencode-digital-pet/src/adapters/sqlite/bun-sqlite-driver.ts similarity index 77% rename from src/adapters/sqlite/bun-sqlite-driver.ts rename to packages/opencode-digital-pet/src/adapters/sqlite/bun-sqlite-driver.ts index 094bc24..f97d463 100644 --- a/src/adapters/sqlite/bun-sqlite-driver.ts +++ b/packages/opencode-digital-pet/src/adapters/sqlite/bun-sqlite-driver.ts @@ -2,14 +2,9 @@ import { mkdirSync } from "node:fs" import { dirname } from "node:path" import { Database } from "bun:sqlite" -export type QueryValue = string | number | boolean | null +import type { QueryValue, SqliteExecutor } from "@jcendal/digital-pet-core/ports/sqlite-executor.ts" -export type SqliteExecutor = { - run: (sql: string, params?: readonly QueryValue[]) => void - get: >(sql: string, params?: readonly QueryValue[]) => TRow | null - all: >(sql: string, params?: readonly QueryValue[]) => readonly TRow[] - transaction: (operation: () => TReturn) => TReturn -} +export type { QueryValue, SqliteExecutor } from "@jcendal/digital-pet-core/ports/sqlite-executor.ts" export const createExecutor = (database: Database): SqliteExecutor => ({ run(sql, params = []) { diff --git a/src/adapters/sqlite/sqlite-vpet-archive-reader.ts b/packages/opencode-digital-pet/src/adapters/sqlite/sqlite-digital-pet-archive-reader.ts similarity index 59% rename from src/adapters/sqlite/sqlite-vpet-archive-reader.ts rename to packages/opencode-digital-pet/src/adapters/sqlite/sqlite-digital-pet-archive-reader.ts index d64076b..40e18a2 100644 --- a/src/adapters/sqlite/sqlite-vpet-archive-reader.ts +++ b/packages/opencode-digital-pet/src/adapters/sqlite/sqlite-digital-pet-archive-reader.ts @@ -1,20 +1,27 @@ import { existsSync } from "node:fs" import { SQLiteError } from "bun:sqlite" -import type { VpetArchiveEvent, VpetArchivePartner, VpetArchiveResult } from "../../application/models/vpet-archive.ts" -import type { VpetArchiveReader } from "../../application/ports/vpet-archive.ts" -import { resolveHostDatabasePath, type HostPathOptions } from "./app-data-path.ts" +import type { + DigitalPetArchiveEvent, + DigitalPetArchivePartner, + DigitalPetArchiveResult, +} from "@jcendal/digital-pet-core/application/models/digital-pet-archive.ts" +import type { DigitalPetArchiveReader } from "@jcendal/digital-pet-core/application/ports/digital-pet-archive.ts" +import { + resolveHostDatabasePath, + type HostPathOptions, +} from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" import { createExecutor, openReadonlyDatabase, type SqliteExecutor } from "./bun-sqlite-driver.ts" import { ARCHIVE_PARTNER_EVENTS_SELECT, ARCHIVE_PARTNERS_SELECT, type PersistedPartnerEventRow, type PersistedPartnerRow, -} from "./sqlite-vpet-schema.ts" +} from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-schema.ts" -export type CreateSqliteVpetArchiveReaderOptions = HostPathOptions & { readonly databasePath?: string } +export type CreateSqliteDigitalPetArchiveReaderOptions = HostPathOptions & { readonly databasePath?: string } -const UNAVAILABLE_ARCHIVE_MESSAGE = "VPet archive is unavailable." +const UNAVAILABLE_ARCHIVE_MESSAGE = "Digital Pet archive is unavailable." export const isRecoverableSqliteReadError = (error: unknown): error is SQLiteError => { return ( @@ -24,13 +31,16 @@ export const isRecoverableSqliteReadError = (error: unknown): error is SQLiteErr ) } -const toArchiveEvent = (row: PersistedPartnerEventRow): VpetArchiveEvent => ({ +const toArchiveEvent = (row: PersistedPartnerEventRow): DigitalPetArchiveEvent => ({ eventId: row.event_id, currentNodeId: row.current_node_id, createdAt: row.created_at, }) -const toArchivePartner = (row: PersistedPartnerRow, events: readonly VpetArchiveEvent[]): VpetArchivePartner => ({ +const toArchivePartner = ( + row: PersistedPartnerRow, + events: readonly DigitalPetArchiveEvent[], +): DigitalPetArchivePartner => ({ partnerId: row.partner_id, generation: row.generation, createdAt: row.created_at, @@ -40,8 +50,8 @@ const toArchivePartner = (row: PersistedPartnerRow, events: readonly VpetArchive const groupEventsByPartnerId = ( rows: readonly PersistedPartnerEventRow[], -): ReadonlyMap => { - const eventsByPartnerId = new Map() +): ReadonlyMap => { + const eventsByPartnerId = new Map() for (const row of rows) { const events = eventsByPartnerId.get(row.partner_id) if (events === undefined) { @@ -53,7 +63,10 @@ const groupEventsByPartnerId = ( return eventsByPartnerId } -export const readSqliteVpetArchive = (executor: Pick, close: () => void): VpetArchiveResult => { +export const readSqliteDigitalPetArchive = ( + executor: Pick, + close: () => void, +): DigitalPetArchiveResult => { try { const partners = executor.all(ARCHIVE_PARTNERS_SELECT) if (partners.length === 0) return { kind: "empty" } @@ -73,18 +86,18 @@ export const readSqliteVpetArchive = (executor: Pick, clo } } -export const createSqliteVpetArchiveReader = ( - options: CreateSqliteVpetArchiveReaderOptions = {}, -): VpetArchiveReader => { +export const createSqliteDigitalPetArchiveReader = ( + options: CreateSqliteDigitalPetArchiveReaderOptions = {}, +): DigitalPetArchiveReader => { const databasePath = options.databasePath ?? resolveHostDatabasePath(options) return { - getArchive(): VpetArchiveResult { + getArchive(): DigitalPetArchiveResult { if (!existsSync(databasePath)) return { kind: "empty" } try { const database = openReadonlyDatabase(databasePath) - return readSqliteVpetArchive(createExecutor(database), () => database.close()) + return readSqliteDigitalPetArchive(createExecutor(database), () => database.close()) } catch (error) { if (isRecoverableSqliteReadError(error)) return { kind: "unavailable", message: UNAVAILABLE_ARCHIVE_MESSAGE } throw error diff --git a/packages/opencode-digital-pet/src/adapters/sqlite/sqlite-digital-pet-write-store.ts b/packages/opencode-digital-pet/src/adapters/sqlite/sqlite-digital-pet-write-store.ts new file mode 100644 index 0000000..102092e --- /dev/null +++ b/packages/opencode-digital-pet/src/adapters/sqlite/sqlite-digital-pet-write-store.ts @@ -0,0 +1,42 @@ +import type { SqliteDigitalPetWriteStore } from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-types.ts" +import { + resolveHostDatabasePath, + type HostPathOptions, +} from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" +import { createSqliteDigitalPetWriteStore } from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-write-store.ts" +import type { SidebarSnapshotReader } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" + +import { createExecutor, openWritableDatabase } from "./bun-sqlite-driver.ts" +import { readSidebarSnapshotFromExecutor } from "./sqlite-sidebar-snapshot-reader.ts" + +export type CreateSqliteDigitalPetRepositoryOptions = HostPathOptions & { readonly databasePath?: string } + +export { resolveHostDatabasePath } +export type { HostPathOptions } from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" +export type { + PersistedPartnerEvent, + SqliteDigitalPetWriteStore, +} from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-types.ts" + +export type OpencodeSqliteDigitalPetRepository = SqliteDigitalPetWriteStore & SidebarSnapshotReader + +export const createSqliteDigitalPetRepository = async ( + options: CreateSqliteDigitalPetRepositoryOptions = {}, +): Promise => { + const databasePath = options.databasePath ?? resolveHostDatabasePath(options) + const database = openWritableDatabase(databasePath) + const executor = createExecutor(database) + + const writeStore = createSqliteDigitalPetWriteStore({ + databasePath, + executor, + close: () => { + database.close() + }, + }) + + return { + ...writeStore, + getSidebarSnapshot: () => readSidebarSnapshotFromExecutor(executor), + } +} diff --git a/packages/opencode-digital-pet/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts b/packages/opencode-digital-pet/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts new file mode 100644 index 0000000..4d33498 --- /dev/null +++ b/packages/opencode-digital-pet/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts @@ -0,0 +1,93 @@ +import { existsSync } from "node:fs" + +import type { + SidebarSnapshot, + SidebarSnapshotReader, +} from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import { + resolveHostDatabasePath, + type HostPathOptions, +} from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" +import type { SqliteExecutor } from "@jcendal/digital-pet-core/ports/sqlite-executor.ts" +import { + ACTIVE_PARTNER_SELECT, + toPartner, + type PersistedPartnerRow, + type TrainerStateRow, +} from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-schema.ts" + +import { createExecutor, openReadonlyDatabase } from "./bun-sqlite-driver.ts" + +export type CreateSqliteSidebarSnapshotReaderOptions = HostPathOptions & { readonly databasePath?: string } + +const TRAINER_STATE_SELECT = "SELECT total_tokens FROM trainer_state WHERE trainer_id = 1" +const CONTROL_STATE_SELECT = "SELECT frozen, cheat_node_id FROM vpet_control_state WHERE control_id = 1" + +type ControlStateRow = { + readonly frozen: number + readonly cheat_node_id: string | null +} + +const isRecoverableReadError = (error: unknown): boolean => { + return ( + error instanceof Error && + (error.message.includes("sqlite") || error.message.includes("database") || error.message.includes("no such table")) + ) +} + +export const readSidebarSnapshotFromExecutor = (executor: Pick): SidebarSnapshot | null => { + const trainer = executor.get(TRAINER_STATE_SELECT) + const control = executor.get(CONTROL_STATE_SELECT) + if (control?.cheat_node_id !== null && control?.cheat_node_id !== undefined) { + return { + currentNodeId: control.cheat_node_id, + gauge: 0, + isTerminal: true, + frozen: false, + isSetOverride: true, + trainerTotalTokens: trainer?.total_tokens ?? 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + } + } + + const partnerRow = executor.get(ACTIVE_PARTNER_SELECT) + if (partnerRow === null) return null + + const partner = toPartner(partnerRow) + return { + currentNodeId: partner.currentNodeId, + gauge: partner.gauge, + isTerminal: partner.isTerminal, + frozen: control?.frozen === 1, + isSetOverride: false, + trainerTotalTokens: trainer?.total_tokens ?? 0, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, + } +} + +export const createSqliteSidebarSnapshotReader = ( + options: CreateSqliteSidebarSnapshotReaderOptions = {}, +): SidebarSnapshotReader => { + const databasePath = options.databasePath ?? resolveHostDatabasePath(options) + + return { + getSidebarSnapshot(): SidebarSnapshot | null { + if (!existsSync(databasePath)) return null + + try { + const database = openReadonlyDatabase(databasePath) + try { + const executor = createExecutor(database) + return readSidebarSnapshotFromExecutor(executor) + } finally { + database.close() + } + } catch (error) { + if (isRecoverableReadError(error)) return null + throw error + } + }, + } +} diff --git a/src/cli.ts b/packages/opencode-digital-pet/src/cli.ts similarity index 90% rename from src/cli.ts rename to packages/opencode-digital-pet/src/cli.ts index 0f1d6ac..5bc6a46 100644 --- a/src/cli.ts +++ b/packages/opencode-digital-pet/src/cli.ts @@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises" import { installGlobalPlugin } from "./cli/install.ts" -const HELP = `Usage: opencode-vpet [--dry-run] +const HELP = `Usage: opencode-digital-pet [--dry-run] Commands: init Install the current package version through OpenCode's global plugin installer. @@ -21,7 +21,7 @@ type ParsedArguments = | Readonly<{ readonly kind: "command"; readonly command: Command; readonly dryRun: boolean }> | Readonly<{ readonly kind: "invalid"; readonly message: string }> -type PackageManifest = Readonly<{ readonly name: "@sbugallo/opencode-vpet"; readonly version: string }> +type PackageManifest = Readonly<{ readonly name: "@jcendal/opencode-digital-pet"; readonly version: string }> const EXACT_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ @@ -30,13 +30,13 @@ const isPackageManifest = (value: unknown): value is PackageManifest => { if (typeof value !== "object" || value === null || Array.isArray(value)) return false if (!("name" in value) || !("version" in value)) return false const { name, version } = value - return name === "@sbugallo/opencode-vpet" && typeof version === "string" && EXACT_SEMVER.test(version) + return name === "@jcendal/opencode-digital-pet" && typeof version === "string" && EXACT_SEMVER.test(version) } const readPackageManifest = async (): Promise => { const source = await readFile(new URL("../package.json", import.meta.url), "utf8") const manifest: unknown = JSON.parse(source) - if (!isPackageManifest(manifest)) throw new Error("Invalid opencode-vpet package manifest.") + if (!isPackageManifest(manifest)) throw new Error("Invalid opencode-digital-pet package manifest.") return manifest } diff --git a/src/cli/install.ts b/packages/opencode-digital-pet/src/cli/install.ts similarity index 100% rename from src/cli/install.ts rename to packages/opencode-digital-pet/src/cli/install.ts diff --git a/src/commands/vpet-command-result.ts b/packages/opencode-digital-pet/src/commands/digital-pet-command-result.ts similarity index 70% rename from src/commands/vpet-command-result.ts rename to packages/opencode-digital-pet/src/commands/digital-pet-command-result.ts index f831c2a..d6fa9b8 100644 --- a/src/commands/vpet-command-result.ts +++ b/packages/opencode-digital-pet/src/commands/digital-pet-command-result.ts @@ -1,12 +1,12 @@ import type { Part } from "@opencode-ai/sdk" -export type VpetCommandEvent = +export type DigitalPetCommandEvent = | { readonly kind: "spawned"; readonly nodeId: string; readonly generation: number } | { readonly kind: "frozen" } | { readonly kind: "unfrozen" } | { readonly kind: "set"; readonly nodeId: string } -export type VpetCommandResult = { +export type DigitalPetCommandResult = { readonly parts: readonly Part[] - readonly event?: VpetCommandEvent + readonly event?: DigitalPetCommandEvent } diff --git a/packages/opencode-digital-pet/src/commands/digital-pet-freeze.ts b/packages/opencode-digital-pet/src/commands/digital-pet-freeze.ts new file mode 100644 index 0000000..d54db8d --- /dev/null +++ b/packages/opencode-digital-pet/src/commands/digital-pet-freeze.ts @@ -0,0 +1,35 @@ +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" +import { freezeDigitalPet } from "@jcendal/digital-pet-core/application/use-cases/freeze-digital-pet.ts" +import type { DigitalPetCommandResult } from "./digital-pet-command-result.ts" + +export type DigitalPetFreezeContext = { + readonly sessionID: string + readonly messageID: string +} + +export const runDigitalPetFreezeCommand = async ( + control: Pick, + context: DigitalPetFreezeContext, +): Promise => { + const outcome = freezeDigitalPet(control) + const parts = [ + { + id: context.messageID, + sessionID: context.sessionID, + messageID: context.messageID, + type: "text" as const, + text: "Digital Pet frozen.", + }, + ] + + switch (outcome.kind) { + case "frozen": + return { parts, event: { kind: "frozen" } } + case "already_frozen": + return { parts } + default: { + const unexpectedOutcome: never = outcome + return unexpectedOutcome + } + } +} diff --git a/src/commands/vpet-set.ts b/packages/opencode-digital-pet/src/commands/digital-pet-set.ts similarity index 51% rename from src/commands/vpet-set.ts rename to packages/opencode-digital-pet/src/commands/digital-pet-set.ts index 3eb33c2..e4f0906 100644 --- a/src/commands/vpet-set.ts +++ b/packages/opencode-digital-pet/src/commands/digital-pet-set.ts @@ -1,11 +1,11 @@ import type { Part } from "@opencode-ai/sdk" -import type { VpetControl } from "../application/ports/vpet-control.ts" -import { setVpetCheatNode } from "../application/use-cases/set-vpet-cheat-node.ts" -import type { DigimonCatalog } from "../data/catalog.ts" -import type { VpetCommandResult } from "./vpet-command-result.ts" +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" +import { setDigitalPetCheatNode } from "@jcendal/digital-pet-core/application/use-cases/set-digital-pet-cheat-node.ts" +import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { DigitalPetCommandResult } from "./digital-pet-command-result.ts" -export type VpetSetContext = { +export type DigitalPetSetContext = { readonly sessionID: string readonly messageID: string readonly arguments: string @@ -13,7 +13,7 @@ export type VpetSetContext = { export type DigimonCatalogLoader = () => Promise -const textPart = (context: VpetSetContext, text: string): Part => ({ +const textPart = (context: DigitalPetSetContext, text: string): Part => ({ id: context.messageID, sessionID: context.sessionID, messageID: context.messageID, @@ -21,23 +21,23 @@ const textPart = (context: VpetSetContext, text: string): Part => ({ text, }) -export const runVpetSetCommand = async ( - control: Pick, - context: VpetSetContext, +export const runDigitalPetSetCommand = async ( + control: Pick, + context: DigitalPetSetContext, loadCatalog: DigimonCatalogLoader, -): Promise => { - if (context.arguments.includes("\n")) return { parts: [textPart(context, "Usage: /vpet-set .")] } +): Promise => { + if (context.arguments.includes("\n")) return { parts: [textPart(context, "Usage: /digital-pet-set .")] } const tokens = context.arguments.trim().split(/\s+/) if (context.arguments.trim() === "" || tokens.length !== 1) - return { parts: [textPart(context, "Usage: /vpet-set .")] } + return { parts: [textPart(context, "Usage: /digital-pet-set .")] } const requestedId = tokens[0] - if (requestedId === undefined) return { parts: [textPart(context, "Usage: /vpet-set .")] } + if (requestedId === undefined) return { parts: [textPart(context, "Usage: /digital-pet-set .")] } const node = (await loadCatalog()).byId.get(requestedId) if (node === undefined) return { parts: [textPart(context, `Unknown Digimon ID: ${requestedId}.`)] } - const outcome = setVpetCheatNode(control, node.id) - const parts = [textPart(context, `VPet set to ${node.nameEn} (${node.id}).`)] + const outcome = setDigitalPetCheatNode(control, node.id) + const parts = [textPart(context, `Digital Pet set to ${node.nameEn} (${node.id}).`)] switch (outcome.kind) { case "set": diff --git a/src/commands/vpet-spawn.ts b/packages/opencode-digital-pet/src/commands/digital-pet-spawn.ts similarity index 53% rename from src/commands/vpet-spawn.ts rename to packages/opencode-digital-pet/src/commands/digital-pet-spawn.ts index 51fa9a5..7b0f36f 100644 --- a/src/commands/vpet-spawn.ts +++ b/packages/opencode-digital-pet/src/commands/digital-pet-spawn.ts @@ -1,17 +1,17 @@ -import type { PartnerLifecycle } from "../application/ports/partner-lifecycle.ts" -import { spawnPartner } from "../application/use-cases/spawn-partner.ts" -import type { VpetCommandResult } from "./vpet-command-result.ts" +import type { PartnerLifecycle } from "@jcendal/digital-pet-core/application/ports/partner-lifecycle.ts" +import { spawnPartner } from "@jcendal/digital-pet-core/application/use-cases/spawn-partner.ts" +import type { DigitalPetCommandResult } from "./digital-pet-command-result.ts" -export type VpetSpawnContext = { +export type DigitalPetSpawnContext = { readonly sessionID: string readonly messageID: string readonly createdAt: string } -export const runVpetSpawnCommand = async ( +export const runDigitalPetSpawnCommand = async ( lifecycle: PartnerLifecycle, - context: VpetSpawnContext, -): Promise => { + context: DigitalPetSpawnContext, +): Promise => { const partner = spawnPartner(lifecycle, context.createdAt) return { diff --git a/packages/opencode-digital-pet/src/commands/digital-pet-unfreeze.ts b/packages/opencode-digital-pet/src/commands/digital-pet-unfreeze.ts new file mode 100644 index 0000000..8cf9c15 --- /dev/null +++ b/packages/opencode-digital-pet/src/commands/digital-pet-unfreeze.ts @@ -0,0 +1,35 @@ +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" +import { unfreezeDigitalPet } from "@jcendal/digital-pet-core/application/use-cases/unfreeze-digital-pet.ts" +import type { DigitalPetCommandResult } from "./digital-pet-command-result.ts" + +export type DigitalPetUnfreezeContext = { + readonly sessionID: string + readonly messageID: string +} + +export const runDigitalPetUnfreezeCommand = async ( + control: Pick, + context: DigitalPetUnfreezeContext, +): Promise => { + const outcome = unfreezeDigitalPet(control) + const parts = [ + { + id: context.messageID, + sessionID: context.sessionID, + messageID: context.messageID, + type: "text" as const, + text: "Digital Pet unfrozen.", + }, + ] + + switch (outcome.kind) { + case "unfrozen": + return { parts, event: { kind: "unfrozen" } } + case "already_unfrozen": + return { parts } + default: { + const unexpectedOutcome: never = outcome + return unexpectedOutcome + } + } +} diff --git a/packages/opencode-digital-pet/src/config/global-digital-pet-settings.ts b/packages/opencode-digital-pet/src/config/global-digital-pet-settings.ts new file mode 100644 index 0000000..fee0fdb --- /dev/null +++ b/packages/opencode-digital-pet/src/config/global-digital-pet-settings.ts @@ -0,0 +1,54 @@ +import { existsSync, readFileSync } from "node:fs" +import { readFile } from "node:fs/promises" +import { homedir } from "node:os" +import { join } from "node:path" + +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { normalizeDigitalPetSettings } from "@jcendal/digital-pet-core/config/normalize.ts" +import type { ResolvedDigitalPetSettings } from "@jcendal/digital-pet-core/config/types.ts" + +type PathApi = Readonly<{ + join: (...paths: readonly string[]) => string +}> + +export type GlobalDigitalPetConfigOptions = Readonly<{ + readonly env?: NodeJS.ProcessEnv + readonly home?: string + readonly pathApi?: PathApi + readonly platform?: NodeJS.Platform +}> + +export const resolveGlobalDigitalPetConfigPath = (options: GlobalDigitalPetConfigOptions = {}): string => { + const pathApi = options.pathApi ?? { join } + const env = options.env ?? process.env + const home = options.home ?? homedir() + + const configDirectory = + (options.platform ?? process.platform) === "win32" + ? pathApi.join(env["APPDATA"] ?? pathApi.join(home, "AppData", "Roaming")) + : pathApi.join(home, ".config") + const currentPath = pathApi.join(configDirectory, "opencode-digital-pet.json") + const legacyPath = pathApi.join(configDirectory, "opencode-vpet.json") + if (!existsSync(currentPath) && existsSync(legacyPath)) return legacyPath + return currentPath +} + +export const loadGlobalDigitalPetSettings = async ( + options: GlobalDigitalPetConfigOptions = {}, +): Promise => { + try { + return normalizeDigitalPetSettings(JSON.parse(await readFile(resolveGlobalDigitalPetConfigPath(options), "utf8"))) + } catch { + return DEFAULT_DIGITAL_PET_SETTINGS + } +} + +export const loadGlobalDigitalPetSettingsSync = ( + options: GlobalDigitalPetConfigOptions = {}, +): ResolvedDigitalPetSettings => { + try { + return normalizeDigitalPetSettings(JSON.parse(readFileSync(resolveGlobalDigitalPetConfigPath(options), "utf8"))) + } catch { + return DEFAULT_DIGITAL_PET_SETTINGS + } +} diff --git a/packages/opencode-digital-pet/src/dev/attach-dev-tools.ts b/packages/opencode-digital-pet/src/dev/attach-dev-tools.ts new file mode 100644 index 0000000..3f6d805 --- /dev/null +++ b/packages/opencode-digital-pet/src/dev/attach-dev-tools.ts @@ -0,0 +1,64 @@ +import type { SidebarCardInputs } from "@jcendal/digital-pet-core/application/models/sidebar-card-inputs.ts" +import type { EvolutionBattleRepository } from "@jcendal/digital-pet-core/application/use-cases/resolve-evolution-battle.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" + +import type { TuiCompositionApi } from "../tui.tsx" +import { buildPartnerInputs } from "./build-partner-inputs.ts" +import { DEV_EVOLUTION } from "./fixtures.ts" +import { registerDevCommandLayer } from "./register-dev-command-layer.tsx" +import { setupBattlePending, type DevScenarioRepository } from "./setup-scenario.ts" + +export type DevToolsRuntime = { + readonly runFeed: () => void + readonly runActivity: () => void + readonly runEvolutionReveal: () => Promise + readonly runEvolutionBattle: () => Promise +} + +export type AttachDevToolsDependencies = { + readonly api: { readonly keymap: NonNullable } + readonly isDisposed: () => boolean + readonly isBusy: () => boolean + readonly feed: () => void + readonly activity: () => void + readonly tryResolveEvolutionBattle: () => Promise + readonly tryPlayEvolutionReveal: (nextInputs: SidebarCardInputs) => Promise + readonly setLastPresentedNodeId: (nodeId: string) => void + readonly battleRepository?: EvolutionBattleRepository +} + +export const attachDevTools = (deps: AttachDevToolsDependencies): (() => void) => { + if (deps.api.keymap === undefined) return () => undefined + + const runtime: DevToolsRuntime = { + runFeed: () => { + if (deps.isDisposed() || deps.isBusy()) return + deps.feed() + }, + runActivity: () => { + if (deps.isDisposed() || deps.isBusy()) return + deps.activity() + }, + runEvolutionReveal: async () => { + if (deps.isDisposed() || deps.isBusy()) return + deps.setLastPresentedNodeId(DEV_EVOLUTION.fromNodeId) + const played = await deps.tryPlayEvolutionReveal(buildPartnerInputs(DIGIMON_CATALOG, DEV_EVOLUTION.toNodeId)) + if (!played) throw new Error("Evolution reveal did not play. Ensure the sidebar is visible.") + }, + runEvolutionBattle: async () => { + if (deps.isDisposed() || deps.isBusy()) return + if (deps.battleRepository === undefined) throw new Error("Battle repository is unavailable.") + setupBattlePending(deps.battleRepository as DevScenarioRepository) + const battled = await deps.tryResolveEvolutionBattle() + if (!battled) throw new Error("Evolution battle did not start. Ensure a partner is active.") + }, + } + + const disposeLayer = registerDevCommandLayer({ + keymap: deps.api.keymap, + runtime, + isDisposed: deps.isDisposed, + }) + + return disposeLayer +} diff --git a/packages/opencode-digital-pet/src/dev/build-partner-inputs.ts b/packages/opencode-digital-pet/src/dev/build-partner-inputs.ts new file mode 100644 index 0000000..5616902 --- /dev/null +++ b/packages/opencode-digital-pet/src/dev/build-partner-inputs.ts @@ -0,0 +1,18 @@ +import type { SidebarCardInputs } from "@jcendal/digital-pet-core/application/models/sidebar-card-inputs.ts" +import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" + +export const buildPartnerInputs = (catalog: DigimonCatalog, nodeId: string): SidebarCardInputs => { + const node = catalog.byId.get(nodeId) + if (node === undefined) throw new Error(`Unknown Digimon node: ${nodeId}`) + + return { + kind: "partner", + node, + gauge: 42, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 100, + evolutionBattlePending: false, + } +} diff --git a/packages/opencode-digital-pet/src/dev/catalog.ts b/packages/opencode-digital-pet/src/dev/catalog.ts new file mode 100644 index 0000000..3955d0b --- /dev/null +++ b/packages/opencode-digital-pet/src/dev/catalog.ts @@ -0,0 +1,9 @@ +export const OPENCODE_DEV_ACTIONS = [ + { id: "feed", title: "Digital Pet Dev Feed", slashName: "digital-pet-dev-feed" }, + { id: "activity", title: "Digital Pet Dev Activity", slashName: "digital-pet-dev-activity" }, + { id: "evolution_reveal", title: "Digital Pet Dev Evolution Reveal", slashName: "digital-pet-dev-evolution-reveal" }, + { id: "evolution_battle", title: "Digital Pet Dev Evolution Battle", slashName: "digital-pet-dev-evolution-battle" }, + { id: "defeat", title: "Digital Pet Dev Defeat (via battle)", slashName: "digital-pet-dev-defeat" }, +] as const + +export type OpencodeDevActionId = (typeof OPENCODE_DEV_ACTIONS)[number]["id"] diff --git a/packages/opencode-digital-pet/src/dev/fixtures.ts b/packages/opencode-digital-pet/src/dev/fixtures.ts new file mode 100644 index 0000000..5eadda9 --- /dev/null +++ b/packages/opencode-digital-pet/src/dev/fixtures.ts @@ -0,0 +1,6 @@ +import type { UsageEvolutionTransition } from "@jcendal/digital-pet-core/application/models/usage.ts" + +export const DEV_EVOLUTION: UsageEvolutionTransition = { + fromNodeId: "3-001", + toNodeId: "4-017", +} diff --git a/packages/opencode-digital-pet/src/dev/register-dev-command-layer.tsx b/packages/opencode-digital-pet/src/dev/register-dev-command-layer.tsx new file mode 100644 index 0000000..ebe6f3e --- /dev/null +++ b/packages/opencode-digital-pet/src/dev/register-dev-command-layer.tsx @@ -0,0 +1,42 @@ +/** @jsxImportSource @opentui/solid */ +import type { TuiKeymap } from "@opencode-ai/plugin/tui" + +import { OPENCODE_DEV_ACTIONS, type OpencodeDevActionId } from "./catalog.ts" +import type { DevToolsRuntime } from "./attach-dev-tools.ts" + +export type DevCommandLayerDependencies = { + readonly keymap: Pick + readonly runtime: DevToolsRuntime + readonly isDisposed: () => boolean +} + +export const registerDevCommandLayer = ({ + keymap, + runtime, + isDisposed, +}: DevCommandLayerDependencies): ReturnType => { + const handlers: Record void | Promise> = { + feed: () => runtime.runFeed(), + activity: () => runtime.runActivity(), + evolution_reveal: () => runtime.runEvolutionReveal(), + evolution_battle: () => runtime.runEvolutionBattle(), + defeat: () => runtime.runEvolutionBattle(), + } + + return keymap.registerLayer({ + name: "opencode-digital-pet-dev.layer", + namespace: "opencode-digital-pet-dev", + commands: OPENCODE_DEV_ACTIONS.map((action) => ({ + name: action.id, + title: action.title, + description: action.title, + category: "Digital Pet Dev", + namespace: "palette", + slashName: action.slashName, + run: () => { + if (isDisposed()) return + void handlers[action.id]() + }, + })), + }) +} diff --git a/packages/opencode-digital-pet/src/dev/setup-scenario.ts b/packages/opencode-digital-pet/src/dev/setup-scenario.ts new file mode 100644 index 0000000..2092329 --- /dev/null +++ b/packages/opencode-digital-pet/src/dev/setup-scenario.ts @@ -0,0 +1,90 @@ +import type { + SidebarSnapshot, + SidebarSnapshotReader, +} from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import { recordUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import { spawnPartner } from "@jcendal/digital-pet-core/application/use-cases/spawn-partner.ts" +import type { SqliteDigitalPetWriteStore } from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-types.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { StageThresholds } from "@jcendal/digital-pet-core/domain/evolution.ts" +import { DIGIMON_STAGES } from "@jcendal/digital-pet-core/domain/stage.ts" + +const DEV_INSTANT_BATTLE_THRESHOLDS: StageThresholds = Object.freeze( + Object.fromEntries(DIGIMON_STAGES.map((stage) => [stage, 1])) as StageThresholds, +) + +export type DevScenarioRepository = SqliteDigitalPetWriteStore & UsageLedger & SidebarSnapshotReader + +const usageReceipt = (receiptKey: string, tokenDelta = 1): Parameters[0]["usage"] => ({ + receiptKey, + eventId: `dev:${receiptKey}`, + tokenDelta, + cost: null, + createdAt: new Date().toISOString(), +}) + +const applyTokenUsage = (ledger: UsageLedger, receiptKey: string, tokenDelta = 1): ReturnType => + recordUsage({ + usage: usageReceipt(receiptKey, tokenDelta), + ledger, + digimonById: DIGIMON_CATALOG.byId, + catalogNodes: DIGIMON_CATALOG.nodes, + selector: () => 0, + thresholds: DEV_INSTANT_BATTLE_THRESHOLDS, + }) + +const hasPendingBattle = (snapshot: SidebarSnapshot): boolean => + snapshot.pendingEvolutionTargetId !== null && snapshot.battleOpponentNodeId !== null + +const assertPartnerCanBattle = (snapshot: SidebarSnapshot): void => { + const current = DIGIMON_CATALOG.byId.get(snapshot.currentNodeId) + if (current === undefined) { + throw new Error(`Digital Pet dev: partner node ${snapshot.currentNodeId} is missing from the catalog.`) + } + if (snapshot.frozen) { + throw new Error("Digital Pet dev: partner is frozen. Unfreeze before opening an evolution battle.") + } + if (snapshot.isSetOverride) { + throw new Error("Digital Pet dev: clear the digimon override before opening an evolution battle.") + } + if (snapshot.isTerminal || current.nextEvolutions.length === 0) { + throw new Error("Digital Pet dev: partner cannot evolve further, so no evolution battle can be opened.") + } +} + +export const ensureActivePartner = (repository: DevScenarioRepository): void => { + if (repository.getActivePartner() !== null) return + spawnPartner(repository, new Date().toISOString()) +} + +export const setupBattlePending = (repository: DevScenarioRepository): void => { + ensureActivePartner(repository) + const initialSnapshot = repository.getSidebarSnapshot() + if (initialSnapshot === null) { + throw new Error("Digital Pet dev: could not read the active partner snapshot.") + } + if (hasPendingBattle(initialSnapshot)) return + + assertPartnerCanBattle(initialSnapshot) + + const receiptSuffix = Date.now().toString(36) + const current = DIGIMON_CATALOG.byId.get(initialSnapshot.currentNodeId) + if (current === undefined) { + throw new Error(`Digital Pet dev: partner node ${initialSnapshot.currentNodeId} is missing from the catalog.`) + } + + if (current.stage === 0) { + applyTokenUsage(repository, `dev-hatch-${receiptSuffix}`, 1) + } + + const afterHatch = repository.getSidebarSnapshot() + if (afterHatch !== null && !hasPendingBattle(afterHatch)) { + applyTokenUsage(repository, `dev-battle-${receiptSuffix}`, 1) + } + + const pending = repository.getSidebarSnapshot() + if (pending === null || !hasPendingBattle(pending)) { + throw new Error("Digital Pet dev: could not open a pending evolution battle for the current partner.") + } +} diff --git a/src/index.ts b/packages/opencode-digital-pet/src/index.ts similarity index 63% rename from src/index.ts rename to packages/opencode-digital-pet/src/index.ts index e14b948..e7ffaed 100644 --- a/src/index.ts +++ b/packages/opencode-digital-pet/src/index.ts @@ -2,21 +2,21 @@ import type { Plugin, PluginOptions } from "@opencode-ai/plugin" import { createServerHooks } from "./adapters/opencode/create-server-hooks.ts" import { createSessionMessagesFetcher } from "./adapters/opencode/session-messages.ts" -import { createBestEffortVpetToastNotifier } from "./adapters/opencode/vpet-toast.ts" -import { createSqliteVpetRepository } from "./adapters/sqlite/sqlite-vpet-write-store.ts" -import { loadGlobalVpetSettings } from "./config/global-vpet-settings.ts" -import type { ResolvedVpetSettings } from "./config/types.ts" -import type { StageThresholds } from "./domain/evolution.ts" +import { createBestEffortDigitalPetToastNotifier } from "./adapters/opencode/digital-pet-toast.ts" +import { createSqliteDigitalPetRepository } from "./adapters/sqlite/sqlite-digital-pet-write-store.ts" +import { loadGlobalDigitalPetSettings } from "./config/global-digital-pet-settings.ts" +import type { ResolvedDigitalPetSettings } from "@jcendal/digital-pet-core/config/types.ts" +import type { StageThresholds } from "@jcendal/digital-pet-core/domain/evolution.ts" export { createCommandConfig } from "./adapters/opencode/create-server-hooks.ts" -export { DIGIMON_DATA } from "./data/digimon-data.js" -export type { DigimonId, DigimonRecord, DigimonStage } from "./data/digimon-data.js" +export { DIGIMON_DATA } from "@jcendal/digital-pet-core/data/digimon-data.ts" +export type { DigimonId, DigimonRecord, DigimonStage } from "@jcendal/digital-pet-core/data/digimon-data.ts" const getStringOption = (options: PluginOptions | undefined, name: string): string | undefined => { const value = options?.[name] return typeof value === "string" ? value : undefined } -const toEvolutionThresholds = (settings: ResolvedVpetSettings): StageThresholds => +const toEvolutionThresholds = (settings: ResolvedDigitalPetSettings): StageThresholds => Object.freeze({ 0: settings.stageThresholds.egg, 1: settings.stageThresholds.babyI, @@ -31,8 +31,8 @@ const toEvolutionThresholds = (settings: ResolvedVpetSettings): StageThresholds export const plugin: Plugin = async (input, options) => { const appDataRoot = getStringOption(options, "appDataRoot") const databasePath = getStringOption(options, "databasePath") - const settings = await loadGlobalVpetSettings() - const repository = await createSqliteVpetRepository({ + const settings = await loadGlobalDigitalPetSettings() + const repository = await createSqliteDigitalPetRepository({ ...(appDataRoot === undefined ? {} : { appDataRoot }), ...(databasePath === undefined ? {} : { databasePath }), }) @@ -43,14 +43,14 @@ export const plugin: Plugin = async (input, options) => { fetchMessages: createSessionMessagesFetcher(input.client), language: settings.language, notificationsEnabled: settings.notifications, - notify: createBestEffortVpetToastNotifier( + notify: createBestEffortDigitalPetToastNotifier( async (payload) => (await input.client.tui.showToast({ body: payload })).data ?? false, ), }) } const entryPlugin = { - id: "opencode-vpet", + id: "opencode-digital-pet", server: plugin, } as const diff --git a/packages/opencode-digital-pet/src/tui.tsx b/packages/opencode-digital-pet/src/tui.tsx new file mode 100644 index 0000000..ef654c0 --- /dev/null +++ b/packages/opencode-digital-pet/src/tui.tsx @@ -0,0 +1,426 @@ +/** @jsxImportSource @opentui/solid */ +import type { PluginOptions } from "@opencode-ai/plugin" +import type { EventMessagePartUpdated, EventMessageUpdated, EventSessionStatus } from "@opencode-ai/sdk/v2" +import type { TuiPlugin, TuiDialogProps, TuiDialogStack, TuiTheme, TuiKeymap } from "@opencode-ai/plugin/tui" +import type { JSX } from "@opentui/solid" +import { createSignal } from "solid-js" + +import type { SidebarCardInputs } from "@jcendal/digital-pet-core/application/models/sidebar-card-inputs.ts" +import type { SidebarSnapshot } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import type { DigitalPetArchiveReader } from "@jcendal/digital-pet-core/application/ports/digital-pet-archive.ts" +import { getSidebarCardInputs } from "@jcendal/digital-pet-core/application/use-cases/get-sidebar-card-inputs.ts" +import type { EvolutionBattleRepository } from "@jcendal/digital-pet-core/application/use-cases/resolve-evolution-battle.ts" +import type { ResolvedDigitalPetSettings } from "@jcendal/digital-pet-core/config/types.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" +import { buildSidebarCardModel } from "@jcendal/digital-pet-core/view-models/sidebar-view-model.ts" +import { + MonsterAnimationController, + type MonsterAnimationOutput, +} from "@jcendal/digital-pet-animation/idle/monster-animation.ts" +import { runEvolutionBattleSession } from "@jcendal/digital-pet-animation/sessions/evolution-battle-session.ts" +import { runEvolutionRevealSession } from "@jcendal/digital-pet-animation/sessions/evolution-reveal-session.ts" + +import { + formatDigitalPetToast, + type DigitalPetToastEvent, + type DigitalPetToastNotifier, +} from "./adapters/opencode/digital-pet-toast.ts" +import { createSqliteSidebarSnapshotReader } from "./adapters/sqlite/sqlite-sidebar-snapshot-reader.ts" +import { createSqliteDigitalPetArchiveReader } from "./adapters/sqlite/sqlite-digital-pet-archive-reader.ts" +import { createSqliteDigitalPetRepository } from "./adapters/sqlite/sqlite-digital-pet-write-store.ts" +import { loadGlobalDigitalPetSettings } from "./config/global-digital-pet-settings.ts" +import { createSidebarPollLoop } from "./tui/sidebar-poll-loop.ts" +import { DigitalPetSidebarCard } from "./tui/sidebar-card.tsx" +import { registerDigitalPetCommandLayer } from "./tui/digital-pet-command-layer.tsx" + +const VISUAL_INTERVAL_MS = 500 +const DEFAULT_BATTLE_ARTWORK_WIDTH = 80 + +const getStringOption = (options: PluginOptions | undefined, name: string): string | undefined => { + const value = options?.[name] + return typeof value === "string" ? value : undefined +} + +const readerOptions = (options: PluginOptions | undefined): Parameters[0] => { + const appDataRoot = getStringOption(options, "appDataRoot") + const databasePath = getStringOption(options, "databasePath") + return { + ...(appDataRoot === undefined ? {} : { appDataRoot }), + ...(databasePath === undefined ? {} : { databasePath }), + } +} + +export type TuiCompositionApi = { + readonly event: { readonly on: (...subscription: ActivitySubscription) => () => void } + readonly renderer: { readonly requestRender: () => void } + readonly lifecycle: { readonly onDispose: (callback: () => void) => () => void } + readonly slots: { + readonly register: (plugin: { readonly slots: { readonly sidebar_content: () => JSX.Element } }) => string + } + readonly keymap?: { readonly registerLayer: TuiKeymap["registerLayer"] } + readonly ui?: { + readonly dialog: Pick + readonly Dialog: (props: TuiDialogProps) => JSX.Element + } + readonly theme?: TuiTheme +} + +type ActivitySubscription = + | readonly [type: "message.updated", handler: (event: EventMessageUpdated) => void] + | readonly [type: "message.part.updated", handler: (event: EventMessagePartUpdated) => void] + | readonly [type: "session.status", handler: (event: EventSessionStatus) => void] + +type TuiSchedulingOptions = { + readonly scheduleVisualInterval?: (callback: () => void, intervalMs: number) => () => void + readonly schedulePollTimeout?: (callback: () => void, intervalMs: number) => () => void + readonly nowMs?: () => number + readonly random?: () => number + readonly onAnimation?: (output: MonsterAnimationOutput) => void + readonly archiveReader?: DigitalPetArchiveReader + readonly battleRepository?: EvolutionBattleRepository + readonly getSnapshot?: () => SidebarSnapshot | null + readonly notify?: DigitalPetToastNotifier + readonly closeBattleRepository?: () => Promise +} + +const sameAnimation = (left: MonsterAnimationOutput, right: MonsterAnimationOutput): boolean => + left.kind === right.kind && + left.result.kind === right.result.kind && + (left.result.kind !== "frame" || right.result.kind !== "frame" || left.result.frame === right.result.frame) && + (left.result.kind !== "unavailable" || + right.result.kind !== "unavailable" || + left.result.sprite === right.result.sprite) && + left.offset === right.offset && + left.facing === right.facing + +export const createTui = + ( + loadInputs: () => SidebarCardInputs | Promise, + settings: ResolvedDigitalPetSettings, + scheduling: TuiSchedulingOptions = {}, + ) => + async (api: TuiCompositionApi): Promise => { + const [inputs, setInputs] = createSignal({ kind: "no_partner" }) + const [animation, setAnimation] = createSignal({ + kind: "blank", + result: { kind: "blank" }, + offset: 0, + facing: "left", + }) + const [customArtwork, setCustomArtwork] = createSignal() + const controller = new MonsterAnimationController( + MONSTER_FRAME_CATALOG, + scheduling.random ?? Math.random, + scheduling.nowMs, + ) + let disposed = false + let battleInProgress = false + let lastPresentedNodeId: string | undefined + let artworkWidth = DEFAULT_BATTLE_ARTWORK_WIDTH + const notifyEvent = async (event: DigitalPetToastEvent): Promise => { + if (scheduling.notify === undefined) return + const payload = formatDigitalPetToast(event, settings.language, DIGIMON_CATALOG) + if (payload !== undefined) await scheduling.notify(payload) + } + const tryResolveEvolutionBattle = async (): Promise => { + if ( + disposed || + battleInProgress || + scheduling.battleRepository === undefined || + scheduling.getSnapshot === undefined + ) { + return false + } + const snapshot = scheduling.getSnapshot() + if (snapshot === null) return false + if (snapshot.pendingEvolutionTargetId === null || snapshot.battleOpponentNodeId === null) return false + + battleInProgress = true + try { + const battled = await runEvolutionBattleSession(snapshot, artworkWidth, { + frameCatalog: MONSTER_FRAME_CATALOG, + digimonCatalog: DIGIMON_CATALOG, + repository: scheduling.battleRepository, + ...(scheduling.random === undefined ? {} : { random: scheduling.random }), + onArtwork: async (artwork) => { + setCustomArtwork(artwork) + api.renderer.requestRender() + }, + onResolved: async (result) => { + if (result.kind === "won") { + await notifyEvent({ kind: "evolution", ...result.evolution }) + } else if (result.kind === "lost") { + await notifyEvent({ kind: "defeat" }) + } + }, + }) + if (battled) { + setCustomArtwork(undefined) + const nextInputs = await Promise.resolve().then(() => loadInputs()) + if (nextInputs.kind === "partner") lastPresentedNodeId = nextInputs.node.id + setInputs(nextInputs) + syncAnimation(nextInputs) + api.renderer.requestRender() + } + return battled + } finally { + battleInProgress = false + setCustomArtwork(undefined) + } + } + const tryPlayEvolutionReveal = async (nextInputs: SidebarCardInputs): Promise => { + if (disposed || battleInProgress) return false + if (nextInputs.kind !== "partner" || nextInputs.evolutionBattlePending) return false + if (lastPresentedNodeId === undefined) { + lastPresentedNodeId = nextInputs.node.id + return false + } + if (lastPresentedNodeId === nextInputs.node.id) return false + + const evolution = { fromNodeId: lastPresentedNodeId, toNodeId: nextInputs.node.id } + battleInProgress = true + try { + const revealed = await runEvolutionRevealSession(evolution, artworkWidth, { + frameCatalog: MONSTER_FRAME_CATALOG, + digimonCatalog: DIGIMON_CATALOG, + onArtwork: async (artwork) => { + setCustomArtwork(artwork) + api.renderer.requestRender() + }, + }) + if (!revealed) return false + lastPresentedNodeId = nextInputs.node.id + setCustomArtwork(undefined) + setInputs(nextInputs) + syncAnimation(nextInputs) + api.renderer.requestRender() + return true + } finally { + battleInProgress = false + setCustomArtwork(undefined) + } + } + const publish = (nextAnimation: MonsterAnimationOutput): boolean => { + if (sameAnimation(animation(), nextAnimation)) return false + setAnimation(nextAnimation) + return true + } + const syncAnimation = (nextInputs: SidebarCardInputs): void => { + const cardModel = buildSidebarCardModel(nextInputs, settings) + publish( + controller.dispatch({ + kind: "partner_changed", + partner: + cardModel.kind === "partner" + ? { sprite: cardModel.sprite, isDigitama: cardModel.stageNumber === 0 } + : undefined, + }), + ) + } + const poller = createSidebarPollLoop({ + intervalMs: VISUAL_INTERVAL_MS, + load: async () => loadInputs(), + apply: (nextInputs) => { + void (async () => { + if (await tryResolveEvolutionBattle()) { + if (disposed) return + poller.refresh() + return + } + if (await tryPlayEvolutionReveal(nextInputs)) { + if (disposed) return + poller.refresh() + return + } + if (disposed || battleInProgress) return + if (nextInputs.kind === "partner") lastPresentedNodeId = nextInputs.node.id + setInputs(nextInputs) + syncAnimation(nextInputs) + api.renderer.requestRender() + })() + }, + schedule: (callback, intervalMs) => + ( + scheduling.schedulePollTimeout ?? + ((scheduledCallback, delay) => { + const handle = setTimeout(scheduledCallback, delay) + return () => clearTimeout(handle) + }) + )(callback, intervalMs), + clear: (stop) => stop(), + }) + const publishAnimation = (nextAnimation: MonsterAnimationOutput): void => { + if (!publish(nextAnimation)) return + scheduling.onAnimation?.(nextAnimation) + api.renderer.requestRender() + } + const stopVisualInterval = ( + scheduling.scheduleVisualInterval ?? + ((callback, intervalMs) => { + const handle = setInterval(callback, intervalMs) + return () => clearInterval(handle) + }) + )(() => { + if (disposed || battleInProgress) return + publishAnimation(controller.dispatch({ kind: "tick" })) + }, VISUAL_INTERVAL_MS) + + let sessionWasBusy = false + const activity = (): void => { + if (disposed) return + poller.refresh() + publishAnimation(controller.dispatch({ kind: "activity" })) + } + const feed = (): void => { + if (disposed || battleInProgress) return + publishAnimation(controller.dispatch({ kind: "feed" })) + } + const unsubscribes = [ + api.event.on("message.updated", activity), + api.event.on("message.part.updated", activity), + api.event.on("session.status", (event: EventSessionStatus) => { + switch (event.properties.status.type) { + case "busy": + case "retry": + sessionWasBusy = true + activity() + return + case "idle": + if (sessionWasBusy) { + sessionWasBusy = false + feed() + } + return + default: { + const unexpectedStatus: never = event.properties.status + return unexpectedStatus + } + } + }), + ] + + poller.start() + + let disposeDevTools = (): void => undefined + if (process.env["OPENCODE_DIGITAL_PET_DEV"] === "1" && api.keymap !== undefined) { + const devAttachPath = `./dev/${"attach-dev-tools.js"}` + const { attachDevTools } = await import(devAttachPath) + disposeDevTools = attachDevTools({ + api: { keymap: api.keymap }, + isDisposed: () => disposed, + isBusy: () => battleInProgress, + feed, + activity, + tryResolveEvolutionBattle, + tryPlayEvolutionReveal, + setLastPresentedNodeId: (nodeId: string) => { + lastPresentedNodeId = nodeId + }, + ...(scheduling.battleRepository === undefined ? {} : { battleRepository: scheduling.battleRepository }), + }) + } + + const disposeLayer = + api.keymap === undefined || api.ui === undefined || api.theme === undefined + ? () => undefined + : scheduling.archiveReader === undefined + ? api.keymap.registerLayer({ + name: "opencode-digital-pet.layer", + namespace: "opencode-digital-pet", + commands: [], + }) + : registerDigitalPetCommandLayer({ + api: { keymap: api.keymap, ui: api.ui, theme: api.theme }, + reader: scheduling.archiveReader, + catalog: DIGIMON_CATALOG, + settings, + isDisposed: () => disposed, + }) + + api.lifecycle.onDispose(() => { + if (disposed) return + disposed = true + disposeDevTools() + disposeLayer() + poller.dispose() + stopVisualInterval() + for (const unsubscribe of unsubscribes) unsubscribe() + void scheduling.closeBattleRepository?.() + }) + + api.slots.register({ + slots: { + sidebar_content() { + return ( + buildSidebarCardModel(inputs(), settings)} + animation={animation} + customArtwork={customArtwork} + onArtworkWidthChange={(width) => { + if (disposed) return + artworkWidth = width + if (publish(controller.dispatch({ kind: "viewport_resized", width }))) api.renderer.requestRender() + }} + /> + ) + }, + }, + }) + } + +export const tui: TuiPlugin = async (api, options) => { + const databaseOptions = readerOptions(options) + const reader = createSqliteSidebarSnapshotReader(databaseOptions) + const battleRepository = await createSqliteDigitalPetRepository(databaseOptions) + const archiveReader = createSqliteDigitalPetArchiveReader(databaseOptions) + const settings = await loadGlobalDigitalPetSettings() + await createTui(() => getSidebarCardInputs(reader, DIGIMON_CATALOG), settings, { + archiveReader, + battleRepository, + getSnapshot: () => reader.getSidebarSnapshot(), + ...("client" in api && api.client !== undefined && "tui" in api.client + ? { + notify: async (payload): Promise => { + void api.client.tui.showToast({ + title: payload.title, + message: payload.message, + variant: payload.variant, + duration: payload.duration, + }) + }, + } + : {}), + closeBattleRepository: () => battleRepository.close(), + })({ + event: { + on: (...subscription) => { + switch (subscription[0]) { + case "message.updated": + return api.event.on(subscription[0], subscription[1]) + case "message.part.updated": + return api.event.on(subscription[0], subscription[1]) + case "session.status": + return api.event.on(subscription[0], subscription[1]) + } + }, + }, + renderer: api.renderer, + lifecycle: api.lifecycle, + slots: api.slots, + keymap: { registerLayer: api.keymap.registerLayer.bind(api.keymap) }, + ui: { + dialog: { + replace: api.ui.dialog.replace.bind(api.ui.dialog), + clear: api.ui.dialog.clear.bind(api.ui.dialog), + setSize: api.ui.dialog.setSize.bind(api.ui.dialog), + }, + Dialog: api.ui.Dialog, + }, + theme: api.theme, + }) +} + +const plugin = { id: "opencode-digital-pet", tui } as const + +export default plugin diff --git a/packages/opencode-digital-pet/src/tui/digital-pet-command-layer.tsx b/packages/opencode-digital-pet/src/tui/digital-pet-command-layer.tsx new file mode 100644 index 0000000..4ec5e87 --- /dev/null +++ b/packages/opencode-digital-pet/src/tui/digital-pet-command-layer.tsx @@ -0,0 +1,109 @@ +/** @jsxImportSource @opentui/solid */ +import type { TuiDialogStack, TuiKeymap, TuiTheme } from "@opencode-ai/plugin/tui" + +import type { DigitalPetArchiveResult } from "@jcendal/digital-pet-core/application/models/digital-pet-archive.ts" +import type { DigitalPetArchiveReader } from "@jcendal/digital-pet-core/application/ports/digital-pet-archive.ts" +import type { ResolvedDigitalPetSettings } from "@jcendal/digital-pet-core/config/types.ts" +import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" +import { buildDexViewModel } from "@jcendal/digital-pet-core/view-models/dex-view-model.ts" +import { buildHistoryViewModel } from "@jcendal/digital-pet-core/view-models/history-view-model.ts" +import { DigitalPetDexDialog } from "./digital-pet-dex-dialog.tsx" +import { DigitalPetHistoryDialog } from "./digital-pet-history-dialog.tsx" + +export type DigitalPetCommandLayerApi = { + readonly keymap: Pick + readonly ui: { + readonly dialog: Pick + } + readonly theme: TuiTheme +} + +export type DigitalPetCommandLayerDependencies = { + readonly api: DigitalPetCommandLayerApi + readonly reader: DigitalPetArchiveReader + readonly catalog: DigimonCatalog + readonly settings: ResolvedDigitalPetSettings + readonly isDisposed: () => boolean +} + +const unavailableArchive = (): DigitalPetArchiveResult => ({ + kind: "unavailable", + message: "Digital Pet archive is unavailable.", +}) + +const readArchive = (reader: DigitalPetArchiveReader): DigitalPetArchiveResult => { + try { + return reader.getArchive() + } catch { + return unavailableArchive() + } +} + +export const registerDigitalPetCommandLayer = ({ + api, + reader, + catalog, + settings, + isDisposed, +}: DigitalPetCommandLayerDependencies): ReturnType => { + let isDialogOpen = false + const closeDialog = (): void => { + if (isDisposed() || !isDialogOpen) return + isDialogOpen = false + api.ui.dialog.clear() + } + + const openDex = (): void => { + if (isDisposed()) return + const model = buildDexViewModel(readArchive(reader), catalog, settings) + isDialogOpen = true + api.ui.dialog.replace( + () => , + closeDialog, + ) + api.ui.dialog.setSize("medium") + } + const openHistory = (): void => { + if (isDisposed()) return + const model = buildHistoryViewModel(readArchive(reader), catalog, settings) + isDialogOpen = true + api.ui.dialog.replace( + () => , + closeDialog, + ) + api.ui.dialog.setSize("medium") + } + + const disposeLayer = api.keymap.registerLayer({ + name: "opencode-digital-pet.layer", + namespace: "opencode-digital-pet", + commands: [ + { + name: "digital-pet.dex", + title: "Digital Pet Dex", + description: "Browse the Digital Pet discovery archive.", + category: "Digital Pet", + namespace: "palette", + slashName: "digital-pet-dex", + run: openDex, + }, + { + name: "digital-pet.history", + title: "Digital Pet History", + description: "Browse Digital Pet generation history.", + category: "Digital Pet", + namespace: "palette", + slashName: "digital-pet-history", + run: openHistory, + }, + ], + }) + + return () => { + if (isDialogOpen) { + api.ui.dialog.clear() + isDialogOpen = false + } + disposeLayer() + } +} diff --git a/src/tui/vpet-dex-dialog.tsx b/packages/opencode-digital-pet/src/tui/digital-pet-dex-dialog.tsx similarity index 90% rename from src/tui/vpet-dex-dialog.tsx rename to packages/opencode-digital-pet/src/tui/digital-pet-dex-dialog.tsx index 614ce09..f68450d 100644 --- a/src/tui/vpet-dex-dialog.tsx +++ b/packages/opencode-digital-pet/src/tui/digital-pet-dex-dialog.tsx @@ -3,21 +3,21 @@ import { createTextAttributes, type KeyEvent, type ScrollBoxRenderable } from "@ import { For, createMemo } from "solid-js" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import type { TuiTheme } from "@opencode-ai/plugin/tui" -import type { DexRow, DexViewModel } from "./dex-view-model.ts" +import type { DexRow, DexViewModel } from "@jcendal/digital-pet-core/view-models/dex-view-model.ts" -export type VpetDexDialogTheme = { +export type DigitalPetDexDialogTheme = { readonly current: Pick } -export type VpetDexDialogProps = { - readonly theme: VpetDexDialogTheme +export type DigitalPetDexDialogProps = { + readonly theme: DigitalPetDexDialogTheme readonly model: DexViewModel readonly onClose: () => void } const BOLD = createTextAttributes({ bold: true }) -export function VpetDexDialog(props: VpetDexDialogProps) { +export function DigitalPetDexDialog(props: DigitalPetDexDialogProps) { const dimensions = useTerminalDimensions() const height = Math.max(10, Math.floor(dimensions().height / 2) - 1) let scrollbox: ScrollBoxRenderable | undefined @@ -38,14 +38,14 @@ export function VpetDexDialog(props: VpetDexDialogProps) { return ( - VPet Dex + Digital Pet Dex {props.model.kind === "empty" && No data available} {props.model.kind === "unavailable" && {props.model.message}} {props.model.kind === "available" && ( { scrollbox = renderable }} @@ -88,7 +88,7 @@ const arrowDelta = (name: string): number | undefined => { function DexList(props: { readonly model: Extract - readonly theme: VpetDexDialogTheme + readonly theme: DigitalPetDexDialogTheme }) { const groups = createMemo(() => { const byStage = new Map() diff --git a/src/tui/vpet-history-dialog.tsx b/packages/opencode-digital-pet/src/tui/digital-pet-history-dialog.tsx similarity index 89% rename from src/tui/vpet-history-dialog.tsx rename to packages/opencode-digital-pet/src/tui/digital-pet-history-dialog.tsx index 2f17bea..14b48c8 100644 --- a/src/tui/vpet-history-dialog.tsx +++ b/packages/opencode-digital-pet/src/tui/digital-pet-history-dialog.tsx @@ -3,21 +3,21 @@ import { createTextAttributes, type KeyEvent, type ScrollBoxRenderable } from "@ import { For } from "solid-js" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import type { TuiTheme } from "@opencode-ai/plugin/tui" -import type { HistoryViewModel } from "./history-view-model.ts" +import type { HistoryViewModel } from "@jcendal/digital-pet-core/view-models/history-view-model.ts" -export type VpetHistoryDialogTheme = { +export type DigitalPetHistoryDialogTheme = { readonly current: Pick } -export type VpetHistoryDialogProps = { - readonly theme: VpetHistoryDialogTheme +export type DigitalPetHistoryDialogProps = { + readonly theme: DigitalPetHistoryDialogTheme readonly model: HistoryViewModel readonly onClose: () => void } const BOLD = createTextAttributes({ bold: true }) -export function VpetHistoryDialog(props: VpetHistoryDialogProps) { +export function DigitalPetHistoryDialog(props: DigitalPetHistoryDialogProps) { const dimensions = useTerminalDimensions() const height = Math.max(10, Math.floor(dimensions().height / 2) - 1) let scrollbox: ScrollBoxRenderable | undefined @@ -38,14 +38,14 @@ export function VpetHistoryDialog(props: VpetHistoryDialogProps) { return ( - VPet History + Digital Pet History {props.model.kind === "empty" && No data available} {props.model.kind === "unavailable" && {props.model.message}} {props.model.kind === "available" && ( { scrollbox = renderable }} diff --git a/src/tui/open-in-browser.ts b/packages/opencode-digital-pet/src/tui/open-in-browser.ts similarity index 100% rename from src/tui/open-in-browser.ts rename to packages/opencode-digital-pet/src/tui/open-in-browser.ts diff --git a/src/tui/sidebar-card.tsx b/packages/opencode-digital-pet/src/tui/sidebar-card.tsx similarity index 69% rename from src/tui/sidebar-card.tsx rename to packages/opencode-digital-pet/src/tui/sidebar-card.tsx index 6a92926..3f894d1 100644 --- a/src/tui/sidebar-card.tsx +++ b/packages/opencode-digital-pet/src/tui/sidebar-card.tsx @@ -1,13 +1,18 @@ /** @jsxImportSource @opentui/solid */ import { createTextAttributes, type BoxRenderable, type TextRenderable } from "@opentui/core" import { createEffect, type Accessor } from "solid-js" + +import type { SidebarCardModel } from "@jcendal/digital-pet-core/view-models/sidebar-view-model.ts" +import { MONSTER_FRAME_ROWS } from "@jcendal/digital-pet-animation/constants/monster-artwork.ts" +import type { + MonsterAnimationOutput, + MonsterAnimationResult, +} from "@jcendal/digital-pet-animation/idle/monster-animation.ts" +import { renderPositionedArtworkRows } from "@jcendal/digital-pet-animation/render/positioned-artwork.ts" + import { openInBrowser } from "./open-in-browser.ts" -import { mirrorMonsterFrame } from "./monster-artwork-mirror.ts" -import type { MonsterAnimationOutput, MonsterAnimationResult } from "./monster-animation.ts" -import type { SidebarCardModel } from "./sidebar-view-model.ts" -const ARTWORK_ROWS = 8 -const ARTWORK_COLUMNS = 16 +const ARTWORK_ROWS = MONSTER_FRAME_ROWS const NEXT_CHECK_PREFIX = "Next check: " const URL_LABEL = "Encyclopedia entry" const LINK_ATTRIBUTES = createTextAttributes({ underline: true }) @@ -15,43 +20,15 @@ const LINK_COLOR = "#5f87ff" const formatCount = (value: number): string => value.toLocaleString("en-US") -const positionedOutput = (animation: MonsterAnimationOutput | MonsterAnimationResult): MonsterAnimationOutput => { - if ("result" in animation) return animation - switch (animation.kind) { - case "blank": - return { kind: "blank", result: animation, offset: 0, facing: "left" } - case "frame": - return { kind: "walking", result: animation, offset: 0, facing: "left" } - case "unavailable": - return { kind: "unavailable", result: animation, offset: 0, facing: "left" } - } -} - -const artworkRows = (animation: MonsterAnimationOutput | MonsterAnimationResult, width: number): readonly string[] => { - const output = positionedOutput(animation) - switch (output.result.kind) { - case "blank": - return Array.from({ length: ARTWORK_ROWS }, () => "") - case "frame": { - const mirrored = output.facing === "right" ? mirrorMonsterFrame(output.result.frame) : undefined - if (mirrored?.kind === "invalid") return Array.from({ length: ARTWORK_ROWS }, () => "") - const content = mirrored?.frame.content ?? output.result.frame.content - const free = Math.max(width - ARTWORK_COLUMNS, 0) - const left = Math.max(0, Math.min(free, Math.floor(free / 2) + output.offset)) - return content.split("\n").map((row) => `${" ".repeat(left)}${row}`) - } - case "unavailable": { - const key = output.result.sprite === "" ? "(empty)" : output.result.sprite - const content = `Artwork unavailable: ${key}` - return Array.from({ length: ARTWORK_ROWS }, (_, index) => - index === 3 ? `${" ".repeat(Math.max(Math.floor((width - content.length) / 2), 0))}${content}` : "", - ) - } - } +const customArtworkRows = (artwork: string): readonly string[] => { + const rows = artwork.split("\n") + if (rows.length >= ARTWORK_ROWS) return rows.slice(0, ARTWORK_ROWS) + return [...rows, ...Array.from({ length: ARTWORK_ROWS - rows.length }, () => "")] } const buildNextCheckLine = (model: SidebarCardModel, width: number): string => { if (model.kind === "no_partner") return "" + if (model.evolutionBattlePending) return `${NEXT_CHECK_PREFIX}Evolution battle!` if (model.isTerminal && !model.isSetOverride) return `${NEXT_CHECK_PREFIX}None` const barWidth = Math.max(width - NEXT_CHECK_PREFIX.length - 2, 0) @@ -60,9 +37,10 @@ const buildNextCheckLine = (model: SidebarCardModel, width: number): string => { return `${NEXT_CHECK_PREFIX}[${"█".repeat(filled)}${"░".repeat(barWidth - filled)}]` } -export const VpetSidebarCard = (props: { +export const DigitalPetSidebarCard = (props: { readonly model: Accessor readonly animation: Accessor + readonly customArtwork?: Accessor readonly onArtworkWidthChange?: (width: number) => void readonly onUrlClick?: (url: string) => void }) => { @@ -90,7 +68,13 @@ export const VpetSidebarCard = (props: { const renderCard = (): void => { const model = props.model() - for (const [index, content] of artworkRows(props.animation(), Math.floor(artworkWidth?.width ?? 0)).entries()) { + const width = Math.floor(artworkWidth?.width ?? 0) + const battleArtwork = props.customArtwork?.() + const rows = + battleArtwork === undefined + ? renderPositionedArtworkRows(props.animation(), width) + : customArtworkRows(battleArtwork) + for (const [index, content] of rows.entries()) { updateText(artwork[index], content) } diff --git a/src/tui/sidebar-poll-loop.ts b/packages/opencode-digital-pet/src/tui/sidebar-poll-loop.ts similarity index 100% rename from src/tui/sidebar-poll-loop.ts rename to packages/opencode-digital-pet/src/tui/sidebar-poll-loop.ts diff --git a/tests/architecture-boundary-helpers.ts b/packages/opencode-digital-pet/tests/architecture-boundary-helpers.ts similarity index 92% rename from tests/architecture-boundary-helpers.ts rename to packages/opencode-digital-pet/tests/architecture-boundary-helpers.ts index 64de7e7..410bc55 100644 --- a/tests/architecture-boundary-helpers.ts +++ b/packages/opencode-digital-pet/tests/architecture-boundary-helpers.ts @@ -25,6 +25,7 @@ export const FORBIDDEN_APPLICATION_IMPORTS = [ "/persistence/", "/runtime/", "/adapters/sqlite/", + "@jcendal/digital-pet-core/adapters/", "/adapters/opencode/", "/tui/", "/commands/", @@ -32,16 +33,17 @@ export const FORBIDDEN_APPLICATION_IMPORTS = [ "@opentui/", "solid-js", "bun:sqlite", - "sqlite-vpet-schema", + "sqlite-digital-pet-schema", "bun-sqlite-driver", ] as const export const FORBIDDEN_TUI_PRESENTATION_IMPORTS = [ - "/config/global-vpet-settings", + "/config/global-digital-pet-settings", "/persistence/", "/runtime/", "/adapters/sqlite/", + "@jcendal/digital-pet-core/adapters/", "bun:sqlite", - "sqlite-vpet-schema", + "sqlite-digital-pet-schema", "bun-sqlite-driver", "sqlite-migrations", ] as const @@ -51,16 +53,17 @@ export const FORBIDDEN_TUI_ANIMATION_IMPORTS = [ "/persistence/", "/runtime/", "/adapters/sqlite/", + "@jcendal/digital-pet-core/adapters/", "bun:sqlite", ] as const export const FORBIDDEN_ADAPTER_IMPORTS = ["/tui/", "../tui.tsx", "../index.ts", "/persistence/", "/runtime/"] as const export const FORBIDDEN_LEGACY_IMPORTS = ["/persistence/", "/runtime/", "/tui/sidebar-state.ts"] as const export const FORBIDDEN_SERVER_HOOK_IMPORTS = ["/adapters/sqlite/", "/application/ports/resource-lifecycle"] as const -export const TUI_PRIVATE_ANIMATION_MODULES = [ - "monster-action-policy.ts", - "monster-animation.ts", - "monster-artwork-mirror.ts", - "monster-walking-policy.ts", +export const DIGITAL_PET_ANIMATION_IDLE_MODULES = [ + "idle/monster-action-policy.ts", + "idle/monster-animation.ts", + "idle/monster-artwork-mirror.ts", + "idle/monster-walking-policy.ts", ] as const const CORE_PARTNER_FIELDS = [ diff --git a/tests/architecture-boundary.test.ts b/packages/opencode-digital-pet/tests/architecture-boundary.test.ts similarity index 75% rename from tests/architecture-boundary.test.ts rename to packages/opencode-digital-pet/tests/architecture-boundary.test.ts index 94d14b8..cef1efc 100644 --- a/tests/architecture-boundary.test.ts +++ b/packages/opencode-digital-pet/tests/architecture-boundary.test.ts @@ -17,7 +17,7 @@ const assertRemoved = async (path: string): Promise => expect(await Bun.fi describe("application architecture boundary", () => { test("Given an application source importing a SQLite adapter When scanned Then its path and forbidden import are reported", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-vpet-architecture-")) + const directory = await mkdtemp(join(tmpdir(), "opencode-digital-pet-architecture-")) const path = join(directory, "forbidden.ts") try { await writeFile(path, 'import { openWritableDatabase } from "../adapters/sqlite/bun-sqlite-driver.ts"\n') @@ -28,18 +28,23 @@ describe("application architecture boundary", () => { await assertRemoved(path) }) test("Given an application source importing a SQLite write-store type When scanned Then its type-only import is reported", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-vpet-architecture-")) + const directory = await mkdtemp(join(tmpdir(), "opencode-digital-pet-architecture-")) const path = join(directory, "forbidden.ts") try { - await writeFile(path, 'import type { SqliteVpetWriteStore } from "../adapters/sqlite/sqlite-vpet-types.ts"\n') - expect(await scanApplicationImports(directory)).toEqual([`${path}: ../adapters/sqlite/sqlite-vpet-types.ts`]) + await writeFile( + path, + 'import type { SqliteDigitalPetWriteStore } from "../adapters/sqlite/sqlite-digital-pet-types.ts"\n', + ) + expect(await scanApplicationImports(directory)).toEqual([ + `${path}: ../adapters/sqlite/sqlite-digital-pet-types.ts`, + ]) } finally { await rm(directory, { recursive: true, force: true }) } await assertRemoved(path) }) test("Given application fixtures importing filesystem, adapters, and frameworks When scanned Then each forbidden boundary import is reported", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-vpet-application-architecture-")) + const directory = await mkdtemp(join(tmpdir(), "opencode-digital-pet-application-architecture-")) const fixtures = [ ["filesystem.ts", 'import { readFile } from "node:fs"\n'], ["framework.ts", 'import type { Plugin } from "@opencode-ai/plugin"\n'], @@ -58,7 +63,7 @@ describe("application architecture boundary", () => { await assertRemoved(directory) }) test("Given a fixture redeclaring Partner core state When scanned Then it is rejected", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-vpet-partner-contract-")) + const directory = await mkdtemp(join(tmpdir(), "opencode-digital-pet-partner-contract-")) const path = join(directory, "duplicate.ts") try { await writeFile( @@ -72,7 +77,7 @@ describe("application architecture boundary", () => { await assertRemoved(path) }) test("Given an application port declaring technical resource shutdown When scanned Then it is rejected", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-vpet-lifecycle-port-")) + const directory = await mkdtemp(join(tmpdir(), "opencode-digital-pet-lifecycle-port-")) const path = join(directory, "resource-lifecycle.ts") try { await writeFile(path, "export type ResourceLifecycle = { close: () => Promise }\n") @@ -83,11 +88,13 @@ describe("application architecture boundary", () => { await assertRemoved(path) }) test("Given a TUI presentation source importing a SQLite schema When scanned Then it is rejected", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-vpet-architecture-")) + const directory = await mkdtemp(join(tmpdir(), "opencode-digital-pet-architecture-")) const path = join(directory, "forbidden.ts") try { - await writeFile(path, 'import { ACTIVE_PARTNER_SELECT } from "../adapters/sqlite/sqlite-vpet-schema.ts"\n') - expect(await scanTuiPresentationImports(directory)).toEqual([`${path}: ../adapters/sqlite/sqlite-vpet-schema.ts`]) + await writeFile(path, 'import { ACTIVE_PARTNER_SELECT } from "../adapters/sqlite/sqlite-digital-pet-schema.ts"\n') + expect(await scanTuiPresentationImports(directory)).toEqual([ + `${path}: ../adapters/sqlite/sqlite-digital-pet-schema.ts`, + ]) } finally { await rm(directory, { recursive: true, force: true }) } @@ -98,9 +105,17 @@ describe("application architecture boundary", () => { findForbiddenImports("malformed.ts", 'import { broken\nimport "bun:sqlite"\n', FORBIDDEN_APPLICATION_IMPORTS), ).toEqual(["malformed.ts: bun:sqlite"])) test("Given the application source tree When scanned Then it contains no forbidden imports", async () => - expect(await scanApplicationImports(join(PROJECT_ROOT, "src", "application"))).toEqual([])) + expect(await scanApplicationImports(join(PROJECT_ROOT, "..", "digital-pet-core", "src", "application"))).toEqual( + [], + )) test("Given the application models When scanned Then they do not redeclare Partner core state", async () => - expect(await scanDuplicateCorePartnerDeclarations(join(PROJECT_ROOT, "src", "application", "models"))).toEqual([])) + expect( + await scanDuplicateCorePartnerDeclarations( + join(PROJECT_ROOT, "..", "digital-pet-core", "src", "application", "models"), + ), + ).toEqual([])) test("Given the application port tree When scanned Then it has no technical lifecycle contract", async () => - expect(await scanTechnicalLifecyclePorts(join(PROJECT_ROOT, "src", "application", "ports"))).toEqual([])) + expect( + await scanTechnicalLifecyclePorts(join(PROJECT_ROOT, "..", "digital-pet-core", "src", "application", "ports")), + ).toEqual([])) }) diff --git a/tests/catalog-evolution.test.ts b/packages/opencode-digital-pet/tests/catalog-evolution.test.ts similarity index 67% rename from tests/catalog-evolution.test.ts rename to packages/opencode-digital-pet/tests/catalog-evolution.test.ts index af386a9..e07c8cf 100644 --- a/tests/catalog-evolution.test.ts +++ b/packages/opencode-digital-pet/tests/catalog-evolution.test.ts @@ -1,11 +1,21 @@ import { describe, expect, test } from "bun:test" -import { loadDigimonCatalog, parseDigimonCatalog, type DigimonNode } from "../src/data/catalog.ts" -import { DIGIMON_DATA } from "../src/data/digimon-data.ts" -import type { DigimonNode as DomainDigimonNode } from "../src/domain/digimon-node.ts" -import { applyTokenProgress, STAGE_GAUGE_THRESHOLDS, type StageThresholds } from "../src/domain/evolution.ts" -import type { Partner, PartnerProgression } from "../src/domain/partner.ts" -import { DIGIMON_STAGES, isDigimonStage } from "../src/domain/stage.ts" +import { loadDigimonCatalog, parseDigimonCatalog, type DigimonNode } from "@jcendal/digital-pet-core/data/catalog.ts" +import { DIGIMON_DATA } from "@jcendal/digital-pet-core/data/digimon-data.ts" +import type { DigimonNode as DomainDigimonNode } from "@jcendal/digital-pet-core/domain/digimon-node.ts" +import { + applyTokenProgress, + resolveEvolutionBattle, + STAGE_GAUGE_THRESHOLDS, + type StageThresholds, +} from "@jcendal/digital-pet-core/domain/evolution.ts" +import type { Partner, PartnerProgression } from "@jcendal/digital-pet-core/domain/partner.ts" +import { DIGIMON_STAGES, isDigimonStage } from "@jcendal/digital-pet-core/domain/stage.ts" + +const noBattle = { + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, +} as const const controlledCurrent: DigimonNode = { id: "controlled-current", @@ -27,6 +37,19 @@ const controlledTarget: DigimonNode = { url: "https://example.test/controlled-target", } +const controlledOpponent: DigimonNode = { + id: "controlled-opponent", + nameEn: "Controlled Opponent", + nameJp: "Controlled Opponent", + nextEvolutions: [], + sprite: "controlled-opponent.png", + stage: 0, + url: "https://example.test/controlled-opponent", +} + +const controlledCatalogNodes = [controlledCurrent, controlledTarget, controlledOpponent] +const controlledLookup = new Map(controlledCatalogNodes.map((node) => [node.id, node])) + describe("catalog and evolution", () => { test("Given domain contracts When catalog data is parsed Then normalized nodes use the domain stage and node shapes", () => { const node: DomainDigimonNode = controlledCurrent @@ -36,6 +59,8 @@ describe("catalog and evolution", () => { currentNodeId: node.id, gauge: 0, isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, } @@ -43,12 +68,14 @@ describe("catalog and evolution", () => { currentNodeId: node.id, gauge: 0, isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, } const thresholds: StageThresholds = STAGE_GAUGE_THRESHOLDS expect(DIGIMON_STAGES.every(isDigimonStage)).toBeTrue() expect(partner.currentNodeId).toBe(progression.currentNodeId) - expect(thresholds[node.stage]).toBe(500_000) + expect(thresholds[node.stage]).toBe(5_000_000) }) test("Given a raw catalog row with a reference URL When parsing Then the normalized node preserves the URL and evolution references are immutable", () => { @@ -118,81 +145,103 @@ describe("catalog and evolution", () => { expect(() => parseDigimonCatalog([root])).toThrow(/next_evolutions/i) }) - test("Given a threshold crossing on a same-stage edge When token progress applies Then it resets after exactly one selected evolution", async () => { + test("Given a threshold crossing on a same-stage edge When token progress applies Then it opens a pending evolution battle", async () => { const catalog = await loadDigimonCatalog() const andromon = catalog.byId.get("5-003") if (andromon === undefined) throw new Error("Expected Andromon in catalog") - const evolved = applyTokenProgress( - { current: andromon, gauge: 12_499_999, isTerminal: false }, + const pending = applyTokenProgress( + { current: andromon, gauge: 124_999_999, isTerminal: false, ...noBattle }, 1, () => 0.5, catalog.byId, + catalog.nodes, STAGE_GAUGE_THRESHOLDS, ) + expect(pending.current.id).toBe("5-003") + expect(pending.pendingEvolutionTargetId).toBe("5-033") + expect(pending.battleOpponentNodeId).not.toBe("5-003") + expect(pending.gauge).toBe(STAGE_GAUGE_THRESHOLDS[5]) + + const evolved = resolveEvolutionBattle(pending, true, catalog.byId) expect(evolved.current.id).toBe("5-033") expect(evolved.gauge).toBe(0) + expect(evolved.pendingEvolutionTargetId).toBeNull() }) - test("Given a controlled immutable lookup When token progress reaches a threshold Then it selects the lookup target and marks terminal state", () => { - const lookup = new Map([[controlledTarget.id, controlledTarget]]) - + test("Given a Digitama threshold crossing When token progress applies Then it evolves immediately without a battle", () => { const evolved = applyTokenProgress( - { current: controlledCurrent, gauge: 499_999, isTerminal: false }, + { current: controlledCurrent, gauge: 4_999_999, isTerminal: false, ...noBattle }, 1, () => 0, - lookup, + controlledLookup, + controlledCatalogNodes, STAGE_GAUGE_THRESHOLDS, ) - expect(evolved).toEqual({ current: controlledTarget, gauge: 0, isTerminal: true }) + expect(evolved).toEqual({ + current: controlledTarget, + gauge: 0, + isTerminal: true, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }) }) test("Given a controlled lookup missing the selected target When token progress reaches a threshold Then it retains the existing catalog error", () => { expect(() => applyTokenProgress( - { current: controlledCurrent, gauge: 499_999, isTerminal: false }, + { current: controlledCurrent, gauge: 4_999_999, isTerminal: false, ...noBattle }, 1, () => 0, - new Map(), + new Map([[controlledCurrent.id, controlledCurrent]]), + [controlledCurrent], STAGE_GAUGE_THRESHOLDS, ), ).toThrow("Evolution target controlled-target is missing from the catalog") }) - test("Given a custom child threshold When token progress reaches it Then evolution crosses at the supplied policy boundary", () => { + test("Given a custom Digitama threshold When token progress reaches it Then evolution crosses at the supplied policy boundary", () => { const thresholds: StageThresholds = Object.freeze({ ...STAGE_GAUGE_THRESHOLDS, 0: 1, }) - const evolved = applyTokenProgress( - { current: controlledCurrent, gauge: 0, isTerminal: false }, - 1, - () => 0, - new Map([[controlledTarget.id, controlledTarget]]), - thresholds, - ) - - expect(evolved).toEqual({ current: controlledTarget, gauge: 0, isTerminal: true }) + expect( + applyTokenProgress( + { current: controlledCurrent, gauge: 0, isTerminal: false, ...noBattle }, + 1, + () => 0, + controlledLookup, + controlledCatalogNodes, + thresholds, + ), + ).toEqual({ + current: controlledTarget, + gauge: 0, + isTerminal: true, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }) }) test("Given an invalid selector When token progress reaches a threshold Then the existing selector error is retained", () => { expect(() => applyTokenProgress( - { current: controlledCurrent, gauge: 499_999, isTerminal: false }, + { current: controlledCurrent, gauge: 4_999_999, isTerminal: false, ...noBattle }, 1, () => 1, - new Map([[controlledTarget.id, controlledTarget]]), + controlledLookup, + controlledCatalogNodes, STAGE_GAUGE_THRESHOLDS, ), ).toThrow("Evolution selector must return a finite number in [0, 1), received 1") }) test("Given a terminal partner When token progress applies Then it preserves the existing terminal state", () => { - const state = { current: controlledTarget, gauge: 0, isTerminal: true } as const + const state = { current: controlledTarget, gauge: 0, isTerminal: true, ...noBattle } as const expect( applyTokenProgress( @@ -201,7 +250,8 @@ describe("catalog and evolution", () => { () => { throw new Error("selector must not run") }, - new Map(), + controlledLookup, + controlledCatalogNodes, STAGE_GAUGE_THRESHOLDS, ), ).toBe(state) @@ -215,24 +265,26 @@ describe("catalog and evolution", () => { expect( applyTokenProgress( - { current: controlledCurrent, gauge: 0, isTerminal: false }, + { current: controlledCurrent, gauge: 0, isTerminal: false, ...noBattle }, 1, () => { throw new Error("selector must not run") }, - new Map(), + controlledLookup, + controlledCatalogNodes, thresholds, ), - ).toEqual({ current: controlledCurrent, gauge: 1, isTerminal: false }) + ).toEqual({ current: controlledCurrent, gauge: 1, isTerminal: false, ...noBattle }) }) test("Given an incomplete untyped threshold policy When token progress applies Then it rejects the policy before evolution", () => { expect(() => applyTokenProgress( - { current: controlledCurrent, gauge: 0, isTerminal: false }, + { current: controlledCurrent, gauge: 0, isTerminal: false, ...noBattle }, 1, () => 0, - new Map([[controlledTarget.id, controlledTarget]]), + controlledLookup, + controlledCatalogNodes, {}, ), ).toThrow("Evolution thresholds must be a complete frozen policy of positive finite numbers") @@ -244,10 +296,11 @@ describe("catalog and evolution", () => { expect(() => applyTokenProgress( - { current: controlledCurrent, gauge: 0, isTerminal: false }, + { current: controlledCurrent, gauge: 0, isTerminal: false, ...noBattle }, 1, () => 0, - new Map([[controlledTarget.id, controlledTarget]]), + controlledLookup, + controlledCatalogNodes, thresholds, ), ).toThrow("Evolution thresholds must be a complete frozen policy of positive finite numbers") @@ -263,10 +316,11 @@ describe("catalog and evolution", () => { for (const thresholds of policies) { expect(() => applyTokenProgress( - { current: controlledCurrent, gauge: 0, isTerminal: false }, + { current: controlledCurrent, gauge: 0, isTerminal: false, ...noBattle }, 1, () => 0, - new Map([[controlledTarget.id, controlledTarget]]), + controlledLookup, + controlledCatalogNodes, thresholds, ), ).toThrow("Evolution thresholds must be a complete frozen policy of positive finite numbers") @@ -279,15 +333,40 @@ describe("catalog and evolution", () => { expect(Reflect.set(thresholds, 0, 1)).toBeFalse() expect( applyTokenProgress( - { current: controlledCurrent, gauge: 0, isTerminal: false }, + { current: controlledCurrent, gauge: 0, isTerminal: false, ...noBattle }, 1, () => { throw new Error("selector must not run") }, - new Map(), + controlledLookup, + controlledCatalogNodes, thresholds, ), - ).toEqual({ current: controlledCurrent, gauge: 1, isTerminal: false }) + ).toEqual({ current: controlledCurrent, gauge: 1, isTerminal: false, ...noBattle }) + }) + + test("Given a lost evolution battle When resolving Then gauge resets for the current stage", async () => { + const catalog = await loadDigimonCatalog() + const andromon = catalog.byId.get("5-003") + + if (andromon === undefined) throw new Error("Expected Andromon in catalog") + + const pending = applyTokenProgress( + { current: andromon, gauge: 124_999_999, isTerminal: false, ...noBattle }, + 1, + () => 0.5, + catalog.byId, + catalog.nodes, + STAGE_GAUGE_THRESHOLDS, + ) + + expect(resolveEvolutionBattle(pending, false, catalog.byId)).toEqual({ + current: andromon, + gauge: 0, + isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }) }) }) diff --git a/tests/cli-config.test.ts b/packages/opencode-digital-pet/tests/cli-config.test.ts similarity index 93% rename from tests/cli-config.test.ts rename to packages/opencode-digital-pet/tests/cli-config.test.ts index 3232207..a0ecfcb 100644 --- a/tests/cli-config.test.ts +++ b/packages/opencode-digital-pet/tests/cli-config.test.ts @@ -12,7 +12,7 @@ type FakeOpenCode = Readonly<{ readonly callsPath: string }> -const createRoot = async (): Promise => mkdtemp(join(tmpdir(), "opencode-vpet-cli-")) +const createRoot = async (): Promise => mkdtemp(join(tmpdir(), "opencode-digital-pet-cli-")) const createFakeOpenCode = async (root: string): Promise => { const binDirectory = join(root, "bin") @@ -41,7 +41,7 @@ const runCli = (arguments_: readonly string[], root: string, fake: FakeOpenCode, OPENCODE_CONFIG_DIR: join(root, "opencode"), OPENCODE_FAKE_CALLS: fake.callsPath, OPENCODE_FAKE_EXIT: String(exitCode), - PATH: `${fake.binDirectory}:${process.env.PATH ?? ""}`, + PATH: `${fake.binDirectory}:${process.env["PATH"] ?? ""}`, }, stdout: "pipe", stderr: "pipe", @@ -62,7 +62,7 @@ describe("OpenCode CLI installer", () => { try { const fake = await createFakeOpenCode(root) const manifest = JSON.parse(await Bun.file(new URL("../package.json", import.meta.url)).text()) - const expected = ["plugin", `@sbugallo/opencode-vpet@${manifest.version}`, "--global", "--force"] + const expected = ["plugin", `@jcendal/opencode-digital-pet@${manifest.version}`, "--global", "--force"] const init = runCli(["init"], root, fake) const update = runCli(["update"], root, fake) @@ -86,7 +86,7 @@ describe("OpenCode CLI installer", () => { const initDryRun = runCli(["init", "--dry-run"], root, fake) const updateDryRun = runCli(["update", "--dry-run"], root, fake) const manifest = JSON.parse(await Bun.file(new URL("../package.json", import.meta.url)).text()) - const plannedCommand = `opencode plugin @sbugallo/opencode-vpet@${manifest.version} --global --force\n` + const plannedCommand = `opencode plugin @jcendal/opencode-digital-pet@${manifest.version} --global --force\n` expect(help.exitCode, new TextDecoder().decode(help.stderr)).toBe(0) expect(invalid.exitCode).toBe(2) diff --git a/tests/config-loader.test.ts b/packages/opencode-digital-pet/tests/config-loader.test.ts similarity index 63% rename from tests/config-loader.test.ts rename to packages/opencode-digital-pet/tests/config-loader.test.ts index c28ff6d..6aad668 100644 --- a/tests/config-loader.test.ts +++ b/packages/opencode-digital-pet/tests/config-loader.test.ts @@ -4,21 +4,21 @@ import { tmpdir } from "node:os" import { join, posix, win32 } from "node:path" import { - loadGlobalVpetSettings, - loadGlobalVpetSettingsSync, - resolveGlobalVpetConfigPath, -} from "../src/config/global-vpet-settings.ts" -import { DEFAULT_VPET_SETTINGS } from "../src/config/defaults.ts" + loadGlobalDigitalPetSettings, + loadGlobalDigitalPetSettingsSync, + resolveGlobalDigitalPetConfigPath, +} from "../src/config/global-digital-pet-settings.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" const tempRoots: string[] = [] const createTempRoot = async (): Promise => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-config-")) + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-config-")) tempRoots.push(root) return root } -const resolveConfigPath = (root: string): string => join(root, ".config", "opencode-vpet.json") +const resolveConfigPath = (root: string): string => join(root, ".config", "opencode-digital-pet.json") const createConfigDirectory = async (root: string): Promise => { const directory = join(root, ".config") @@ -30,16 +30,27 @@ afterEach(async () => { await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) -describe("global VPet configuration paths", () => { +describe("global Digital Pet configuration paths", () => { + test("existing legacy configuration remains readable until a new configuration is created", async () => { + const root = await createTempRoot() + const directory = await createConfigDirectory(root) + const legacyPath = join(directory, "opencode-vpet.json") + await writeFile(legacyPath, JSON.stringify({ language: "jp" })) + expect(resolveGlobalDigitalPetConfigPath({ home: root, platform: "linux" })).toBe(legacyPath) + expect(loadGlobalDigitalPetSettingsSync({ home: root, platform: "linux" }).language).toBe("jp") + await writeFile(resolveConfigPath(root), JSON.stringify({ language: "en" })) + expect(resolveGlobalDigitalPetConfigPath({ home: root, platform: "linux" })).toBe(resolveConfigPath(root)) + }) + test.each([ - ["Linux", "linux", "/home/na me/雪", "/home/na me/雪/.config/opencode-vpet.json"], - ["macOS", "darwin", "/Users/na me/雪", "/Users/na me/雪/.config/opencode-vpet.json"], - ["WSL", "linux", "/home/na me/雪", "/home/na me/雪/.config/opencode-vpet.json"], + ["Linux", "linux", "/home/na me/雪", "/home/na me/雪/.config/opencode-digital-pet.json"], + ["macOS", "darwin", "/Users/na me/雪", "/Users/na me/雪/.config/opencode-digital-pet.json"], + ["WSL", "linux", "/home/na me/雪", "/home/na me/雪/.config/opencode-digital-pet.json"], ] as const)( - "Given %s with OpenCode and XDG variables When resolving with path.posix Then it uses the independent VPet path", + "Given %s with OpenCode and XDG variables When resolving with path.posix Then it uses the independent Digital Pet path", (_name, platform, home, expected) => { expect( - resolveGlobalVpetConfigPath({ + resolveGlobalDigitalPetConfigPath({ env: { APPDATA: "/app-data/雪", OPENCODE_CONFIG_DIR: "/open-code-config/雪", @@ -58,39 +69,39 @@ describe("global VPet configuration paths", () => { "APPDATA", { APPDATA: "C:\\App Data\\雪", OPENCODE_CONFIG_DIR: "C:\\OpenCode\\雪", XDG_CONFIG_HOME: "C:\\XDG\\雪" }, "C:\\Users\\ignored", - "C:\\App Data\\雪\\opencode-vpet.json", + "C:\\App Data\\雪\\opencode-digital-pet.json", ], [ "home fallback", { OPENCODE_CONFIG_DIR: "C:\\OpenCode\\雪", XDG_CONFIG_HOME: "C:\\XDG\\雪" }, "C:\\Users\\na me\\雪", - "C:\\Users\\na me\\雪\\AppData\\Roaming\\opencode-vpet.json", + "C:\\Users\\na me\\雪\\AppData\\Roaming\\opencode-digital-pet.json", ], ] as const)( - "Given native Windows with %s When resolving with path.win32 Then it uses the independent VPet path", + "Given native Windows with %s When resolving with path.win32 Then it uses the independent Digital Pet path", (_name, env, home, expected) => { - expect(resolveGlobalVpetConfigPath({ env, home, pathApi: win32, platform: "win32" })).toBe(expected) + expect(resolveGlobalDigitalPetConfigPath({ env, home, pathApi: win32, platform: "win32" })).toBe(expected) }, ) test.each([ - ["missing", async (_root: string) => undefined], + ["missing", async (_root: string): Promise => undefined], [ "unreadable directory", - async (root: string) => { + async (root: string): Promise => { await mkdir(resolveConfigPath(root), { recursive: true }) }, ], [ "malformed", - async (root: string) => { + async (root: string): Promise => { await createConfigDirectory(root) await writeFile(resolveConfigPath(root), "{") }, ], [ "scalar root", - async (root: string) => { + async (root: string): Promise => { await createConfigDirectory(root) await writeFile(resolveConfigPath(root), '"jp"') }, @@ -102,18 +113,18 @@ describe("global VPet configuration paths", () => { await createPath(root) const entriesBefore = await readdir(root) - const settings = loadGlobalVpetSettingsSync({ + const settings = loadGlobalDigitalPetSettingsSync({ home: root, pathApi: posix, }) - expect(settings).toEqual(DEFAULT_VPET_SETTINGS) + expect(settings).toEqual(DEFAULT_DIGITAL_PET_SETTINGS) expect(await readdir(root)).toEqual(entriesBefore) }, ) }) -describe("global VPet configuration loading", () => { +describe("global Digital Pet configuration loading", () => { test("Given a valid file with mixed settings When loading asynchronously Then valid siblings survive in a frozen snapshot", async () => { const root = await createTempRoot() await createConfigDirectory(root) @@ -122,16 +133,16 @@ describe("global VPet configuration loading", () => { JSON.stringify({ language: "en", notifications: false, stageThresholds: { child: 777, egg: 0 } }), ) - const settings = await loadGlobalVpetSettings({ + const settings = await loadGlobalDigitalPetSettings({ home: root, pathApi: posix, }) expect(settings).toEqual({ - ...DEFAULT_VPET_SETTINGS, + ...DEFAULT_DIGITAL_PET_SETTINGS, language: "en", notifications: false, - stageThresholds: { ...DEFAULT_VPET_SETTINGS.stageThresholds, child: 777 }, + stageThresholds: { ...DEFAULT_DIGITAL_PET_SETTINGS.stageThresholds, child: 777 }, }) expect(Object.isFrozen(settings)).toBeTrue() expect(Object.isFrozen(settings.stageThresholds)).toBeTrue() @@ -142,7 +153,7 @@ describe("global VPet configuration loading", () => { await createConfigDirectory(root) await writeFile(resolveConfigPath(root), JSON.stringify({ stageThresholds: { adult: 999 } })) - const settings = loadGlobalVpetSettingsSync({ home: root, pathApi: posix }) + const settings = loadGlobalDigitalPetSettingsSync({ home: root, pathApi: posix }) expect(settings.stageThresholds.adult).toBe(999) expect(Object.isFrozen(settings)).toBeTrue() @@ -161,29 +172,29 @@ describe("global VPet configuration loading", () => { await createConfigDirectory(root) await writeFile(resolveConfigPath(root), JSON.stringify(config)) - expect((await loadGlobalVpetSettings({ home: root, pathApi: posix })).notifications).toBeTrue() - expect(loadGlobalVpetSettingsSync({ home: root, pathApi: posix }).notifications).toBeTrue() + expect((await loadGlobalDigitalPetSettings({ home: root, pathApi: posix })).notifications).toBeTrue() + expect(loadGlobalDigitalPetSettingsSync({ home: root, pathApi: posix }).notifications).toBeTrue() }, ) test.each([ - ["missing", async (_root: string) => undefined], + ["missing", async (_root: string): Promise => undefined], [ "unreadable directory", - async (root: string) => { + async (root: string): Promise => { await mkdir(resolveConfigPath(root), { recursive: true }) }, ], [ "malformed", - async (root: string) => { + async (root: string): Promise => { await createConfigDirectory(root) await writeFile(resolveConfigPath(root), "{") }, ], [ "scalar root", - async (root: string) => { + async (root: string): Promise => { await createConfigDirectory(root) await writeFile(resolveConfigPath(root), '"jp"') }, @@ -195,12 +206,12 @@ describe("global VPet configuration loading", () => { await createPath(root) const entriesBefore = await readdir(root) - const settings = await loadGlobalVpetSettings({ + const settings = await loadGlobalDigitalPetSettings({ home: root, pathApi: posix, }) - expect(settings).toEqual(DEFAULT_VPET_SETTINGS) + expect(settings).toEqual(DEFAULT_DIGITAL_PET_SETTINGS) expect(await readdir(root)).toEqual(entriesBefore) }, ) diff --git a/tests/config-settings.test.ts b/packages/opencode-digital-pet/tests/config-settings.test.ts similarity index 79% rename from tests/config-settings.test.ts rename to packages/opencode-digital-pet/tests/config-settings.test.ts index 1a78108..4e8b4ae 100644 --- a/tests/config-settings.test.ts +++ b/packages/opencode-digital-pet/tests/config-settings.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from "bun:test" -import { normalizeVpetSettings } from "../src/config/normalize.ts" -import { STAGE_THRESHOLD_KEYS, VPET_LANGUAGES, type ResolvedVpetSettings } from "../src/config/types.ts" +import { normalizeDigitalPetSettings } from "@jcendal/digital-pet-core/config/normalize.ts" +import { + STAGE_THRESHOLD_KEYS, + DIGITAL_PET_LANGUAGES, + type ResolvedDigitalPetSettings, +} from "@jcendal/digital-pet-core/config/types.ts" const expectedDefaults = { language: "jp", @@ -29,18 +33,18 @@ const expectedDefaults = { }, }, stageThresholds: { - egg: 500_000, - babyI: 1_000_000, - babyII: 2_000_000, - child: 4_000_000, - adult: 7_500_000, - perfect: 12_500_000, - ultimate: 20_000_000, - superUltimate: 30_000_000, + egg: 5_000_000, + babyI: 10_000_000, + babyII: 20_000_000, + child: 40_000_000, + adult: 75_000_000, + perfect: 125_000_000, + ultimate: 200_000_000, + superUltimate: 300_000_000, }, -} as const satisfies ResolvedVpetSettings +} as const satisfies ResolvedDigitalPetSettings -const happyCases: readonly [string, unknown, ResolvedVpetSettings][] = [ +const happyCases: readonly [string, unknown, ResolvedDigitalPetSettings][] = [ ["empty object", {}, expectedDefaults], [ "complete English config", @@ -84,7 +88,7 @@ const happyCases: readonly [string, unknown, ResolvedVpetSettings][] = [ ], ] -const fallbackCases: readonly [string, unknown, ResolvedVpetSettings][] = [ +const fallbackCases: readonly [string, unknown, ResolvedDigitalPetSettings][] = [ ["null root", null, expectedDefaults], ["array root", [], expectedDefaults], ["scalar root", "jp", expectedDefaults], @@ -113,19 +117,19 @@ const fallbackCases: readonly [string, unknown, ResolvedVpetSettings][] = [ ["wrong threshold container", { language: true, stageThresholds: [11, 22] }, expectedDefaults], ] -describe("vpet settings normalization", () => { +describe("digital-pet settings normalization", () => { test.each(happyCases)( "Given %s When normalizing Then it returns the exact resolved settings", (_name, input, expected) => { - expect(normalizeVpetSettings(input)).toEqual(expected) + expect(normalizeDigitalPetSettings(input)).toEqual(expected) }, ) test.each(fallbackCases)( "Given %s When normalizing Then it independently falls back without throwing", (_name, input, expected) => { - expect(() => normalizeVpetSettings(input)).not.toThrow() - expect(normalizeVpetSettings(input)).toEqual(expected) + expect(() => normalizeDigitalPetSettings(input)).not.toThrow() + expect(normalizeDigitalPetSettings(input)).toEqual(expected) }, ) @@ -148,12 +152,12 @@ describe("vpet settings normalization", () => { ] as const)( "Given %s notifications When normalizing Then only literal booleans survive", (_name, input, expected) => { - expect(normalizeVpetSettings(input).notifications).toBe(expected) + expect(normalizeDigitalPetSettings(input).notifications).toBe(expected) }, ) test("Given invalid notifications with valid language and threshold siblings When normalizing Then sibling values survive", () => { - const settings = normalizeVpetSettings({ + const settings = normalizeDigitalPetSettings({ language: "en", notifications: "false", stageThresholds: { child: 777 }, @@ -165,7 +169,7 @@ describe("vpet settings normalization", () => { }) test("Given resolved settings When mutation is attempted Then every resolved layer is frozen", () => { - const settings = normalizeVpetSettings({}) + const settings = normalizeDigitalPetSettings({}) expect(Object.isFrozen(settings)).toBeTrue() expect(Object.isFrozen(settings.stageThresholds)).toBeTrue() @@ -182,7 +186,7 @@ describe("vpet settings normalization", () => { throw new Error("root") }, }), - 4_000_000, + 40_000_000, ], [ "language getter", @@ -191,7 +195,7 @@ describe("vpet settings normalization", () => { throw new Error("language") }, }), - 4_000_000, + 40_000_000, ], [ "stageThresholds getter", @@ -200,7 +204,7 @@ describe("vpet settings normalization", () => { throw new Error("thresholds") }, }), - 4_000_000, + 40_000_000, ], [ "threshold getter with valid sibling", @@ -228,7 +232,7 @@ describe("vpet settings normalization", () => { }, }, ), - 4_000_000, + 40_000_000, ], [ "threshold Proxy get trap with valid sibling", @@ -252,17 +256,17 @@ describe("vpet settings normalization", () => { ])( "Given a throwing %s When normalizing Then it falls back without losing valid siblings", (_name, input, expectedChild) => { - expect(() => normalizeVpetSettings(input)).not.toThrow() - expect(normalizeVpetSettings(input).stageThresholds.child).toBe(expectedChild) + expect(() => normalizeDigitalPetSettings(input)).not.toThrow() + expect(normalizeDigitalPetSettings(input).stageThresholds.child).toBe(expectedChild) }, ) test("Given public config collections When push mutation is attempted Then both exports remain frozen", () => { - expect(Object.isFrozen(VPET_LANGUAGES)).toBeTrue() + expect(Object.isFrozen(DIGITAL_PET_LANGUAGES)).toBeTrue() expect(Object.isFrozen(STAGE_THRESHOLD_KEYS)).toBeTrue() - expect(() => Reflect.apply(Array.prototype.push, VPET_LANGUAGES, ["en"])).toThrow() + expect(() => Reflect.apply(Array.prototype.push, DIGITAL_PET_LANGUAGES, ["en"])).toThrow() expect(() => Reflect.apply(Array.prototype.push, STAGE_THRESHOLD_KEYS, ["egg"])).toThrow() - expect(VPET_LANGUAGES).toEqual(["en", "jp"]) + expect(DIGITAL_PET_LANGUAGES).toEqual(["en", "jp"]) expect(STAGE_THRESHOLD_KEYS).toEqual([ "egg", "babyI", diff --git a/packages/opencode-digital-pet/tests/dev/attach-dev-tools.test.ts b/packages/opencode-digital-pet/tests/dev/attach-dev-tools.test.ts new file mode 100644 index 0000000..ab4ec6e --- /dev/null +++ b/packages/opencode-digital-pet/tests/dev/attach-dev-tools.test.ts @@ -0,0 +1,112 @@ +import { beforeAll, describe, expect, test } from "bun:test" +import { existsSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { join } from "node:path" + +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { createTui } from "../../src/tui.tsx" +import { OPENCODE_DEV_ACTIONS } from "../../src/dev/catalog.ts" +import { partnerInputs, settle, TuiCompositionHarness } from "../tui-composition-fixture.ts" + +const PACKAGE_ROOT = fileURLToPath(new URL("../../", import.meta.url)) +const DIST_DEV_TOOLS = join(PACKAGE_ROOT, "dist/dev/attach-dev-tools.js") +const DIST_TUI = new URL("../../dist/tui.js", import.meta.url).href + +const DEV_COMMAND_IDS = OPENCODE_DEV_ACTIONS.map((action) => action.id) + +const withDevEnv = async (run: () => Promise): Promise => { + const previous = process.env["OPENCODE_DIGITAL_PET_DEV"] + process.env["OPENCODE_DIGITAL_PET_DEV"] = "1" + try { + await run() + } finally { + if (previous === undefined) delete process.env["OPENCODE_DIGITAL_PET_DEV"] + else process.env["OPENCODE_DIGITAL_PET_DEV"] = previous + } +} + +const withoutDevEnv = async (run: () => Promise): Promise => { + const previous = process.env["OPENCODE_DIGITAL_PET_DEV"] + delete process.env["OPENCODE_DIGITAL_PET_DEV"] + try { + await run() + } finally { + if (previous === undefined) delete process.env["OPENCODE_DIGITAL_PET_DEV"] + else process.env["OPENCODE_DIGITAL_PET_DEV"] = previous + } +} + +const expectDevLayer = (harness: TuiCompositionHarness): void => { + const devLayer = harness.layers.find((layer) => layer["namespace"] === "opencode-digital-pet-dev") + expect(devLayer).toBeDefined() + expect(devLayer?.commands?.map((command) => command["name"])).toEqual(DEV_COMMAND_IDS) +} + +describe("opencode-digital-pet dev attach-dev-tools", () => { + test("Given the dev catalog When inspected Then it exposes five palette commands", () => { + expect(OPENCODE_DEV_ACTIONS.map((action) => action.id)).toEqual([ + "feed", + "activity", + "evolution_reveal", + "evolution_battle", + "defeat", + ]) + }) + + test("Given OPENCODE_DIGITAL_PET_DEV When createTui composes Then the dev command layer registers", async () => { + await withDevEnv(async () => { + const harness = new TuiCompositionHarness() + await harness.startWith(createTui(() => partnerInputs("agumon"), DEFAULT_DIGITAL_PET_SETTINGS)) + expectDevLayer(harness) + await harness.dispose?.() + }) + }) + + test("Given OPENCODE_DIGITAL_PET_DEV When the feed command runs Then it publishes a feed animation", async () => { + await withDevEnv(async () => { + const harness = new TuiCompositionHarness() + await harness.start(() => partnerInputs("agumon")) + const observedBefore = harness.observed.length + harness.invokeCommand("feed") + harness.scheduler.tickVisual() + expect(harness.observed.length).toBeGreaterThan(observedBefore) + await harness.dispose?.() + }) + }) + + test("Given no OPENCODE_DIGITAL_PET_DEV When createTui composes Then the dev command layer is omitted", async () => { + await withoutDevEnv(async () => { + const harness = new TuiCompositionHarness() + await harness.startWith(createTui(() => partnerInputs("agumon"), DEFAULT_DIGITAL_PET_SETTINGS)) + expect(harness.layers.some((layer) => layer["namespace"] === "opencode-digital-pet-dev")).toBe(false) + await harness.dispose?.() + }) + }) +}) + +describe("opencode-digital-pet dev attach-dev-tools dist", () => { + beforeAll(() => { + if (!existsSync(DIST_DEV_TOOLS)) { + const build = Bun.spawnSync(["bun", "run", "build"], { cwd: PACKAGE_ROOT }) + if (build.exitCode !== 0) { + throw new Error(new TextDecoder().decode(build.stderr)) + } + } + }, 60_000) + + test("Given built dist When the dev attach module is imported Then attachDevTools is exported", async () => { + const module = await import(new URL("../../dist/dev/attach-dev-tools.js", import.meta.url).href) + expect(typeof module.attachDevTools).toBe("function") + }) + + test("Given built dist and OPENCODE_DIGITAL_PET_DEV When createTui composes Then the dev command layer registers", async () => { + await withDevEnv(async () => { + const { createTui: createBuiltTui } = await import(DIST_TUI) + const harness = new TuiCompositionHarness() + await harness.startWith(createBuiltTui(() => partnerInputs("agumon"), DEFAULT_DIGITAL_PET_SETTINGS)) + expectDevLayer(harness) + await harness.dispose?.() + await settle() + }) + }) +}) diff --git a/packages/opencode-digital-pet/tests/dev/setup-scenario.test.ts b/packages/opencode-digital-pet/tests/dev/setup-scenario.test.ts new file mode 100644 index 0000000..4ea158a --- /dev/null +++ b/packages/opencode-digital-pet/tests/dev/setup-scenario.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { join } from "node:path" + +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { recordUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import { resolveEvolutionBattleForPartner } from "@jcendal/digital-pet-core/application/use-cases/resolve-evolution-battle.ts" +import { spawnPartner } from "@jcendal/digital-pet-core/application/use-cases/spawn-partner.ts" +import type { StageThresholds } from "@jcendal/digital-pet-core/domain/evolution.ts" +import { DIGIMON_STAGES } from "@jcendal/digital-pet-core/domain/stage.ts" + +import { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" +import { setupBattlePending } from "../../src/dev/setup-scenario.ts" + +const DEV_INSTANT_BATTLE_THRESHOLDS: StageThresholds = Object.freeze( + Object.fromEntries(DIGIMON_STAGES.map((stage) => [stage, 1])) as StageThresholds, +) + +const applyDevUsage = ( + repository: Awaited>, + receiptKey: string, +): void => { + recordUsage({ + usage: { + receiptKey, + eventId: `dev:${receiptKey}`, + tokenDelta: 1, + cost: null, + createdAt: new Date().toISOString(), + }, + ledger: repository, + digimonById: DIGIMON_CATALOG.byId, + catalogNodes: DIGIMON_CATALOG.nodes, + selector: () => 0, + thresholds: DEV_INSTANT_BATTLE_THRESHOLDS, + }) +} + +const advancePartnerToStage = ( + repository: Awaited>, + targetStage: number, +): void => { + for (let step = 0; step < 12; step += 1) { + const partner = repository.getActivePartner() + const current = partner === null ? undefined : DIGIMON_CATALOG.byId.get(partner.currentNodeId) + if (current !== undefined && current.stage >= targetStage) return + + applyDevUsage(repository, `dev-progress-${step}`) + const pending = repository.getActivePartner() + if (pending?.pendingEvolutionTargetId !== null && pending?.battleOpponentNodeId !== null) { + resolveEvolutionBattleForPartner(repository, true, DIGIMON_CATALOG.byId, `dev-battle-${step}-${Date.now()}`) + } + } +} + +describe("opencode-digital-pet dev setup-scenario", () => { + test("Given a perfect-stage partner When battle setup runs Then a pending evolution battle opens", async () => { + const root = await mkdtemp(join(process.cwd(), ".tmp-opencode-digital-pet-dev-perfect-")) + try { + const repository = await createSqliteDigitalPetRepository({ appDataRoot: root }) + try { + spawnPartner(repository, new Date().toISOString()) + advancePartnerToStage(repository, 4) + + const current = DIGIMON_CATALOG.byId.get(repository.getActivePartner()?.currentNodeId ?? "") + expect(current?.stage).toBeGreaterThanOrEqual(4) + + setupBattlePending(repository) + const snapshot = repository.getSidebarSnapshot() + expect(snapshot?.pendingEvolutionTargetId).not.toBeNull() + expect(snapshot?.battleOpponentNodeId).not.toBeNull() + } finally { + repository.close() + } + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("Given an empty database When battle setup runs Then a pending evolution battle opens", async () => { + const root = await mkdtemp(join(process.cwd(), ".tmp-opencode-digital-pet-dev-")) + try { + const repository = await createSqliteDigitalPetRepository({ appDataRoot: root }) + try { + setupBattlePending(repository) + const snapshot = repository.getSidebarSnapshot() + expect(snapshot?.pendingEvolutionTargetId).not.toBeNull() + expect(snapshot?.battleOpponentNodeId).not.toBeNull() + } finally { + repository.close() + } + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/dex-view-model.test.ts b/packages/opencode-digital-pet/tests/dex-view-model.test.ts similarity index 85% rename from tests/dex-view-model.test.ts rename to packages/opencode-digital-pet/tests/dex-view-model.test.ts index a6e5a94..a7aaae6 100644 --- a/tests/dex-view-model.test.ts +++ b/packages/opencode-digital-pet/tests/dex-view-model.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" -import { DEFAULT_VPET_SETTINGS } from "../src/config/defaults.ts" -import { DIGIMON_CATALOG } from "../src/data/catalog.ts" -import { buildDexViewModel } from "../src/tui/dex-view-model.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { buildDexViewModel } from "@jcendal/digital-pet-core/view-models/dex-view-model.ts" const catalog = { nodes: [ @@ -62,7 +62,7 @@ describe("Dex view model", () => { ], }, catalog, - DEFAULT_VPET_SETTINGS, + DEFAULT_DIGITAL_PET_SETTINGS, ) expect(model).toEqual({ @@ -77,14 +77,14 @@ describe("Dex view model", () => { }) test("Given an empty or unavailable archive When building the Dex Then it preserves the explicit result state", () => { - expect(buildDexViewModel({ kind: "empty" }, catalog, DEFAULT_VPET_SETTINGS)).toEqual({ kind: "empty" }) + expect(buildDexViewModel({ kind: "empty" }, catalog, DEFAULT_DIGITAL_PET_SETTINGS)).toEqual({ kind: "empty" }) expect( - buildDexViewModel({ kind: "unavailable", message: "Archive unavailable" }, catalog, DEFAULT_VPET_SETTINGS), + buildDexViewModel({ kind: "unavailable", message: "Archive unavailable" }, catalog, DEFAULT_DIGITAL_PET_SETTINGS), ).toEqual({ kind: "unavailable", message: "Archive unavailable" }) }) test("Given the production catalog When building the Dex Then it deterministically projects all 650 catalog IDs once", () => { - const model = buildDexViewModel({ kind: "available", partners: [] }, DIGIMON_CATALOG, DEFAULT_VPET_SETTINGS) + const model = buildDexViewModel({ kind: "available", partners: [] }, DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS) if (model.kind !== "available") throw new Error("Expected available Dex model") diff --git a/tests/digimon-data.test.ts b/packages/opencode-digital-pet/tests/digimon-data.test.ts similarity index 96% rename from tests/digimon-data.test.ts rename to packages/opencode-digital-pet/tests/digimon-data.test.ts index 4fe215e..cbeeea8 100644 --- a/tests/digimon-data.test.ts +++ b/packages/opencode-digital-pet/tests/digimon-data.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { DIGIMON_DATA } from "../src/data/digimon-data" +import { DIGIMON_DATA } from "@jcendal/digital-pet-core/data/digimon-data.ts" const RAW_RECORD_KEYS = ["id", "name_en", "name_jp", "next_evolutions", "sprite", "stage", "url"] diff --git a/tests/vpet-command-layer.test.tsx b/packages/opencode-digital-pet/tests/digital-pet-command-layer.test.tsx similarity index 71% rename from tests/vpet-command-layer.test.tsx rename to packages/opencode-digital-pet/tests/digital-pet-command-layer.test.tsx index a14e5de..fc27b95 100644 --- a/tests/vpet-command-layer.test.tsx +++ b/packages/opencode-digital-pet/tests/digital-pet-command-layer.test.tsx @@ -2,30 +2,30 @@ import { describe, expect, test } from "bun:test" import { testRender } from "@opentui/solid" -import type { VpetArchiveReader } from "../src/application/ports/vpet-archive.ts" -import { DEFAULT_VPET_SETTINGS } from "../src/config/defaults.ts" +import type { DigitalPetArchiveReader } from "@jcendal/digital-pet-core/application/ports/digital-pet-archive.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" import { createTui } from "../src/tui.tsx" import { partnerInputs, settle, TuiCompositionHarness } from "./tui-composition-fixture.ts" -describe("VPet command dialog ownership", () => { +describe("Digital Pet command dialog ownership", () => { test.each([ - ["Dex", "vpet-dex"], - ["history", "vpet-history"], + ["Dex", "digital-pet-dex"], + ["history", "digital-pet-history"], ])( "Given the %s command When Escape and the host close callback run Then one host modal is replaced and cleared once", async (_label, command) => { - const archiveReader: VpetArchiveReader = { + const archiveReader: DigitalPetArchiveReader = { getArchive: () => ({ kind: "empty" }), } const harness = new TuiCompositionHarness() - await harness.startWith(createTui(() => partnerInputs("agumon"), DEFAULT_VPET_SETTINGS, { archiveReader })) + await harness.startWith(createTui(() => partnerInputs("agumon"), DEFAULT_DIGITAL_PET_SETTINGS, { archiveReader })) harness.invokeCommand(command) expect(harness.dialogStack.replaceCount).toBe(1) expect(harness.dialogStack.sizes).toEqual(["medium"]) const render = harness.dialogStack.renders[0] - if (render === undefined) throw new Error("Expected VPet dialog body") + if (render === undefined) throw new Error("Expected Digital Pet dialog body") const setup = await testRender(render, { width: 60, height: 20 }) await setup.flush() expect(harness.dialogComponentCalls).toBe(0) diff --git a/tests/vpet-control.test.ts b/packages/opencode-digital-pet/tests/digital-pet-control.test.ts similarity index 52% rename from tests/vpet-control.test.ts rename to packages/opencode-digital-pet/tests/digital-pet-control.test.ts index fecf9e5..49a7f97 100644 --- a/tests/vpet-control.test.ts +++ b/packages/opencode-digital-pet/tests/digital-pet-control.test.ts @@ -1,21 +1,21 @@ import { describe, expect, test } from "bun:test" -import type { VpetControl } from "../src/application/ports/vpet-control.ts" -import { freezeVpet } from "../src/application/use-cases/freeze-vpet.ts" -import { setVpetCheatNode } from "../src/application/use-cases/set-vpet-cheat-node.ts" -import { unfreezeVpet } from "../src/application/use-cases/unfreeze-vpet.ts" +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" +import { freezeDigitalPet } from "@jcendal/digital-pet-core/application/use-cases/freeze-digital-pet.ts" +import { setDigitalPetCheatNode } from "@jcendal/digital-pet-core/application/use-cases/set-digital-pet-cheat-node.ts" +import { unfreezeDigitalPet } from "@jcendal/digital-pet-core/application/use-cases/unfreeze-digital-pet.ts" type ControlState = { frozen: boolean cheatNodeId: string | null } -type FakeVpetControl = VpetControl & { +type FakeDigitalPetControl = DigitalPetControl & { readonly calls: string[] readonly state: ControlState } -const createVpetControl = (initialState: ControlState): FakeVpetControl => { +const createDigitalPetControl = (initialState: ControlState): FakeDigitalPetControl => { const calls: string[] = [] const state = { ...initialState } @@ -43,12 +43,12 @@ const createVpetControl = (initialState: ControlState): FakeVpetControl => { } } -describe("vpet control application use cases", () => { - test("Given an unfrozen VPet When freeze runs twice Then the first transition applies and the second is idempotent", () => { - const control = createVpetControl({ frozen: false, cheatNodeId: null }) +describe("digital-pet control application use cases", () => { + test("Given an unfrozen Digital Pet When freeze runs twice Then the first transition applies and the second is idempotent", () => { + const control = createDigitalPetControl({ frozen: false, cheatNodeId: null }) - const first = freezeVpet(control) - const second = freezeVpet(control) + const first = freezeDigitalPet(control) + const second = freezeDigitalPet(control) expect(first).toEqual({ kind: "frozen" }) expect(second).toEqual({ kind: "already_frozen" }) @@ -56,11 +56,11 @@ describe("vpet control application use cases", () => { expect(control.state).toEqual({ frozen: true, cheatNodeId: null }) }) - test("Given a frozen VPet When unfreeze runs twice Then the first transition applies and the second is idempotent", () => { - const control = createVpetControl({ frozen: true, cheatNodeId: "3-001" }) + test("Given a frozen Digital Pet When unfreeze runs twice Then the first transition applies and the second is idempotent", () => { + const control = createDigitalPetControl({ frozen: true, cheatNodeId: "3-001" }) - const first = unfreezeVpet(control) - const second = unfreezeVpet(control) + const first = unfreezeDigitalPet(control) + const second = unfreezeDigitalPet(control) expect(first).toEqual({ kind: "unfrozen" }) expect(second).toEqual({ kind: "already_unfrozen" }) @@ -68,11 +68,11 @@ describe("vpet control application use cases", () => { expect(control.state).toEqual({ frozen: false, cheatNodeId: "3-001" }) }) - test("Given a frozen VPet and a catalog-validated node ID When set runs Then it preserves freeze and returns typed set outcomes", () => { - const control = createVpetControl({ frozen: true, cheatNodeId: "2-001" }) + test("Given a frozen Digital Pet and a catalog-validated node ID When set runs Then it preserves freeze and returns typed set outcomes", () => { + const control = createDigitalPetControl({ frozen: true, cheatNodeId: "2-001" }) - const first = setVpetCheatNode(control, "3-001") - const second = setVpetCheatNode(control, "3-001") + const first = setDigitalPetCheatNode(control, "3-001") + const second = setDigitalPetCheatNode(control, "3-001") expect(first).toEqual({ kind: "set", cheatNodeId: "3-001" }) expect(second).toEqual({ kind: "already_set", cheatNodeId: "3-001" }) @@ -80,10 +80,10 @@ describe("vpet control application use cases", () => { expect(control.state).toEqual({ frozen: true, cheatNodeId: "3-001" }) }) - test("Given a VPet control that throws during freeze When freeze runs Then the error propagates and no other transition is called", () => { + test("Given a Digital Pet control that throws during freeze When freeze runs Then the error propagates and no other transition is called", () => { const failure = new Error("freeze failed") const calls: string[] = [] - const control: VpetControl = { + const control: DigitalPetControl = { freeze: () => { calls.push("freeze") throw failure @@ -98,7 +98,7 @@ describe("vpet control application use cases", () => { }, } - expect(() => freezeVpet(control)).toThrow(failure) + expect(() => freezeDigitalPet(control)).toThrow(failure) expect(calls).toEqual(["freeze"]) }) }) diff --git a/tests/vpet-dex-dialog-fixture.tsx b/packages/opencode-digital-pet/tests/digital-pet-dex-dialog-fixture.tsx similarity index 80% rename from tests/vpet-dex-dialog-fixture.tsx rename to packages/opencode-digital-pet/tests/digital-pet-dex-dialog-fixture.tsx index f3aa0e4..26a370d 100644 --- a/tests/vpet-dex-dialog-fixture.tsx +++ b/packages/opencode-digital-pet/tests/digital-pet-dex-dialog-fixture.tsx @@ -1,11 +1,11 @@ /** @jsxImportSource @opentui/solid */ import { mock } from "bun:test" -import type { VpetDexDialogTheme } from "../src/tui/vpet-dex-dialog.tsx" +import type { DigitalPetDexDialogTheme } from "../src/tui/digital-pet-dex-dialog.tsx" import type { TuiDialogProps } from "@opencode-ai/plugin/tui" import { parseColor } from "@opentui/core" import type { JSX } from "solid-js" -export const createMockTheme = (): VpetDexDialogTheme => ({ +export const createMockTheme = (): DigitalPetDexDialogTheme => ({ current: { success: parseColor("#00ff00"), textMuted: parseColor("#888888"), diff --git a/tests/vpet-dex-dialog-hardening.test.tsx b/packages/opencode-digital-pet/tests/digital-pet-dex-dialog-hardening.test.tsx similarity index 55% rename from tests/vpet-dex-dialog-hardening.test.tsx rename to packages/opencode-digital-pet/tests/digital-pet-dex-dialog-hardening.test.tsx index 362f43c..884ea14 100644 --- a/tests/vpet-dex-dialog-hardening.test.tsx +++ b/packages/opencode-digital-pet/tests/digital-pet-dex-dialog-hardening.test.tsx @@ -1,29 +1,29 @@ /** @jsxImportSource @opentui/solid */ import { describe, expect, test, mock } from "bun:test" import { testRender } from "@opentui/solid" -import { VpetDexDialog } from "../src/tui/vpet-dex-dialog.tsx" -import type { DexViewModel } from "../src/tui/dex-view-model.ts" -import { createMockTheme } from "./vpet-dex-dialog-fixture.tsx" +import { DigitalPetDexDialog } from "../src/tui/digital-pet-dex-dialog.tsx" +import type { DexViewModel } from "@jcendal/digital-pet-core/view-models/dex-view-model.ts" +import { createMockTheme } from "./digital-pet-dex-dialog-fixture.tsx" -describe("VpetDexDialog hardening", () => { +describe("DigitalPetDexDialog hardening", () => { test("renders all undiscovered correctly", async () => { const onClose = mock() const model: DexViewModel = { kind: "available", rows: [ - { id: "vpet-001", stage: "Baby", discovered: false, name: "------" }, - { id: "vpet-002", stage: "Child", discovered: false, name: "------" }, + { id: "digital-pet-001", stage: "Baby", discovered: false, name: "------" }, + { id: "digital-pet-002", stage: "Child", discovered: false, name: "------" }, ], } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 24, }) await setup.flush() const frame = setup.captureCharFrame() - expect(frame).toContain("vpet-001") + expect(frame).toContain("digital-pet-001") expect(frame).not.toContain("Agumon") setup.renderer.destroy() @@ -34,12 +34,12 @@ describe("VpetDexDialog hardening", () => { const model: DexViewModel = { kind: "available", rows: [ - { id: "vpet-special-001", stage: "Mega", discovered: true, name: "Omegamon / WarGreymon" }, - { id: "vpet-extremely-long-unknown-id", stage: "Super", discovered: false, name: "------" }, + { id: "digital-pet-special-001", stage: "Mega", discovered: true, name: "Omegamon / WarGreymon" }, + { id: "digital-pet-extremely-long-unknown-id", stage: "Super", discovered: false, name: "------" }, ], } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 50, height: 24, }) @@ -54,7 +54,7 @@ describe("VpetDexDialog hardening", () => { test("scrolls the entire unfiltered catalogue", async () => { const onClose = mock() const rows = Array.from({ length: 50 }).map((_, i) => ({ - id: `vpet-${i.toString().padStart(3, "0")}`, + id: `digital-pet-${i.toString().padStart(3, "0")}`, stage: "Baby", discovered: true, name: `Mon ${i}`, @@ -65,15 +65,15 @@ describe("VpetDexDialog hardening", () => { rows, } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 10, }) await setup.flush() let frame = setup.captureCharFrame() - expect(frame).toContain("vpet-000") - expect(frame).not.toContain("vpet-049") + expect(frame).toContain("digital-pet-000") + expect(frame).not.toContain("digital-pet-049") for (let i = 0; i < 30; i++) { setup.mockInput.pressArrow("down") @@ -81,7 +81,7 @@ describe("VpetDexDialog hardening", () => { await setup.flush() frame = setup.captureCharFrame() - expect(frame).not.toContain("vpet-000") + expect(frame).not.toContain("digital-pet-000") setup.renderer.destroy() }) diff --git a/tests/vpet-dex-dialog.test.tsx b/packages/opencode-digital-pet/tests/digital-pet-dex-dialog.test.tsx similarity index 74% rename from tests/vpet-dex-dialog.test.tsx rename to packages/opencode-digital-pet/tests/digital-pet-dex-dialog.test.tsx index c44285a..0901479 100644 --- a/tests/vpet-dex-dialog.test.tsx +++ b/packages/opencode-digital-pet/tests/digital-pet-dex-dialog.test.tsx @@ -2,16 +2,16 @@ import { describe, expect, test, mock } from "bun:test" import { ScrollBoxRenderable } from "@opentui/core" import { testRender } from "@opentui/solid" -import { VpetDexDialog } from "../src/tui/vpet-dex-dialog.tsx" -import type { DexViewModel } from "../src/tui/dex-view-model.ts" -import { createMockTheme, createTrackedDialogApi } from "./vpet-dex-dialog-fixture.tsx" +import { DigitalPetDexDialog } from "../src/tui/digital-pet-dex-dialog.tsx" +import type { DexViewModel } from "@jcendal/digital-pet-core/view-models/dex-view-model.ts" +import { createMockTheme, createTrackedDialogApi } from "./digital-pet-dex-dialog-fixture.tsx" -describe("VpetDexDialog", () => { +describe("DigitalPetDexDialog", () => { test("renders empty state without invoking a nested Dialog", async () => { const onClose = mock() const model: DexViewModel = { kind: "empty" } const { theme, Dialog } = createTrackedDialogApi() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 24, }) @@ -26,7 +26,7 @@ describe("VpetDexDialog", () => { const onClose = mock() const model: DexViewModel = { kind: "unavailable", message: "Cannot load" } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 24, }) @@ -41,20 +41,20 @@ describe("VpetDexDialog", () => { const model: DexViewModel = { kind: "available", rows: Array.from({ length: 50 }, (_, index) => ({ - id: `vpet-${index.toString().padStart(3, "0")}`, + id: `digital-pet-${index.toString().padStart(3, "0")}`, stage: "Baby", discovered: true, name: `Mon ${index}`, })), } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 10, }) await setup.flush() - const renderable = setup.renderer.root.findDescendantById("vpet-dex-scrollbox") + const renderable = setup.renderer.root.findDescendantById("digital-pet-dex-scrollbox") if (!(renderable instanceof ScrollBoxRenderable)) throw new Error("Expected native Dex scrollbox") expect(renderable.primaryAxis).toBe("row") @@ -79,7 +79,7 @@ describe("VpetDexDialog", () => { expect(renderable.scrollTop).toBe(0) const frame = setup.captureCharFrame() - expect(frame).toContain("VPet Dex") + expect(frame).toContain("Digital Pet Dex") expect(frame).toContain("↑↓: 1 item · ←→: 10 items · Esc: Close") expect(frame).not.toContain("Search") expect(frame).not.toMatch(/[╭╮╰╯]/) @@ -97,10 +97,10 @@ describe("VpetDexDialog", () => { }) const model: DexViewModel = { kind: "available", - rows: [{ id: "vpet-001", stage: "Baby", discovered: true, name: "Botamon" }], + rows: [{ id: "digital-pet-001", stage: "Baby", discovered: true, name: "Botamon" }], } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 24, }) @@ -117,17 +117,17 @@ describe("VpetDexDialog", () => { const onClose = mock() const model: DexViewModel = { kind: "available", - rows: [{ id: "vpet-001", stage: "Baby", discovered: true, name: "Very Long Name Indeed" }], + rows: [{ id: "digital-pet-001", stage: "Baby", discovered: true, name: "Very Long Name Indeed" }], } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 25, height: 24, }) await setup.flush() const frame = setup.captureCharFrame() - expect(frame).toContain("vpet-001") + expect(frame).toContain("digital-pet-001") expect(frame).toContain("Very") expect(frame).toContain("Long") setup.renderer.destroy() @@ -137,14 +137,14 @@ describe("VpetDexDialog", () => { const onClose = mock() const model: DexViewModel = { kind: "empty" } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 32, height: 12, }) await setup.flush() const frame = setup.captureCharFrame() - expect(frame).toContain("VPet Dex") + expect(frame).toContain("Digital Pet Dex") expect(frame).toContain("Esc: Close") expect(frame).not.toContain("Search") expect(frame).not.toMatch(/[╭╮╰╯]/) @@ -155,7 +155,7 @@ describe("VpetDexDialog", () => { const onClose = mock() const model: DexViewModel = { kind: "empty" } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 60, height: 20, }) @@ -163,10 +163,10 @@ describe("VpetDexDialog", () => { const frame = setup.captureCharFrame() const lines = frame.split("\n") - expect(frame).toContain("VPet Dex") + expect(frame).toContain("Digital Pet Dex") expect(frame).toContain("No data available") expect(frame).toContain("Esc: Close") - expect(lines.findIndex((line) => line.includes("VPet Dex"))).toBeLessThan( + expect(lines.findIndex((line) => line.includes("Digital Pet Dex"))).toBeLessThan( lines.findIndex((line) => line.includes("No data available")), ) expect(lines.findIndex((line) => line.includes("No data available"))).toBeLessThan( @@ -179,7 +179,7 @@ describe("VpetDexDialog", () => { const onClose = mock() const model: DexViewModel = { kind: "empty" } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 32, height: 12, }) diff --git a/tests/vpet-dialog-layout.test.tsx b/packages/opencode-digital-pet/tests/digital-pet-dialog-layout.test.tsx similarity index 76% rename from tests/vpet-dialog-layout.test.tsx rename to packages/opencode-digital-pet/tests/digital-pet-dialog-layout.test.tsx index 242c2f3..8f5e4be 100644 --- a/tests/vpet-dialog-layout.test.tsx +++ b/packages/opencode-digital-pet/tests/digital-pet-dialog-layout.test.tsx @@ -3,9 +3,9 @@ import { describe, expect, mock, test } from "bun:test" import { testRender, useTerminalDimensions } from "@opentui/solid" import type { BoxRenderable } from "@opentui/core" import type { JSX } from "solid-js" -import { VpetDexDialog } from "../src/tui/vpet-dex-dialog.tsx" -import { VpetHistoryDialog } from "../src/tui/vpet-history-dialog.tsx" -import { createMockTheme } from "./vpet-dex-dialog-fixture.tsx" +import { DigitalPetDexDialog } from "../src/tui/digital-pet-dex-dialog.tsx" +import { DigitalPetHistoryDialog } from "../src/tui/digital-pet-history-dialog.tsx" +import { createMockTheme } from "./digital-pet-dex-dialog-fixture.tsx" type DialogLayoutCase = { readonly name: string @@ -15,15 +15,15 @@ type DialogLayoutCase = { const layoutCases: DialogLayoutCase[] = [ { name: "Dex", - render: () => , + render: () => , }, { name: "history", - render: () => , + render: () => , }, ] -describe("VPet dialog layout", () => { +describe("Digital Pet dialog layout", () => { test.each(layoutCases)( "Given the OpenCode host Dialog When it renders a %s replacement body Then the host-owned surface is centered in the viewport", async ({ render }) => { diff --git a/tests/vpet-freeze.test.ts b/packages/opencode-digital-pet/tests/digital-pet-freeze.test.ts similarity index 69% rename from tests/vpet-freeze.test.ts rename to packages/opencode-digital-pet/tests/digital-pet-freeze.test.ts index 9ef02eb..7b3d5e7 100644 --- a/tests/vpet-freeze.test.ts +++ b/packages/opencode-digital-pet/tests/digital-pet-freeze.test.ts @@ -2,10 +2,10 @@ import { describe, expect, test } from "bun:test" import type { Part } from "@opencode-ai/sdk" import { createServerHooks } from "../src/adapters/opencode/create-server-hooks.ts" -import { runVpetFreezeCommand } from "../src/commands/vpet-freeze.ts" +import { runDigitalPetFreezeCommand } from "../src/commands/digital-pet-freeze.ts" -describe("vpet freeze command", () => { - test("Given a VPet control When freeze runs repeatedly Then it returns the exact success text for both transitions", async () => { +describe("digital-pet freeze command", () => { + test("Given a Digital Pet control When freeze runs repeatedly Then it returns the exact success text for both transitions", async () => { let freezeCalls = 0 const control = { freeze: () => { @@ -14,15 +14,19 @@ describe("vpet freeze command", () => { }, } - const first = await runVpetFreezeCommand(control, { sessionID: "session-1", messageID: "freeze-1" }) - const second = await runVpetFreezeCommand(control, { sessionID: "session-1", messageID: "freeze-2" }) + const first = await runDigitalPetFreezeCommand(control, { sessionID: "session-1", messageID: "freeze-1" }) + const second = await runDigitalPetFreezeCommand(control, { sessionID: "session-1", messageID: "freeze-2" }) expect(first).toEqual({ - parts: [{ id: "freeze-1", sessionID: "session-1", messageID: "freeze-1", type: "text", text: "VPet frozen." }], + parts: [ + { id: "freeze-1", sessionID: "session-1", messageID: "freeze-1", type: "text", text: "Digital Pet frozen." }, + ], event: { kind: "frozen" }, }) expect(second).toEqual({ - parts: [{ id: "freeze-2", sessionID: "session-1", messageID: "freeze-2", type: "text", text: "VPet frozen." }], + parts: [ + { id: "freeze-2", sessionID: "session-1", messageID: "freeze-2", type: "text", text: "Digital Pet frozen." }, + ], }) expect(freezeCalls).toBe(2) }) @@ -44,6 +48,8 @@ describe("vpet freeze command", () => { isTerminal: false, createdAt: "2026-08-22T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), applyUsageReceipt: () => ({ kind: "no_active_partner" as const }), } @@ -52,15 +58,15 @@ describe("vpet freeze command", () => { const otherOutput = { parts: [] as Part[] } await hooks["command.execute.before"]?.( - { command: "vpet-freeze", sessionID: "session-1", arguments: "" }, + { command: "digital-pet-freeze", sessionID: "session-1", arguments: "" }, freezeOutput, ) await hooks["command.execute.before"]?.( - { command: "vpet-freeze ", sessionID: "session-1", arguments: "" }, + { command: "digital-pet-freeze ", sessionID: "session-1", arguments: "" }, otherOutput, ) - expect(freezeOutput.parts).toEqual([expect.objectContaining({ type: "text", text: "VPet frozen." })]) + expect(freezeOutput.parts).toEqual([expect.objectContaining({ type: "text", text: "Digital Pet frozen." })]) expect(otherOutput.parts).toEqual([]) expect(freezeCalls).toBe(1) }) @@ -78,6 +84,8 @@ describe("vpet freeze command", () => { isTerminal: false, createdAt: "2026-08-22T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), applyUsageReceipt: () => ({ kind: "no_active_partner" as const }), } @@ -85,8 +93,14 @@ describe("vpet freeze command", () => { const first = { parts: [] as Part[] } const second = { parts: [] as Part[] } - await hooks["command.execute.before"]?.({ command: "vpet-freeze", sessionID: "session-1", arguments: "" }, first) - await hooks["command.execute.before"]?.({ command: "vpet-freeze", sessionID: "session-1", arguments: "" }, second) + await hooks["command.execute.before"]?.( + { command: "digital-pet-freeze", sessionID: "session-1", arguments: "" }, + first, + ) + await hooks["command.execute.before"]?.( + { command: "digital-pet-freeze", sessionID: "session-1", arguments: "" }, + second, + ) expect(first.parts[0]?.id).not.toBe(second.parts[0]?.id) }) @@ -108,6 +122,8 @@ describe("vpet freeze command", () => { isTerminal: false, createdAt: "2026-08-22T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), applyUsageReceipt: () => ({ kind: "no_active_partner" as const }), } @@ -115,9 +131,9 @@ describe("vpet freeze command", () => { const handler = hooks["command.execute.before"] if (handler === undefined) throw new Error("Expected command handler") - await expect(handler({ command: "vpet-freeze", sessionID: "session-1", arguments: "" }, output)).rejects.toBe( - failure, - ) + await expect( + handler({ command: "digital-pet-freeze", sessionID: "session-1", arguments: "" }, output), + ).rejects.toBe(failure) expect(output.parts).toEqual([]) }) }) diff --git a/tests/vpet-history-dialog.test.tsx b/packages/opencode-digital-pet/tests/digital-pet-history-dialog.test.tsx similarity index 80% rename from tests/vpet-history-dialog.test.tsx rename to packages/opencode-digital-pet/tests/digital-pet-history-dialog.test.tsx index 32cc94c..d9249b3 100644 --- a/tests/vpet-history-dialog.test.tsx +++ b/packages/opencode-digital-pet/tests/digital-pet-history-dialog.test.tsx @@ -2,12 +2,12 @@ import { describe, expect, test, mock } from "bun:test" import { ScrollBoxRenderable } from "@opentui/core" import { testRender } from "@opentui/solid" -import { VpetHistoryDialog, type VpetHistoryDialogTheme } from "../src/tui/vpet-history-dialog.tsx" -import type { HistoryViewModel } from "../src/tui/history-view-model.ts" +import { DigitalPetHistoryDialog, type DigitalPetHistoryDialogTheme } from "../src/tui/digital-pet-history-dialog.tsx" +import type { HistoryViewModel } from "@jcendal/digital-pet-core/view-models/history-view-model.ts" import { parseColor } from "@opentui/core" -import { createTrackedDialogApi } from "./vpet-dex-dialog-fixture.tsx" +import { createTrackedDialogApi } from "./digital-pet-dex-dialog-fixture.tsx" -const createMockTheme = (): VpetHistoryDialogTheme => { +const createMockTheme = (): DigitalPetHistoryDialogTheme => { return { current: { primary: parseColor("#0000ff"), @@ -15,12 +15,12 @@ const createMockTheme = (): VpetHistoryDialogTheme => { } } -describe("VpetHistoryDialog", () => { +describe("DigitalPetHistoryDialog", () => { test("renders empty state without invoking a nested Dialog", async () => { const onClose = mock() const model: HistoryViewModel = { kind: "empty" } const { theme, Dialog } = createTrackedDialogApi() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 24, }) @@ -41,7 +41,7 @@ describe("VpetHistoryDialog", () => { ], } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 24, }) @@ -71,7 +71,7 @@ describe("VpetHistoryDialog", () => { ], } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 30, height: 24, }) @@ -99,7 +99,7 @@ describe("VpetHistoryDialog", () => { }) const model: HistoryViewModel = { kind: "empty" } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 24, }) @@ -127,7 +127,7 @@ describe("VpetHistoryDialog", () => { generations, } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 10, }) @@ -161,13 +161,13 @@ describe("VpetHistoryDialog", () => { })), } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 32, height: 12, }) await setup.flush() - const renderable = setup.renderer.root.findDescendantById("vpet-history-scrollbox") + const renderable = setup.renderer.root.findDescendantById("digital-pet-history-scrollbox") if (!(renderable instanceof ScrollBoxRenderable)) throw new Error("Expected native History scrollbox") const frame = setup.captureCharFrame() @@ -177,7 +177,7 @@ describe("VpetHistoryDialog", () => { expect(renderable.verticalScrollBar.y).toBe(renderable.viewport.y) expect(renderable.verticalScrollBar.height).toBe(renderable.viewport.height) expect(footerRow).toBeGreaterThan(renderable.y + renderable.height - 1) - expect(frame).toContain("VPet History") + expect(frame).toContain("Digital Pet History") expect(frame).toContain("Esc: Close") expect(frame).not.toMatch(/[╭╮╰╯]/) setup.renderer.destroy() @@ -196,13 +196,13 @@ describe("VpetHistoryDialog", () => { })), } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 10, }) await setup.flush() - const renderable = setup.renderer.root.findDescendantById("vpet-history-scrollbox") + const renderable = setup.renderer.root.findDescendantById("digital-pet-history-scrollbox") if (!(renderable instanceof ScrollBoxRenderable)) throw new Error("Expected native History scrollbox") setup.mockInput.pressArrow("down") @@ -229,7 +229,7 @@ describe("VpetHistoryDialog", () => { const onClose = mock() const model: HistoryViewModel = { kind: "empty" } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 60, height: 20, }) @@ -237,10 +237,10 @@ describe("VpetHistoryDialog", () => { const frame = setup.captureCharFrame() const lines = frame.split("\n") - expect(frame).toContain("VPet History") + expect(frame).toContain("Digital Pet History") expect(frame).toContain("No data available") expect(frame).toContain("Esc: Close") - expect(lines.findIndex((line) => line.includes("VPet History"))).toBeLessThan( + expect(lines.findIndex((line) => line.includes("Digital Pet History"))).toBeLessThan( lines.findIndex((line) => line.includes("No data available")), ) expect(lines.findIndex((line) => line.includes("No data available"))).toBeLessThan( @@ -253,7 +253,7 @@ describe("VpetHistoryDialog", () => { const onClose = mock() const model: HistoryViewModel = { kind: "empty" } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 32, height: 12, }) diff --git a/tests/vpet-set.test.ts b/packages/opencode-digital-pet/tests/digital-pet-set.test.ts similarity index 80% rename from tests/vpet-set.test.ts rename to packages/opencode-digital-pet/tests/digital-pet-set.test.ts index 7e6ef82..d940622 100644 --- a/tests/vpet-set.test.ts +++ b/packages/opencode-digital-pet/tests/digital-pet-set.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test" import type { Part } from "@opencode-ai/sdk" -import type { DigimonCatalog } from "../src/data/catalog.ts" +import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" import { createServerHooks } from "../src/adapters/opencode/create-server-hooks.ts" -import { runVpetSetCommand } from "../src/commands/vpet-set.ts" +import { runDigitalPetSetCommand } from "../src/commands/digital-pet-set.ts" const nodes = [ { @@ -31,7 +31,7 @@ const catalog: DigimonCatalog = { byId: new Map(nodes.map((node) => [node.id, node])), } -describe("vpet set command", () => { +describe("digital-pet set command", () => { test("Given whitespace-padded egg and terminal IDs When set runs Then it trims and returns exact catalog-backed messages", async () => { const calls: string[] = [] const control = { @@ -42,12 +42,12 @@ describe("vpet set command", () => { } const loadCatalog = async () => catalog - const egg = await runVpetSetCommand( + const egg = await runDigitalPetSetCommand( control, { sessionID: "session-1", messageID: "set-egg", arguments: " 0-001 " }, loadCatalog, ) - const terminal = await runVpetSetCommand( + const terminal = await runDigitalPetSetCommand( control, { sessionID: "session-1", messageID: "set-terminal", arguments: "\t7-001\t" }, loadCatalog, @@ -60,7 +60,7 @@ describe("vpet set command", () => { sessionID: "session-1", messageID: "set-egg", type: "text", - text: "VPet set to Digitama (0-001).", + text: "Digital Pet set to Digitama (0-001).", }, ], event: { kind: "set", nodeId: "0-001" }, @@ -72,7 +72,7 @@ describe("vpet set command", () => { sessionID: "session-1", messageID: "set-terminal", type: "text", - text: "VPet set to Susanoomon (7-001).", + text: "Digital Pet set to Susanoomon (7-001).", }, ], event: { kind: "set", nodeId: "7-001" }, @@ -91,7 +91,7 @@ describe("vpet set command", () => { }, } - const result = await runVpetSetCommand( + const result = await runDigitalPetSetCommand( control, { sessionID: "session-1", messageID: "invalid", arguments: arguments_ }, async () => catalog, @@ -99,7 +99,13 @@ describe("vpet set command", () => { expect(result).toEqual({ parts: [ - { id: "invalid", sessionID: "session-1", messageID: "invalid", type: "text", text: "Usage: /vpet-set ." }, + { + id: "invalid", + sessionID: "session-1", + messageID: "invalid", + type: "text", + text: "Usage: /digital-pet-set .", + }, ], }) expect(writes).toBe(0) @@ -108,7 +114,7 @@ describe("vpet set command", () => { test("Given an unknown ID When set runs Then it appends the exact error and makes zero writes", async () => { let writes = 0 - const result = await runVpetSetCommand( + const result = await runDigitalPetSetCommand( { setCheatNode: () => { writes += 1 @@ -142,20 +148,20 @@ describe("vpet set command", () => { }, } - const same = await runVpetSetCommand( + const same = await runDigitalPetSetCommand( control, { sessionID: "session-1", messageID: "same", arguments: "0-001" }, async () => catalog, ) - const replacement = await runVpetSetCommand( + const replacement = await runDigitalPetSetCommand( control, { sessionID: "session-1", messageID: "replacement", arguments: "7-001" }, async () => catalog, ) - expect(same).toEqual({ parts: [expect.objectContaining({ text: "VPet set to Digitama (0-001)." })] }) + expect(same).toEqual({ parts: [expect.objectContaining({ text: "Digital Pet set to Digitama (0-001)." })] }) expect(replacement).toEqual({ - parts: [expect.objectContaining({ text: "VPet set to Susanoomon (7-001)." })], + parts: [expect.objectContaining({ text: "Digital Pet set to Susanoomon (7-001)." })], event: { kind: "set", nodeId: "7-001" }, }) expect(calls).toEqual(["0-001", "7-001"]) @@ -164,7 +170,7 @@ describe("vpet set command", () => { test("Given catalog or control failures When set runs Then the failure propagates without a part", async () => { const catalogFailure = new Error("catalog failed") await expect( - runVpetSetCommand( + runDigitalPetSetCommand( { setCheatNode: () => ({ kind: "set" as const, cheatNodeId: "0-001" }) }, { sessionID: "session-1", messageID: "catalog-failure", arguments: "0-001" }, async () => { @@ -175,7 +181,7 @@ describe("vpet set command", () => { const controlFailure = new Error("set failed") await expect( - runVpetSetCommand( + runDigitalPetSetCommand( { setCheatNode: () => { throw controlFailure @@ -187,7 +193,7 @@ describe("vpet set command", () => { ).rejects.toBe(controlFailure) }) - test("Given a complete repository When the hook dispatches vpet-set Then it routes valid raw input once", async () => { + test("Given a complete repository When the hook dispatches digital-pet-set Then it routes valid raw input once", async () => { const calls: string[] = [] const repository = { freeze: () => ({ kind: "frozen" as const }), @@ -204,6 +210,8 @@ describe("vpet set command", () => { isTerminal: false, createdAt: "2026-08-22T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), applyUsageReceipt: () => ({ kind: "no_active_partner" as const }), } @@ -211,11 +219,11 @@ describe("vpet set command", () => { const output = { parts: [] as Part[] } await hooks["command.execute.before"]?.( - { command: "vpet-set", sessionID: "session-1", arguments: " 0-001 " }, + { command: "digital-pet-set", sessionID: "session-1", arguments: " 0-001 " }, output, ) - expect(output.parts).toEqual([expect.objectContaining({ text: "VPet set to Digiegg (0-001)." })]) + expect(output.parts).toEqual([expect.objectContaining({ text: "Digital Pet set to Digiegg (0-001)." })]) expect(calls).toEqual(["0-001"]) }) }) diff --git a/tests/vpet-spawn.test.ts b/packages/opencode-digital-pet/tests/digital-pet-spawn.test.ts similarity index 72% rename from tests/vpet-spawn.test.ts rename to packages/opencode-digital-pet/tests/digital-pet-spawn.test.ts index 1b7fb63..652db7f 100644 --- a/tests/vpet-spawn.test.ts +++ b/packages/opencode-digital-pet/tests/digital-pet-spawn.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from "bun:test" import type { Part } from "@opencode-ai/sdk" -import { spawnPartner } from "../src/application/use-cases/spawn-partner.ts" +import { spawnPartner } from "@jcendal/digital-pet-core/application/use-cases/spawn-partner.ts" import { createServerHooks } from "../src/adapters/opencode/create-server-hooks.ts" -import { runVpetSpawnCommand } from "../src/commands/vpet-spawn.ts" +import { runDigitalPetSpawnCommand } from "../src/commands/digital-pet-spawn.ts" -describe("vpet spawn command", () => { +describe("digital-pet spawn command", () => { test("Given a partner lifecycle When the application spawns a partner Then it uses the fixed initial state and returns the generation", () => { const calls: unknown[] = [] const lifecycle = { @@ -19,6 +19,8 @@ describe("vpet spawn command", () => { isTerminal: false, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, } }, } @@ -36,7 +38,7 @@ describe("vpet spawn command", () => { expect(result).toEqual({ generation: 2 }) }) - test("Given the vpet spawn command When it runs Then it atomically creates the egg generation and returns a text part", async () => { + test("Given the digital-pet spawn command When it runs Then it atomically creates the egg generation and returns a text part", async () => { const spawned = { partnerId: "partner-2", generation: 2, @@ -45,6 +47,8 @@ describe("vpet spawn command", () => { isTerminal: false, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, } const calls: unknown[] = [] const repository = { @@ -54,9 +58,9 @@ describe("vpet spawn command", () => { }, } - const result = await runVpetSpawnCommand(repository, { + const result = await runDigitalPetSpawnCommand(repository, { sessionID: "session-1", - messageID: "vpet-spawn-session-1", + messageID: "digital-pet-spawn-session-1", createdAt: spawned.createdAt, }) @@ -64,9 +68,9 @@ describe("vpet spawn command", () => { expect(result).toEqual({ parts: [ { - id: "vpet-spawn-session-1", + id: "digital-pet-spawn-session-1", sessionID: "session-1", - messageID: "vpet-spawn-session-1", + messageID: "digital-pet-spawn-session-1", type: "text", text: "Spawned Generation 2.", }, @@ -75,7 +79,7 @@ describe("vpet spawn command", () => { }) }) - test("Given a repository stub When OpenCode dispatches the raw command name Then the hook appends spawn parts only for vpet-spawn", async () => { + test("Given a repository stub When OpenCode dispatches the raw command name Then the hook appends spawn parts only for digital-pet-spawn", async () => { let spawnCalls = 0 const repository = { spawnPartner: () => { @@ -88,6 +92,8 @@ describe("vpet spawn command", () => { isTerminal: false, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, } }, applyUsageReceipt: () => ({ kind: "no_active_partner" as const }), @@ -100,7 +106,7 @@ describe("vpet spawn command", () => { const unrelatedOutput = { parts: [] as Part[] } await hooks["command.execute.before"]?.( - { command: "vpet-spawn", sessionID: "session-1", arguments: "" }, + { command: "digital-pet-spawn", sessionID: "session-1", arguments: "" }, spawnOutput, ) await hooks["command.execute.before"]?.( @@ -110,9 +116,9 @@ describe("vpet spawn command", () => { expect(spawnOutput.parts).toEqual([ expect.objectContaining({ - id: expect.stringMatching(/^vpet-vpet-spawn-session-1-/), + id: expect.stringMatching(/^digital-pet-digital-pet-spawn-session-1-/), sessionID: "session-1", - messageID: expect.stringMatching(/^vpet-vpet-spawn-session-1-/), + messageID: expect.stringMatching(/^digital-pet-digital-pet-spawn-session-1-/), type: "text", text: "Spawned Generation 2.", }), @@ -121,7 +127,7 @@ describe("vpet spawn command", () => { expect(spawnCalls).toBe(1) }) - test("Given a lifecycle that throws When OpenCode dispatches vpet-spawn Then the error propagates and no success part is appended", () => { + test("Given a lifecycle that throws When OpenCode dispatches digital-pet-spawn Then the error propagates and no success part is appended", () => { const failure = new Error("spawn failed") const repository = { spawnPartner: () => { @@ -135,9 +141,11 @@ describe("vpet spawn command", () => { const hooks = createServerHooks({ repository, resource: { close: async () => {} } }) const output = { parts: [] as Part[] } const handler = hooks["command.execute.before"] - if (handler === undefined) throw new Error("vpet-spawn command handler is not registered") + if (handler === undefined) throw new Error("digital-pet-spawn command handler is not registered") - return Promise.resolve(handler({ command: "vpet-spawn", sessionID: "session-1", arguments: "" }, output)).then( + return Promise.resolve( + handler({ command: "digital-pet-spawn", sessionID: "session-1", arguments: "" }, output), + ).then( () => { throw new Error("expected spawn lifecycle failure") }, @@ -158,6 +166,8 @@ describe("vpet spawn command", () => { isTerminal: false, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), freeze: () => ({ kind: "frozen" as const }), unfreeze: () => ({ kind: "unfrozen" as const }), @@ -168,8 +178,14 @@ describe("vpet spawn command", () => { const first = { parts: [] as Part[] } const second = { parts: [] as Part[] } - await hooks["command.execute.before"]?.({ command: "vpet-spawn", sessionID: "session-1", arguments: "" }, first) - await hooks["command.execute.before"]?.({ command: "vpet-spawn", sessionID: "session-1", arguments: "" }, second) + await hooks["command.execute.before"]?.( + { command: "digital-pet-spawn", sessionID: "session-1", arguments: "" }, + first, + ) + await hooks["command.execute.before"]?.( + { command: "digital-pet-spawn", sessionID: "session-1", arguments: "" }, + second, + ) expect(first.parts[0]?.id).not.toBe(second.parts[0]?.id) }) diff --git a/tests/vpet-toast.test.ts b/packages/opencode-digital-pet/tests/digital-pet-toast.test.ts similarity index 62% rename from tests/vpet-toast.test.ts rename to packages/opencode-digital-pet/tests/digital-pet-toast.test.ts index 69d630a..6776feb 100644 --- a/tests/vpet-toast.test.ts +++ b/packages/opencode-digital-pet/tests/digital-pet-toast.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from "bun:test" import { - createBestEffortVpetToastNotifier, - formatVpetToast, - type VpetToastPayload, -} from "../src/adapters/opencode/vpet-toast.ts" -import type { DigimonCatalog } from "../src/data/catalog.ts" + createBestEffortDigitalPetToastNotifier, + formatDigitalPetToast, + type DigitalPetToastPayload, +} from "../src/adapters/opencode/digital-pet-toast.ts" +import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" const nodes = [ { @@ -50,35 +50,56 @@ const unsuccessfulTransports: readonly (readonly [string, () => boolean | Promis ["rejected Promise", () => Promise.reject(new Error("toast transport failed"))], ] -describe("VPet toast presentation", () => { +describe("Digital Pet toast presentation", () => { test.each([ [ "spawn", spawn, - { title: "VPet", message: "Spawned Digitama (Generation 2).", variant: "success", duration: 5_000 }, + { title: "Digital Pet", message: "Spawned Digitama (Generation 2).", variant: "success", duration: 5_000 }, + ], + [ + "freeze", + freeze, + { title: "Digital Pet", message: "Digimon progression frozen.", variant: "info", duration: 3_000 }, ], - ["freeze", freeze, { title: "VPet", message: "Digimon progression frozen.", variant: "info", duration: 3_000 }], [ "unfreeze", unfreeze, - { title: "VPet", message: "Digimon progression resumed.", variant: "info", duration: 3_000 }, + { title: "Digital Pet", message: "Digimon progression resumed.", variant: "info", duration: 3_000 }, + ], + [ + "set", + set, + { title: "Digital Pet", message: "Digital Pet set to Koromon (1-001).", variant: "info", duration: 3_000 }, ], - ["set", set, { title: "VPet", message: "VPet set to Koromon (1-001).", variant: "info", duration: 3_000 }], [ "evolution", evolution, { title: "Digi-evolution", message: "Digitama evolved into Koromon!", variant: "success", duration: 5_000 }, ], + [ + "defeat", + { kind: "defeat" }, + { + title: "Defeat", + message: "Defeat! You lost all tokens for this stage.", + variant: "info", + duration: 5_000, + }, + ], ] as const)( "Given an English catalog and a %s event When formatting Then it returns the approved payload", (_name, event, expected) => { - expect(formatVpetToast(event, "en", catalog)).toEqual(expected) + expect(formatDigitalPetToast(event, "en", catalog)).toEqual(expected) }, ) test.each([ - [spawn, { title: "VPet", message: "Spawned DigiTama (Generation 2).", variant: "success", duration: 5_000 }], - [set, { title: "VPet", message: "VPet set to Koromon JP (1-001).", variant: "info", duration: 3_000 }], + [spawn, { title: "Digital Pet", message: "Spawned DigiTama (Generation 2).", variant: "success", duration: 5_000 }], + [ + set, + { title: "Digital Pet", message: "Digital Pet set to Koromon JP (1-001).", variant: "info", duration: 3_000 }, + ], [ evolution, { title: "Digi-evolution", message: "DigiTama evolved into Koromon JP!", variant: "success", duration: 5_000 }, @@ -86,7 +107,7 @@ describe("VPet toast presentation", () => { ] as const)( "Given a Japanese catalog-name setting When formatting a name-bearing event Then it selects nameJp", (event, expected) => { - expect(formatVpetToast(event, "jp", catalog)).toEqual(expected) + expect(formatDigitalPetToast(event, "jp", catalog)).toEqual(expected) }, ) @@ -97,17 +118,17 @@ describe("VPet toast presentation", () => { ] as const)( "Given a missing catalog ID When formatting a name-bearing event Then it produces no payload", (event) => { - expect(formatVpetToast(event, "en", catalog)).toBeUndefined() + expect(formatDigitalPetToast(event, "en", catalog)).toBeUndefined() }, ) test("Given a true-returning toast transport When the notifier receives a payload Then it calls the transport once", async () => { - const calls: VpetToastPayload[] = [] - const notifier = createBestEffortVpetToastNotifier(async (payload) => { + const calls: DigitalPetToastPayload[] = [] + const notifier = createBestEffortDigitalPetToastNotifier(async (payload) => { calls.push(payload) return true }) - const payload = formatVpetToast(spawn, "en", catalog) + const payload = formatDigitalPetToast(spawn, "en", catalog) if (payload === undefined) throw new Error("Expected spawn toast payload") await notifier(payload) @@ -119,11 +140,11 @@ describe("VPet toast presentation", () => { "Given a transport that returns %s When the notifier receives a payload Then it resolves after one attempted call without retry", async (_outcome, deliver) => { let callCount = 0 - const notifier = createBestEffortVpetToastNotifier(() => { + const notifier = createBestEffortDigitalPetToastNotifier(() => { callCount += 1 return deliver() }) - const payload = formatVpetToast(freeze, "en", catalog) + const payload = formatDigitalPetToast(freeze, "en", catalog) if (payload === undefined) throw new Error("Expected freeze toast payload") await notifier(payload) diff --git a/tests/vpet-unfreeze.test.ts b/packages/opencode-digital-pet/tests/digital-pet-unfreeze.test.ts similarity index 58% rename from tests/vpet-unfreeze.test.ts rename to packages/opencode-digital-pet/tests/digital-pet-unfreeze.test.ts index 3fe5692..ca943ee 100644 --- a/tests/vpet-unfreeze.test.ts +++ b/packages/opencode-digital-pet/tests/digital-pet-unfreeze.test.ts @@ -2,10 +2,10 @@ import { describe, expect, test } from "bun:test" import type { Part } from "@opencode-ai/sdk" import { createServerHooks } from "../src/adapters/opencode/create-server-hooks.ts" -import { runVpetUnfreezeCommand } from "../src/commands/vpet-unfreeze.ts" +import { runDigitalPetUnfreezeCommand } from "../src/commands/digital-pet-unfreeze.ts" -describe("vpet unfreeze command", () => { - test("Given a VPet control When unfreeze runs repeatedly Then it returns the exact success text for both transitions", async () => { +describe("digital-pet unfreeze command", () => { + test("Given a Digital Pet control When unfreeze runs repeatedly Then it returns the exact success text for both transitions", async () => { let unfreezeCalls = 0 const control = { unfreeze: () => { @@ -14,24 +14,36 @@ describe("vpet unfreeze command", () => { }, } - const first = await runVpetUnfreezeCommand(control, { sessionID: "session-1", messageID: "unfreeze-1" }) - const second = await runVpetUnfreezeCommand(control, { sessionID: "session-1", messageID: "unfreeze-2" }) + const first = await runDigitalPetUnfreezeCommand(control, { sessionID: "session-1", messageID: "unfreeze-1" }) + const second = await runDigitalPetUnfreezeCommand(control, { sessionID: "session-1", messageID: "unfreeze-2" }) expect(first).toEqual({ parts: [ - { id: "unfreeze-1", sessionID: "session-1", messageID: "unfreeze-1", type: "text", text: "VPet unfrozen." }, + { + id: "unfreeze-1", + sessionID: "session-1", + messageID: "unfreeze-1", + type: "text", + text: "Digital Pet unfrozen.", + }, ], event: { kind: "unfrozen" }, }) expect(second).toEqual({ parts: [ - { id: "unfreeze-2", sessionID: "session-1", messageID: "unfreeze-2", type: "text", text: "VPet unfrozen." }, + { + id: "unfreeze-2", + sessionID: "session-1", + messageID: "unfreeze-2", + type: "text", + text: "Digital Pet unfrozen.", + }, ], }) expect(unfreezeCalls).toBe(2) }) - test("Given a complete repository When the hook dispatches vpet-unfreeze Then it routes once", async () => { + test("Given a complete repository When the hook dispatches digital-pet-unfreeze Then it routes once", async () => { let unfreezeCalls = 0 const repository = { freeze: () => ({ kind: "frozen" as const }), @@ -48,15 +60,20 @@ describe("vpet unfreeze command", () => { isTerminal: false, createdAt: "2026-08-22T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), applyUsageReceipt: () => ({ kind: "no_active_partner" as const }), } const hooks = createServerHooks({ repository, resource: { close: async () => {} } }) const output = { parts: [] as Part[] } - await hooks["command.execute.before"]?.({ command: "vpet-unfreeze", sessionID: "session-1", arguments: "" }, output) + await hooks["command.execute.before"]?.( + { command: "digital-pet-unfreeze", sessionID: "session-1", arguments: "" }, + output, + ) - expect(output.parts).toEqual([expect.objectContaining({ type: "text", text: "VPet unfrozen." })]) + expect(output.parts).toEqual([expect.objectContaining({ type: "text", text: "Digital Pet unfrozen." })]) expect(unfreezeCalls).toBe(1) }) @@ -64,7 +81,7 @@ describe("vpet unfreeze command", () => { const failure = new Error("unfreeze failed") await expect( - runVpetUnfreezeCommand( + runDigitalPetUnfreezeCommand( { unfreeze: () => { throw failure diff --git a/tests/get-sidebar-card-inputs.test.ts b/packages/opencode-digital-pet/tests/get-sidebar-card-inputs.test.ts similarity index 86% rename from tests/get-sidebar-card-inputs.test.ts rename to packages/opencode-digital-pet/tests/get-sidebar-card-inputs.test.ts index 44b6693..6700bad 100644 --- a/tests/get-sidebar-card-inputs.test.ts +++ b/packages/opencode-digital-pet/tests/get-sidebar-card-inputs.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" -import type { DigimonCatalog, DigimonNode } from "../src/data/catalog.ts" -import type { SidebarSnapshotReader } from "../src/application/ports/sidebar-snapshot.ts" -import { getSidebarCardInputs } from "../src/application/use-cases/get-sidebar-card-inputs.ts" +import type { DigimonCatalog, DigimonNode } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { SidebarSnapshotReader } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import { getSidebarCardInputs } from "@jcendal/digital-pet-core/application/use-cases/get-sidebar-card-inputs.ts" const agumon: DigimonNode = { id: "3-001", @@ -32,6 +32,11 @@ const catalog: DigimonCatalog = { ]), } +const snapshotFields = { + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, +} as const + describe("get sidebar card inputs", () => { test("Given a reader without a snapshot When querying sidebar card inputs Then it returns no partner", () => { const reader: SidebarSnapshotReader = { @@ -50,6 +55,7 @@ describe("get sidebar card inputs", () => { frozen: false, isSetOverride: false, trainerTotalTokens: 40_000, + ...snapshotFields, }), } @@ -61,6 +67,7 @@ describe("get sidebar card inputs", () => { frozen: false, isSetOverride: false, trainerTotalTokens: 40_000, + evolutionBattlePending: false, }) }) @@ -73,6 +80,7 @@ describe("get sidebar card inputs", () => { frozen: false, isSetOverride: false, trainerTotalTokens: 40_000, + ...snapshotFields, }), } @@ -88,6 +96,7 @@ describe("get sidebar card inputs", () => { frozen: false, isSetOverride: true, trainerTotalTokens: 40_000, + ...snapshotFields, }), } @@ -103,6 +112,7 @@ describe("get sidebar card inputs", () => { frozen: false, isSetOverride: true, trainerTotalTokens: 40_000, + ...snapshotFields, }), } @@ -114,6 +124,7 @@ describe("get sidebar card inputs", () => { frozen: false, isSetOverride: true, trainerTotalTokens: 40_000, + evolutionBattlePending: false, }) }) @@ -126,6 +137,7 @@ describe("get sidebar card inputs", () => { frozen: false, isSetOverride: false, trainerTotalTokens: 40_000, + ...snapshotFields, }), } @@ -137,6 +149,7 @@ describe("get sidebar card inputs", () => { frozen: false, isSetOverride: false, trainerTotalTokens: 40_000, + evolutionBattlePending: false, }) }) @@ -149,6 +162,7 @@ describe("get sidebar card inputs", () => { frozen: true, isSetOverride: false, trainerTotalTokens: 40_000, + ...snapshotFields, }), } @@ -160,6 +174,7 @@ describe("get sidebar card inputs", () => { frozen: true, isSetOverride: false, trainerTotalTokens: 40_000, + evolutionBattlePending: false, }) }) }) diff --git a/tests/hexagonal-boundary.test.ts b/packages/opencode-digital-pet/tests/hexagonal-boundary.test.ts similarity index 68% rename from tests/hexagonal-boundary.test.ts rename to packages/opencode-digital-pet/tests/hexagonal-boundary.test.ts index cac04e1..1ec5d2d 100644 --- a/tests/hexagonal-boundary.test.ts +++ b/packages/opencode-digital-pet/tests/hexagonal-boundary.test.ts @@ -10,7 +10,7 @@ import { FORBIDDEN_SERVER_HOOK_IMPORTS, FORBIDDEN_TUI_ANIMATION_IMPORTS, PROJECT_ROOT, - TUI_PRIVATE_ANIMATION_MODULES, + DIGITAL_PET_ANIMATION_IDLE_MODULES, findForbiddenImports, scanForbiddenImports, scanTuiPresentationImports, @@ -18,19 +18,19 @@ import { describe("hexagonal dependency direction", () => { test("Given domain fixtures importing data, config, application, and frameworks When scanned Then each exact violation is reported", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-vpet-domain-architecture-")) + const directory = await mkdtemp(join(tmpdir(), "opencode-digital-pet-domain-architecture-")) const fixtures = [ - ["data.ts", 'import type { DigimonCatalog } from "../data/catalog.ts"\n'], - ["config.ts", 'import type { ResolvedVpetSettings } from "../config/types.ts"\n'], - ["application.ts", 'import type { Partner } from "../application/models/partner.ts"\n'], + ["data.ts", 'import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts"\n'], + ["config.ts", 'import type { ResolvedDigitalPetSettings } from "@jcendal/digital-pet-core/config/types.ts"\n'], + ["application.ts", 'import type { Partner } from "@jcendal/digital-pet-core/application/models/partner.ts"\n'], ["framework.ts", 'import type { Plugin } from "@opencode-ai/plugin"\n'], ] as const try { await Promise.all(fixtures.map(async ([name, source]) => writeFile(join(directory, name), source))) expect(await scanForbiddenImports(directory, FORBIDDEN_DOMAIN_IMPORTS)).toEqual([ - `${join(directory, "application.ts")}: ../application/models/partner.ts`, - `${join(directory, "config.ts")}: ../config/types.ts`, - `${join(directory, "data.ts")}: ../data/catalog.ts`, + `${join(directory, "application.ts")}: @jcendal/digital-pet-core/application/models/partner.ts`, + `${join(directory, "config.ts")}: @jcendal/digital-pet-core/config/types.ts`, + `${join(directory, "data.ts")}: @jcendal/digital-pet-core/data/catalog.ts`, `${join(directory, "framework.ts")}: @opencode-ai/plugin`, ]) } finally { @@ -39,11 +39,17 @@ describe("hexagonal dependency direction", () => { expect(await Bun.file(directory).exists()).toBeFalse() }) test("Given the domain source tree When scanned Then it has no forbidden dependencies", async () => - expect(await scanForbiddenImports(join(PROJECT_ROOT, "src", "domain"), FORBIDDEN_DOMAIN_IMPORTS)).toEqual([])) + expect( + await scanForbiddenImports( + join(PROJECT_ROOT, "..", "digital-pet-core", "src", "domain"), + FORBIDDEN_DOMAIN_IMPORTS, + ), + ).toEqual([])) test("Given the inner TUI source tree When scanned Then it has no persistence or SQLite dependencies", async () => expect(await scanTuiPresentationImports(join(PROJECT_ROOT, "src", "tui"))).toEqual([])) - test("Given animation policy and mirror modules When scanned Then they remain TUI-private without domain, application, or SQLite dependencies", async () => { - const paths = TUI_PRIVATE_ANIMATION_MODULES.map((module) => join(PROJECT_ROOT, "src", "tui", module)) + test("Given digital-pet-animation idle modules When scanned Then they remain presentation-only without application or SQLite dependencies", async () => { + const animationRoot = join(PROJECT_ROOT, "..", "digital-pet-animation", "src") + const paths = DIGITAL_PET_ANIMATION_IDLE_MODULES.map((module) => join(animationRoot, module)) expect(await Promise.all(paths.map((path) => Bun.file(path).exists()))).toEqual([true, true, true, true]) expect( await Promise.all( @@ -54,15 +60,15 @@ describe("hexagonal dependency direction", () => { ).toEqual([[], [], [], []]) }) test("Given inner TUI fixtures importing raw config and SQLite When scanned Then both violations are reported", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-vpet-tui-architecture-")) + const directory = await mkdtemp(join(tmpdir(), "opencode-digital-pet-tui-architecture-")) const fixtures = [ - ["config.ts", 'import { loadGlobalVpetSettings } from "../config/global-vpet-settings.ts"\n'], + ["config.ts", 'import { loadGlobalDigitalPetSettings } from "../config/global-digital-pet-settings.ts"\n'], ["sqlite.ts", 'import { Database } from "bun:sqlite"\n'], ] as const try { await Promise.all(fixtures.map(async ([name, source]) => writeFile(join(directory, name), source))) expect(await scanTuiPresentationImports(directory)).toEqual([ - `${join(directory, "config.ts")}: ../config/global-vpet-settings.ts`, + `${join(directory, "config.ts")}: ../config/global-digital-pet-settings.ts`, `${join(directory, "sqlite.ts")}: bun:sqlite`, ]) } finally { @@ -84,7 +90,9 @@ describe("hexagonal dependency direction", () => { test("Given the server hook composition When scanned Then it has no SQLite or application lifecycle port dependency", async () => { const hook = join(PROJECT_ROOT, "src", "adapters", "opencode", "create-server-hooks.ts") expect( - await Bun.file(join(PROJECT_ROOT, "src", "application", "ports", "resource-lifecycle.ts")).exists(), + await Bun.file( + join(PROJECT_ROOT, "..", "digital-pet-core", "src", "application", "ports", "resource-lifecycle.ts"), + ).exists(), ).toBeFalse() expect(findForbiddenImports(hook, await readFile(hook, "utf8"), FORBIDDEN_SERVER_HOOK_IMPORTS)).toEqual([]) }) diff --git a/tests/history-view-model.test.ts b/packages/opencode-digital-pet/tests/history-view-model.test.ts similarity index 86% rename from tests/history-view-model.test.ts rename to packages/opencode-digital-pet/tests/history-view-model.test.ts index c6cb208..657d18f 100644 --- a/tests/history-view-model.test.ts +++ b/packages/opencode-digital-pet/tests/history-view-model.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" -import { DEFAULT_VPET_SETTINGS } from "../src/config/defaults.ts" -import type { DigimonNode } from "../src/domain/digimon-node.ts" -import { buildHistoryViewModel } from "../src/tui/history-view-model.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import type { DigimonNode } from "@jcendal/digital-pet-core/domain/digimon-node.ts" +import { buildHistoryViewModel } from "@jcendal/digital-pet-core/view-models/history-view-model.ts" const eggNode: DigimonNode = { id: "0-001", @@ -59,7 +59,7 @@ describe("history view model", () => { ], }, catalog, - DEFAULT_VPET_SETTINGS, + DEFAULT_DIGITAL_PET_SETTINGS, ) expect(model).toEqual({ @@ -93,7 +93,7 @@ describe("history view model", () => { ], }, catalog, - DEFAULT_VPET_SETTINGS, + DEFAULT_DIGITAL_PET_SETTINGS, ) expect(model).toEqual({ @@ -111,9 +111,13 @@ describe("history view model", () => { }) test("Given an empty or unavailable archive When building history Then it preserves the explicit result state", () => { - expect(buildHistoryViewModel({ kind: "empty" }, catalog, DEFAULT_VPET_SETTINGS)).toEqual({ kind: "empty" }) + expect(buildHistoryViewModel({ kind: "empty" }, catalog, DEFAULT_DIGITAL_PET_SETTINGS)).toEqual({ kind: "empty" }) expect( - buildHistoryViewModel({ kind: "unavailable", message: "Archive unavailable" }, catalog, DEFAULT_VPET_SETTINGS), + buildHistoryViewModel( + { kind: "unavailable", message: "Archive unavailable" }, + catalog, + DEFAULT_DIGITAL_PET_SETTINGS, + ), ).toEqual({ kind: "unavailable", message: "Archive unavailable" }) }) }) diff --git a/tests/monster-frame-catalog.test.ts b/packages/opencode-digital-pet/tests/monster-frame-catalog.test.ts similarity index 97% rename from tests/monster-frame-catalog.test.ts rename to packages/opencode-digital-pet/tests/monster-frame-catalog.test.ts index 3236e47..7d3780f 100644 --- a/tests/monster-frame-catalog.test.ts +++ b/packages/opencode-digital-pet/tests/monster-frame-catalog.test.ts @@ -6,7 +6,7 @@ import { MonsterFrameCatalog, STANDARD_ANIMATION_SEQUENCE, type MonsterFrameName, -} from "../src/data/monster-frame-catalog.ts" +} from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" type Equal = (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true : false diff --git a/tests/monster-frame-data.test.ts b/packages/opencode-digital-pet/tests/monster-frame-data.test.ts similarity index 94% rename from tests/monster-frame-data.test.ts rename to packages/opencode-digital-pet/tests/monster-frame-data.test.ts index 526e534..4dfa973 100644 --- a/tests/monster-frame-data.test.ts +++ b/packages/opencode-digital-pet/tests/monster-frame-data.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" -import { MONSTER_FRAME_CATALOG } from "../src/data/monster-frame-catalog.ts" -import { MONSTER_FRAME_DATA } from "../src/data/monster-frame-data.ts" +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" +import { MONSTER_FRAME_DATA } from "@jcendal/digital-pet-core/data/monster-frame-data.ts" const AGUMON_WALK_1 = " ▄▄▄▄▄ \n ▄█ ▄▄ ▀▄ \n ▄▀▀ ▄██ ▀▄ \n ▀█▀▀ █ \n ▀██▀▀ ▄ █ \n █▄█ █▄▄ █ \n ▄▄▀▀▄▄▄▄▀ █ \n █▄█▄▄█ █▄█▄█▄█" diff --git a/tests/package-fixture.ts b/packages/opencode-digital-pet/tests/package-fixture.ts similarity index 80% rename from tests/package-fixture.ts rename to packages/opencode-digital-pet/tests/package-fixture.ts index 8209e7b..b508f7e 100644 --- a/tests/package-fixture.ts +++ b/packages/opencode-digital-pet/tests/package-fixture.ts @@ -3,9 +3,12 @@ import { tmpdir } from "node:os" import { basename, join } from "node:path" import { fileURLToPath } from "node:url" +import { stripInternalDependencies } from "../scripts/strip-internal-deps.ts" + const PROJECT_ROOT = fileURLToPath(new URL("../", import.meta.url)) +const MONOREPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url)) const REQUIRED_ARTIFACTS = ["index.js", "index.d.ts", "tui.js", "tui.d.ts", "cli.js"] as const -const EXCLUDED_COPY_DIRECTORIES = new Set([".git", ".omo", "dist", "node_modules"]) +const EXCLUDED_COPY_DIRECTORIES = new Set([".git", ".omo", "node_modules", "build", "dist"]) export type PackageFixture = { readonly root: string @@ -32,18 +35,28 @@ const runCommand = (command: Command): string => { } const copyPackage = async (root: string): Promise => { - const packageDirectory = join(root, "package") + const packagesDirectory = join(root, "packages") + const packageDirectory = join(packagesDirectory, "opencode-digital-pet") await cp(PROJECT_ROOT, packageDirectory, { recursive: true, filter: (source) => !EXCLUDED_COPY_DIRECTORIES.has(basename(source)), }) - await symlink(join(PROJECT_ROOT, "node_modules"), join(packageDirectory, "node_modules"), "dir") + await cp(join(PROJECT_ROOT, "../digital-pet-core"), join(packagesDirectory, "digital-pet-core"), { + recursive: true, + filter: (source) => !EXCLUDED_COPY_DIRECTORIES.has(basename(source)), + }) + await cp(join(PROJECT_ROOT, "../digital-pet-animation"), join(packagesDirectory, "digital-pet-animation"), { + recursive: true, + filter: (source) => !EXCLUDED_COPY_DIRECTORIES.has(basename(source)), + }) + await symlink(join(MONOREPO_ROOT, "node_modules"), join(packageDirectory, "node_modules"), "dir") return packageDirectory } const packPackage = async ( packageDirectory: string, ): Promise<{ readonly archivePath: string; readonly archiveMembers: readonly string[] }> => { + await stripInternalDependencies(packageDirectory) const archiveDirectory = join(packageDirectory, "archive") await mkdir(archiveDirectory) runCommand({ args: ["bun", "pm", "pack", "--destination", archiveDirectory], cwd: packageDirectory }) @@ -68,7 +81,7 @@ const linkConsumerDependencies = async (consumerDirectory: string): Promise => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-package-")) + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-package-")) try { const packageDirectory = await copyPackage(root) const packed = await packPackage(packageDirectory) @@ -114,8 +127,8 @@ export const verifyPackedConsumer = async (fixture: PackageFixture): Promise { })({ renderer: { requestRender: () => undefined }, lifecycle: { onDispose: (callback) => { dispose = callback; return () => undefined } }, - slots: { register: (registered) => { slot = registered.slots.sidebar_content; return "opencode-vpet" } }, + slots: { register: (registered) => { slot = registered.slots.sidebar_content; return "opencode-digital-pet" } }, }) try { tick() @@ -154,8 +167,8 @@ const render = async (inputs) => { } } -if (entryPlugin.id !== "opencode-vpet" || typeof entryPlugin.server !== "function") throw new Error("Core export failed") -if (tuiPlugin.id !== "opencode-vpet" || typeof tuiPlugin.tui !== "function") throw new Error("TUI export failed") +if (entryPlugin.id !== "opencode-digital-pet" || typeof entryPlugin.server !== "function") throw new Error("Core export failed") +if (tuiPlugin.id !== "opencode-digital-pet" || typeof tuiPlugin.tui !== "function") throw new Error("TUI export failed") const [known, unavailable, empty] = await Promise.all([render(partner("agumon")), render(partner("unknown-sprite")), render(partner(""))]) if (!known.includes("▄▀▀▀▀▀▄")) throw new Error("Known artwork fallback failed") if (!unavailable.includes("Artwork unavailable: unknown-sprite")) throw new Error("Unknown artwork fallback failed") diff --git a/packages/opencode-digital-pet/tests/partner-fixtures.ts b/packages/opencode-digital-pet/tests/partner-fixtures.ts new file mode 100644 index 0000000..7e5e050 --- /dev/null +++ b/packages/opencode-digital-pet/tests/partner-fixtures.ts @@ -0,0 +1,30 @@ +import type { Partner, PartnerProgression } from "@jcendal/digital-pet-core/domain/partner.ts" + +export const idlePartnerBattleFields = { + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, +} as const + +export const testPartner = (overrides: Partial = {}): Partner => ({ + partnerId: "partner-1", + generation: 1, + currentNodeId: "0-001", + gauge: 0, + isTerminal: false, + createdAt: "2026-07-31T00:00:00.000Z", + retiredAt: null, + ...idlePartnerBattleFields, + ...overrides, +}) + +export const partnerProgression = ( + partner: Partner, + overrides: Partial = {}, +): PartnerProgression => ({ + currentNodeId: partner.currentNodeId, + gauge: partner.gauge, + isTerminal: partner.isTerminal, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, + ...overrides, +}) diff --git a/tests/persistence/sqlite-vpet-archive-history.persistence.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-archive-history.persistence.ts similarity index 79% rename from tests/persistence/sqlite-vpet-archive-history.persistence.ts rename to packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-archive-history.persistence.ts index 67dbf8e..6c08c22 100644 --- a/tests/persistence/sqlite-vpet-archive-history.persistence.ts +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-archive-history.persistence.ts @@ -1,16 +1,16 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import { Database } from "bun:sqlite" -import { createSqliteVpetArchiveReader } from "../../src/adapters/sqlite/sqlite-vpet-archive-reader.ts" +import { createSqliteDigitalPetArchiveReader } from "../../src/adapters/sqlite/sqlite-digital-pet-archive-reader.ts" import { openWritableDatabase } from "../../src/adapters/sqlite/bun-sqlite-driver.ts" -import { createSqliteVpetRepository } from "../../src/adapters/sqlite/sqlite-vpet-write-store.ts" -import { runVpetSetCommand } from "../../src/commands/vpet-set.ts" -import { DEFAULT_VPET_SETTINGS } from "../../src/config/defaults.ts" -import type { ResolvedVpetSettings } from "../../src/config/types.ts" -import type { DigimonCatalog } from "../../src/data/catalog.ts" -import type { DigimonNode } from "../../src/domain/digimon-node.ts" -import { buildDexViewModel } from "../../src/tui/dex-view-model.ts" -import { buildHistoryViewModel } from "../../src/tui/history-view-model.ts" +import { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" +import { runDigitalPetSetCommand } from "../../src/commands/digital-pet-set.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import type { ResolvedDigitalPetSettings } from "@jcendal/digital-pet-core/config/types.ts" +import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { DigimonNode } from "@jcendal/digital-pet-core/domain/digimon-node.ts" +import { buildDexViewModel } from "@jcendal/digital-pet-core/view-models/dex-view-model.ts" +import { buildHistoryViewModel } from "@jcendal/digital-pet-core/view-models/history-view-model.ts" import { isBunSqliteAvailable } from "../sqlite-capability.ts" import { applyReceipt, @@ -20,7 +20,7 @@ import { spawn, type TempTestRoot, usageReceipt, -} from "./sqlite-vpet-repository.fixtures.ts" +} from "./sqlite-digital-pet-repository.fixtures.ts" const catalogNodes = [ { @@ -67,9 +67,9 @@ const catalog: DigimonCatalog = { } const englishSettings = { - ...DEFAULT_VPET_SETTINGS, + ...DEFAULT_DIGITAL_PET_SETTINGS, language: "en", -} as const satisfies ResolvedVpetSettings +} as const satisfies ResolvedDigitalPetSettings test("Given a set-only catalog ID without canonical events When projecting the Dex Then its English and Japanese names remain redacted", () => { const archive = { @@ -85,7 +85,7 @@ test("Given a set-only catalog ID without canonical events When projecting the D ], } as const const englishDex = buildDexViewModel(archive, catalog, englishSettings) - const japaneseDex = buildDexViewModel(archive, catalog, DEFAULT_VPET_SETTINGS) + const japaneseDex = buildDexViewModel(archive, catalog, DEFAULT_DIGITAL_PET_SETTINGS) if (englishDex.kind !== "available" || japaneseDex.kind !== "available") throw new Error("Expected available Dex models") @@ -95,7 +95,7 @@ test("Given a set-only catalog ID without canonical events When projecting the D expect(japaneseDex.rows.map((row) => row.name)).not.toContain("Set-only Japanese") }) -describe.if(isBunSqliteAvailable)("sqlite vpet archive history persistence", () => { +describe.if(isBunSqliteAvailable)("sqlite digital-pet archive history persistence", () => { let tempRoot: TempTestRoot beforeEach(async () => { @@ -106,7 +106,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet archive history persistence", () }) test("Given canonical generations and a set-only control When the archive reopens Then discovery and history expose only canonical event state", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) let repositoryClosed = false try { @@ -115,7 +115,13 @@ describe.if(isBunSqliteAvailable)("sqlite vpet archive history persistence", () expect( repository.applyUsageReceipt( usageReceipt("receipt-same-snapshot", "event-c-same-snapshot", 100), - (partner) => ({ currentNodeId: "1-001", gauge: partner.gauge + 100, isTerminal: false }), + (partner) => ({ + currentNodeId: "1-001", + gauge: partner.gauge + 100, + isTerminal: false, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, + }), ), ).toEqual({ kind: "applied" }) expect( @@ -123,11 +129,13 @@ describe.if(isBunSqliteAvailable)("sqlite vpet archive history persistence", () currentNodeId: "2-001", gauge: partner.gauge + 100, isTerminal: false, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, })), ).toEqual({ kind: "applied" }) const secondPartner = spawn(repository, "2026-07-30T12:10:00.000Z") - const setResult = await runVpetSetCommand( + const setResult = await runDigitalPetSetCommand( repository, { sessionID: "session-history", messageID: "set-history", arguments: "7-001" }, async () => catalog, @@ -158,7 +166,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet archive history persistence", () const closeSpy = spyOn(Database.prototype, "close") try { - const archive = createSqliteVpetArchiveReader({ databasePath: repository.databasePath }).getArchive() + const archive = createSqliteDigitalPetArchiveReader({ databasePath: repository.databasePath }).getArchive() expect(closeSpy).toHaveBeenCalledTimes(1) if (archive.kind !== "available") throw new Error("Expected populated archive after canonical writes") @@ -172,7 +180,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet archive history persistence", () ) const englishDex = buildDexViewModel(archive, catalog, englishSettings) - const japaneseDex = buildDexViewModel(archive, catalog, DEFAULT_VPET_SETTINGS) + const japaneseDex = buildDexViewModel(archive, catalog, DEFAULT_DIGITAL_PET_SETTINGS) if (englishDex.kind !== "available" || japaneseDex.kind !== "available") throw new Error("Expected available Dex models") expect(englishDex.rows.find((row) => row.id === "7-001")).toEqual({ @@ -190,7 +198,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet archive history persistence", () expect(englishDex.rows.map((row) => row.name)).not.toContain("Set-only English") expect(japaneseDex.rows.map((row) => row.name)).not.toContain("Set-only Japanese") - expect(buildHistoryViewModel(archive, catalog, DEFAULT_VPET_SETTINGS)).toEqual({ + expect(buildHistoryViewModel(archive, catalog, DEFAULT_DIGITAL_PET_SETTINGS)).toEqual({ kind: "available", generations: [ { diff --git a/tests/persistence/sqlite-vpet-control.persistence.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-control.persistence.ts similarity index 86% rename from tests/persistence/sqlite-vpet-control.persistence.ts rename to packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-control.persistence.ts index 5a788a9..3932a4a 100644 --- a/tests/persistence/sqlite-vpet-control.persistence.ts +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-control.persistence.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises" import { join } from "node:path" import { openReadonlyDatabase, openWritableDatabase } from "../../src/adapters/sqlite/bun-sqlite-driver.ts" -import { createSqliteVpetRepository } from "../../src/adapters/sqlite/sqlite-vpet-write-store.ts" +import { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" import { isBunSqliteAvailable } from "../sqlite-capability.ts" type TempTestRoot = { @@ -24,7 +24,7 @@ type SpawnControlCase = { const SPAWNED_AT = "2026-07-30T12:00:00.000Z" -const spawn = (repository: Awaited>, createdAt = SPAWNED_AT) => +const spawn = (repository: Awaited>, createdAt = SPAWNED_AT) => repository.spawnPartner({ currentNodeId: "0-001", gauge: 0, @@ -39,7 +39,7 @@ const getControlState = (databasePath: string): ControlState => { const controlState = database .query("SELECT frozen, cheat_node_id FROM vpet_control_state WHERE control_id = 1") .get() - if (controlState === null) throw new Error("Expected singleton VPet control state") + if (controlState === null) throw new Error("Expected singleton Digital Pet control state") return controlState } finally { database.close() @@ -53,11 +53,11 @@ const spawnControlCases: SpawnControlCase[] = [ { name: "frozen and cheat", freeze: true, cheatNodeId: "6-001" }, ] -describe.if(isBunSqliteAvailable)("sqlite vpet control persistence", () => { +describe.if(isBunSqliteAvailable)("sqlite digital-pet control persistence", () => { let tempRoot: TempTestRoot beforeEach(async () => { - const root = await mkdtemp(join(process.cwd(), ".tmp-vpet-control-")) + const root = await mkdtemp(join(process.cwd(), ".tmp-digital-pet-control-")) const appDataRoot = join(root, "app-data") await mkdir(appDataRoot, { recursive: true }) tempRoot = { root, appDataRoot } @@ -68,7 +68,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet control persistence", () => { }) test("Given SQLite control state When freeze and unfreeze repeat Then each single-row transition is idempotent and unfreeze preserves cheat", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { expect(repository.freeze()).toEqual({ kind: "frozen" }) @@ -83,7 +83,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet control persistence", () => { }) test("Given no active partner When cheat is set to the same and different IDs Then only the cheat control changes with typed idempotent outcomes", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { expect(repository.setCheatNode("2-001")).toEqual({ kind: "set", cheatNodeId: "2-001" }) @@ -98,7 +98,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet control persistence", () => { test("Given coexisting frozen and cheat controls When the repository reopens Then both values persist and set preserves freeze", async () => { const databasePath = join(tempRoot.root, "control-reopen.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) try { expect(repository.freeze()).toEqual({ kind: "frozen" }) @@ -107,7 +107,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet control persistence", () => { await repository.close() } - const reopenedRepository = await createSqliteVpetRepository({ databasePath }) + const reopenedRepository = await createSqliteDigitalPetRepository({ databasePath }) try { expect(getControlState(databasePath)).toEqual({ frozen: 1, cheat_node_id: "3-001" }) @@ -121,7 +121,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet control persistence", () => { test.each(spawnControlCases)( "Given $name controls When a replacement spawns Then it resets controls and creates the next egg atomically", async ({ freeze, cheatNodeId }) => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { const firstPartner = spawn(repository) @@ -143,7 +143,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet control persistence", () => { ) test("Given frozen and cheat controls When the egg insert fails after spawn reset Then the immediate transaction restores controls and the active partner", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { const firstPartner = spawn(repository) diff --git a/tests/persistence/sqlite-vpet-repository.control-ledger.persistence.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.control-ledger.persistence.ts similarity index 84% rename from tests/persistence/sqlite-vpet-repository.control-ledger.persistence.ts rename to packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.control-ledger.persistence.ts index 1a5423f..126478c 100644 --- a/tests/persistence/sqlite-vpet-repository.control-ledger.persistence.ts +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.control-ledger.persistence.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { join } from "node:path" import { openWritableDatabase } from "../../src/adapters/sqlite/bun-sqlite-driver.ts" -import { createSqliteVpetRepository } from "../../src/adapters/sqlite/sqlite-vpet-write-store.ts" +import { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" import { isBunSqliteAvailable } from "../sqlite-capability.ts" import { applyReceipt, @@ -13,9 +13,9 @@ import { spawn, type TempTestRoot, usageReceipt, -} from "./sqlite-vpet-repository.fixtures.ts" +} from "./sqlite-digital-pet-repository.fixtures.ts" -describe.if(isBunSqliteAvailable)("sqlite vpet repository control ledger", () => { +describe.if(isBunSqliteAvailable)("sqlite digital-pet repository control ledger", () => { let tempRoot: TempTestRoot beforeEach(async () => { tempRoot = await createTempTestRoot() @@ -25,7 +25,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository control ledger", () => }) test("Given a cheat and frozen control state When a receipt arrives Then cheat takes precedence and only its control receipt is written", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { spawn(repository) const database = openWritableDatabase(repository.databasePath) @@ -48,14 +48,14 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository control ledger", () => test("Given a control-ledger receipt When a repository reopens and replays it Then both ledgers deduplicate it without changing trainer totals", async () => { const databasePath = join(tempRoot.root, "control-reopen.db") - const firstRepository = await createSqliteVpetRepository({ databasePath }) + const firstRepository = await createSqliteDigitalPetRepository({ databasePath }) try { setReceiptMode(databasePath, "frozen") expect(applyReceipt(firstRepository, "receipt-reopen", "usage-reopen", 100)).toEqual({ kind: "applied" }) } finally { await firstRepository.close() } - const reopenedRepository = await createSqliteVpetRepository({ databasePath }) + const reopenedRepository = await createSqliteDigitalPetRepository({ databasePath }) try { expect(applyReceipt(reopenedRepository, "receipt-reopen", "usage-reopen-replay", 999)).toEqual({ kind: "duplicate", @@ -69,8 +69,8 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository control ledger", () => }) test("Given two repositories sharing a control-ledger receipt When both replay it Then exactly one control transaction changes trainer totals", async () => { - const firstRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const secondRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const firstRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const secondRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { setReceiptMode(firstRepository.databasePath, "frozen") expect(applyReceipt(firstRepository, "receipt-control-replay", "usage-control-replay", 100)).toEqual({ @@ -90,7 +90,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository control ledger", () => }) test("Given a receipt first written to the canonical ledger When control changes before replay Then the control ledger cannot apply it again", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { spawn(repository) expect(applyReceipt(repository, "receipt-cross-ledger", "usage-canonical", 100)).toEqual({ kind: "applied" }) @@ -107,7 +107,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository control ledger", () => }) test("Given frozen mode When the receipt insert fails Then the immediate transaction rolls back the trainer total and control receipt", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { setReceiptMode(repository.databasePath, "frozen") const database = openWritableDatabase(repository.databasePath) diff --git a/tests/persistence/sqlite-vpet-repository.fixtures.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.fixtures.ts similarity index 82% rename from tests/persistence/sqlite-vpet-repository.fixtures.ts rename to packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.fixtures.ts index 5a89ed1..910b78c 100644 --- a/tests/persistence/sqlite-vpet-repository.fixtures.ts +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.fixtures.ts @@ -2,17 +2,17 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises" import { join } from "node:path" import { openReadonlyDatabase, openWritableDatabase } from "../../src/adapters/sqlite/bun-sqlite-driver.ts" -import type { UsageReceiptMetadata } from "../../src/application/models/usage.ts" -import type { createSqliteVpetRepository } from "../../src/adapters/sqlite/sqlite-vpet-write-store.ts" +import type { UsageReceiptMetadata } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" export type TempTestRoot = { readonly root: string; readonly appDataRoot: string } -export type Repository = Awaited> +export type Repository = Awaited> export type ReceiptMode = "normal" | "frozen" | "cheat" const SPAWNED_AT = "2026-07-30T12:00:00.000Z" export const createTempTestRoot = async (): Promise => { - const root = await mkdtemp(join(process.cwd(), ".tmp-vpet-persistence-")) + const root = await mkdtemp(join(process.cwd(), ".tmp-digital-pet-persistence-")) const appDataRoot = join(root, "app-data") await mkdir(appDataRoot, { recursive: true }) return { root, appDataRoot } @@ -43,6 +43,8 @@ export const applyReceipt = (repository: Repository, receiptKey: string, eventId currentNodeId: partner.currentNodeId, gauge: partner.gauge + tokenDelta, isTerminal: partner.isTerminal, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, })) export const setReceiptMode = (databasePath: string, mode: ReceiptMode): void => { diff --git a/tests/persistence/sqlite-vpet-repository.lifecycle.persistence.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.lifecycle.persistence.ts similarity index 76% rename from tests/persistence/sqlite-vpet-repository.lifecycle.persistence.ts rename to packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.lifecycle.persistence.ts index 4dbee42..1d7bd7c 100644 --- a/tests/persistence/sqlite-vpet-repository.lifecycle.persistence.ts +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.lifecycle.persistence.ts @@ -1,13 +1,14 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { mkdir } from "node:fs/promises" import { join } from "node:path" import { Database } from "bun:sqlite" import { openReadonlyDatabase, openWritableDatabase } from "../../src/adapters/sqlite/bun-sqlite-driver.ts" -import type { Partner } from "../../src/domain/partner.ts" +import type { Partner } from "@jcendal/digital-pet-core/domain/partner.ts" import { - createSqliteVpetRepository, + createSqliteDigitalPetRepository, resolveHostDatabasePath, -} from "../../src/adapters/sqlite/sqlite-vpet-write-store.ts" +} from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" import { isBunSqliteAvailable } from "../sqlite-capability.ts" import { applyReceipt, @@ -17,9 +18,9 @@ import { spawnInput, type TempTestRoot, usageReceipt, -} from "./sqlite-vpet-repository.fixtures.ts" +} from "./sqlite-digital-pet-repository.fixtures.ts" -describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { +describe.if(isBunSqliteAvailable)("sqlite digital-pet repository lifecycle", () => { let tempRoot: TempTestRoot beforeEach(async () => { tempRoot = await createTempTestRoot() @@ -49,19 +50,34 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { }) test("Given a temp app-data root When the repository initializes Then it creates the migrated host-wide database path", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { - expect(repository.databasePath).toBe(join(tempRoot.appDataRoot, "opencode-vpet", "pet.db")) + expect(repository.databasePath).toBe(join(tempRoot.appDataRoot, "opencode-digital-pet", "pet.db")) expect(resolveHostDatabasePath({ appDataRoot: tempRoot.appDataRoot })).toBe(repository.databasePath) expect(await Bun.file(repository.databasePath).exists()).toBe(true) - expect(repository.getAppliedMigrations()).toEqual([1, 2, 3]) + expect(repository.getAppliedMigrations()).toEqual([1, 2, 3, 4]) + } finally { + await repository.close() + } + }) + + test("an existing legacy database is opened without creating a second pet", async () => { + const legacyDirectory = join(tempRoot.appDataRoot, "opencode-vpet") + await mkdir(legacyDirectory, { recursive: true }) + const legacyPath = join(legacyDirectory, "pet.db") + const legacyDatabase = new Database(legacyPath) + legacyDatabase.close() + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + try { + expect(repository.databasePath).toBe(legacyPath) + expect(repository.getAppliedMigrations()).toEqual([1, 2, 3, 4]) } finally { await repository.close() } }) test("Given application spawn and receipt contracts When SQLite persists them Then reads and transaction callbacks use canonical domain Partner state", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { const spawned: Partner = repository.spawnPartner(spawnInput()) const callbackPartners: Partner[] = [] @@ -69,7 +85,13 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { usageReceipt("receipt-domain-contract", "usage-domain-contract", 100), (partner) => { callbackPartners.push(partner) - return { currentNodeId: partner.currentNodeId, gauge: partner.gauge + 100, isTerminal: partner.isTerminal } + return { + currentNodeId: partner.currentNodeId, + gauge: partner.gauge + 100, + isTerminal: partner.isTerminal, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, + } }, ) expect(outcome).toEqual({ kind: "applied" }) @@ -82,7 +104,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { test("Given a populated SQLite database When the repository reopens it Then migrations preserve its partner, receipt, event, and trainer state", async () => { const databasePath = join(tempRoot.root, "existing.db") - const firstRepository = await createSqliteVpetRepository({ databasePath }) + const firstRepository = await createSqliteDigitalPetRepository({ databasePath }) const partner = spawn(firstRepository) expect(applyReceipt(firstRepository, "receipt-existing", "usage-existing", 100)).toEqual({ kind: "applied" }) const expectedPartner = firstRepository.getActivePartner() @@ -90,9 +112,9 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { const expectedReceipts = firstRepository.listUsageReceipts() const expectedTrainer = firstRepository.getTrainerState() await firstRepository.close() - const reopenedRepository = await createSqliteVpetRepository({ databasePath }) + const reopenedRepository = await createSqliteDigitalPetRepository({ databasePath }) try { - expect(reopenedRepository.getAppliedMigrations()).toEqual([1, 2, 3]) + expect(reopenedRepository.getAppliedMigrations()).toEqual([1, 2, 3, 4]) expect(reopenedRepository.getActivePartner()).toEqual(expectedPartner) expect(reopenedRepository.listPartnerEvents(partner.partnerId)).toEqual(expectedEvents) expect(reopenedRepository.listUsageReceipts()).toEqual(expectedReceipts) @@ -103,8 +125,8 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { }) test("Given two repositories sharing a database When distinct usage receipts arrive Then both receipts and their token totals survive", async () => { - const firstRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const secondRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const firstRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const secondRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { spawn(firstRepository) expect(applyReceipt(firstRepository, "receipt-1", "usage-1", 100)).toEqual({ kind: "applied" }) @@ -122,8 +144,8 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { }) test("Given two repositories sharing a database When they apply the same receipt Then exactly one transaction changes the state", async () => { - const firstRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const secondRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const firstRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const secondRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { spawn(firstRepository) expect(applyReceipt(firstRepository, "receipt-1", "usage-1", 100)).toEqual({ kind: "applied" }) @@ -138,7 +160,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { }) test("Given an active generation When a replacement is spawned Then retirement, events, and the next unique generation commit atomically", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { const firstPartner = spawn(repository) const secondPartner = spawn(repository, "2026-07-30T12:10:00.000Z") @@ -160,8 +182,8 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { }) test("Given two repositories sharing one active partner When both spawn replacements Then generations remain unique and exactly one partner is active", async () => { - const firstRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const secondRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const firstRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const secondRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { spawn(firstRepository) const replacements = await Promise.all([ diff --git a/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.persistence.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.persistence.ts new file mode 100644 index 0000000..a54e2f7 --- /dev/null +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.persistence.ts @@ -0,0 +1,4 @@ +import "./sqlite-digital-pet-repository.lifecycle.persistence.ts" +import "./sqlite-digital-pet-repository.receipts.persistence.ts" +import "./sqlite-digital-pet-repository.control-ledger.persistence.ts" +import "./sqlite-digital-pet-archive-history.persistence.ts" diff --git a/tests/persistence/sqlite-vpet-repository.receipts.persistence.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.receipts.persistence.ts similarity index 89% rename from tests/persistence/sqlite-vpet-repository.receipts.persistence.ts rename to packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.receipts.persistence.ts index b4021c8..38093be 100644 --- a/tests/persistence/sqlite-vpet-repository.receipts.persistence.ts +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.receipts.persistence.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { createSqliteVpetRepository } from "../../src/adapters/sqlite/sqlite-vpet-write-store.ts" +import { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" import { isBunSqliteAvailable } from "../sqlite-capability.ts" import { applyReceipt, @@ -12,7 +12,7 @@ import { type ReceiptMode, type TempTestRoot, usageReceipt, -} from "./sqlite-vpet-repository.fixtures.ts" +} from "./sqlite-digital-pet-repository.fixtures.ts" type ReceiptMatrixCase = { readonly mode: ReceiptMode @@ -148,7 +148,7 @@ const receiptMatrixCases: ReceiptMatrixCase[] = [ }, ] -describe.if(isBunSqliteAvailable)("sqlite vpet repository receipts", () => { +describe.if(isBunSqliteAvailable)("sqlite digital-pet repository receipts", () => { let tempRoot: TempTestRoot beforeEach(async () => { tempRoot = await createTempTestRoot() @@ -169,7 +169,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository receipts", () => { expectedTrainerTotal, expectedEvolutionCalls, }) => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) let evolutionCalls = 0 try { if (hasActivePartner) spawn(repository) @@ -181,6 +181,8 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository receipts", () => { currentNodeId: partner.currentNodeId, gauge: partner.gauge + receipt.tokenDelta, isTerminal: partner.isTerminal, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, })), ).toEqual({ kind: "applied" }) const outcome = repository.applyUsageReceipt(receipt, (partner) => { @@ -189,6 +191,8 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository receipts", () => { currentNodeId: partner.currentNodeId, gauge: partner.gauge + receipt.tokenDelta, isTerminal: partner.isTerminal, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, } }) expect(outcome).toEqual({ kind: expectedOutcome }) @@ -203,7 +207,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository receipts", () => { ) test("Given no active partner When a receipt arrives Then no receipt or trainer state is written", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { expect(applyReceipt(repository, "receipt-1", "usage-1", 100)).toEqual({ kind: "no_active_partner" }) expect(repository.getTrainerState()).toEqual({ totalTokens: 0 }) @@ -214,7 +218,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository receipts", () => { }) test("Given an active partner When the evolution callback throws Then the partner, trainer, events, and receipts remain unchanged", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { const partner = spawn(repository) const baselinePartner = repository.getActivePartner() diff --git a/tests/persistence/sqlite-migrations.persistence.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-migrations.persistence.ts similarity index 67% rename from tests/persistence/sqlite-migrations.persistence.ts rename to packages/opencode-digital-pet/tests/persistence/sqlite-migrations.persistence.ts index 75b6b56..d6457b7 100644 --- a/tests/persistence/sqlite-migrations.persistence.ts +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-migrations.persistence.ts @@ -5,11 +5,10 @@ import { join } from "node:path" import { Database } from "bun:sqlite" import { createExecutor } from "../../src/adapters/sqlite/bun-sqlite-driver.ts" -import { runMigrations } from "../../src/adapters/sqlite/sqlite-migrations.ts" +import { runMigrations } from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-migrations.ts" +import type { QueryValue } from "@jcendal/digital-pet-core/ports/sqlite-executor.ts" import { isBunSqliteAvailable } from "../sqlite-capability.ts" -type QueryValue = string | number | null - type MigrationExecutor = { readonly statements: string[] transactionCount: number @@ -42,7 +41,7 @@ const createMigrationExecutor = (): MigrationExecutor => ({ }) const withDatabase = async (operation: (database: Database) => void): Promise => { - const root = await mkdtemp(join(tmpdir(), "vpet-migrations-")) + const root = await mkdtemp(join(tmpdir(), "digital-pet-migrations-")) const database = new Database(join(root, "pet.db")) try { @@ -57,25 +56,38 @@ const createV2Database = (database: Database): void => { runMigrations(createExecutor(database)) database.run("DROP TABLE vpet_control_receipts") database.run("DROP TABLE vpet_control_state") - database.run("DELETE FROM schema_migrations WHERE version = 3") + database.run("ALTER TABLE partners DROP COLUMN pending_evolution_target_id") + database.run("ALTER TABLE partners DROP COLUMN battle_opponent_node_id") + database.run("DELETE FROM schema_migrations WHERE version IN (3, 4)") } +const createV3Database = (database: Database): void => { + runMigrations(createExecutor(database)) + database.run("ALTER TABLE partners DROP COLUMN pending_evolution_target_id") + database.run("ALTER TABLE partners DROP COLUMN battle_opponent_node_id") + database.run("DELETE FROM schema_migrations WHERE version = 4") +} + +const ALL_MIGRATIONS = [1, 2, 3, 4] as const + +const partnerLegacyColumns = "partner_id, generation, current_node_id, gauge, is_terminal, created_at, retired_at" + describe("sqlite migrations", () => { test("Given an unmigrated database When migrations run Then inspection and all migration writes share one immediate transaction", () => { const executor = createMigrationExecutor() - expect(runMigrations(executor)).toEqual([1, 2, 3]) + expect(runMigrations(executor)).toEqual(ALL_MIGRATIONS) expect(executor.transactionCount).toBe(1) expect(executor.statements.filter((statement) => statement.includes("INSERT INTO schema_migrations"))).toHaveLength( - 3, + ALL_MIGRATIONS.length, ) }) test.if(isBunSqliteAvailable)( - "Given a fresh database When migration 3 runs Then it creates one default singleton control state and a constrained disabled-mode receipt ledger", + "Given a fresh database When migrations run Then it creates one default singleton control state and a constrained disabled-mode receipt ledger", async () => { await withDatabase((database) => { - expect(runMigrations(createExecutor(database))).toEqual([1, 2, 3]) + expect(runMigrations(createExecutor(database))).toEqual(ALL_MIGRATIONS) expect(database.query("SELECT control_id, frozen, cheat_node_id FROM vpet_control_state").all()).toEqual([ { control_id: 1, frozen: 0, cheat_node_id: null }, ]) @@ -110,11 +122,16 @@ describe("sqlite migrations", () => { database.run( "INSERT INTO usage_receipts (receipt_key, partner_id, event_id, token_delta, cost, created_at) VALUES ('receipt-v2', 'partner-v2', 'event-v2', 7, 1.5, '2026-08-22T00:00:00.000Z')", ) - const legacyTables = ["trainer_state", "partners", "partner_events", "usage_receipts"] + const legacyTables = ["trainer_state", "partner_events", "usage_receipts"] const legacyRows = legacyTables.map((table) => database.query(`SELECT * FROM ${table}`).all()) + const legacyPartners = database.query(`SELECT ${partnerLegacyColumns} FROM partners`).all() - expect(runMigrations(createExecutor(database))).toEqual([1, 2, 3]) + expect(runMigrations(createExecutor(database))).toEqual(ALL_MIGRATIONS) expect(legacyTables.map((table) => database.query(`SELECT * FROM ${table}`).all())).toEqual(legacyRows) + expect(database.query(`SELECT ${partnerLegacyColumns} FROM partners`).all()).toEqual(legacyPartners) + expect( + database.query("SELECT pending_evolution_target_id, battle_opponent_node_id FROM partners").all(), + ).toEqual([{ pending_evolution_target_id: null, battle_opponent_node_id: null }]) }) }, ) @@ -152,15 +169,15 @@ describe("sqlite migrations", () => { test.if(isBunSqliteAvailable)( "Given a migrated database When migration 3 reruns or two writers open it Then it retains exactly one default control row", async () => { - const root = await mkdtemp(join(tmpdir(), "vpet-migrations-")) + const root = await mkdtemp(join(tmpdir(), "digital-pet-migrations-")) const databasePath = join(root, "pet.db") const first = new Database(databasePath) const second = new Database(databasePath) try { - expect(runMigrations(createExecutor(first))).toEqual([1, 2, 3]) - expect(runMigrations(createExecutor(first))).toEqual([1, 2, 3]) - expect(runMigrations(createExecutor(second))).toEqual([1, 2, 3]) + expect(runMigrations(createExecutor(first))).toEqual(ALL_MIGRATIONS) + expect(runMigrations(createExecutor(first))).toEqual(ALL_MIGRATIONS) + expect(runMigrations(createExecutor(second))).toEqual(ALL_MIGRATIONS) expect(first.query("SELECT control_id, frozen, cheat_node_id FROM vpet_control_state").all()).toEqual([ { control_id: 1, frozen: 0, cheat_node_id: null }, ]) @@ -171,4 +188,23 @@ describe("sqlite migrations", () => { } }, ) + + test.if(isBunSqliteAvailable)( + "Given a migrated version 3 database When migration 4 runs Then it adds nullable evolution battle columns to partners", + async () => { + await withDatabase((database) => { + createV3Database(database) + database.run( + "INSERT INTO partners (partner_id, generation, current_node_id, gauge, is_terminal, created_at, retired_at) VALUES ('partner-v3', 1, '0-001', 7, 0, '2026-08-22T00:00:00.000Z', NULL)", + ) + const legacyPartner = database.query(`SELECT ${partnerLegacyColumns} FROM partners`).all() + + expect(runMigrations(createExecutor(database))).toEqual(ALL_MIGRATIONS) + expect(database.query(`SELECT ${partnerLegacyColumns} FROM partners`).all()).toEqual(legacyPartner) + expect( + database.query("SELECT pending_evolution_target_id, battle_opponent_node_id FROM partners").all(), + ).toEqual([{ pending_evolution_target_id: null, battle_opponent_node_id: null }]) + }) + }, + ) }) diff --git a/tests/persistence/sqlite-usage-event-toast.persistence.test.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-usage-event-toast.persistence.test.ts similarity index 75% rename from tests/persistence/sqlite-usage-event-toast.persistence.test.ts rename to packages/opencode-digital-pet/tests/persistence/sqlite-usage-event-toast.persistence.test.ts index efaa6be..33d982b 100644 --- a/tests/persistence/sqlite-usage-event-toast.persistence.test.ts +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-usage-event-toast.persistence.test.ts @@ -2,12 +2,17 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import type { AssistantMessage, Event } from "@opencode-ai/sdk" import { createServerHooks } from "../../src/adapters/opencode/create-server-hooks.ts" -import type { VpetToastPayload } from "../../src/adapters/opencode/vpet-toast.ts" -import { DIGIMON_CATALOG } from "../../src/data/catalog.ts" -import { STAGE_GAUGE_THRESHOLDS } from "../../src/domain/evolution.ts" -import { createSqliteVpetRepository } from "../../src/adapters/sqlite/sqlite-vpet-write-store.ts" +import type { DigitalPetToastPayload } from "../../src/adapters/opencode/digital-pet-toast.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { STAGE_GAUGE_THRESHOLDS } from "@jcendal/digital-pet-core/domain/evolution.ts" +import { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" import { isBunSqliteAvailable } from "../sqlite-capability.ts" -import { createTempTestRoot, removeTempTestRoot, spawn, type TempTestRoot } from "./sqlite-vpet-repository.fixtures.ts" +import { + createTempTestRoot, + removeTempTestRoot, + spawn, + type TempTestRoot, +} from "./sqlite-digital-pet-repository.fixtures.ts" const completedMessage = (id: string): AssistantMessage => ({ id, @@ -29,10 +34,10 @@ const idleEvent = { type: "session.idle", properties: { sessionID: "session-toas const evolutionThresholds = Object.freeze({ ...STAGE_GAUGE_THRESHOLDS, 0: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1 }) const createHooks = ( - repository: Awaited>, - delivered: VpetToastPayload[], + repository: Awaited>, + delivered: DigitalPetToastPayload[], messages: readonly AssistantMessage[] = [], - notify: (payload: VpetToastPayload) => Promise = async (payload) => { + notify: (payload: DigitalPetToastPayload) => Promise = async (payload) => { delivered.push(payload) }, ) => @@ -64,8 +69,8 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => }) test("Given a persisted evolution receipt When direct ingestion handles it Then exactly one evolution toast is delivered", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const delivered: VpetToastPayload[] = [] + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const delivered: DigitalPetToastPayload[] = [] try { spawn(repository) @@ -74,7 +79,13 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => expect(delivered).toEqual([ expect.objectContaining({ title: "Digi-evolution", variant: "success", duration: 5_000 }), ]) - expect(repository.getActivePartner()).toEqual(expect.objectContaining({ currentNodeId: "1-001", gauge: 0 })) + expect(repository.getActivePartner()).toEqual( + expect.objectContaining({ + currentNodeId: "1-001", + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }), + ) expect(repository.listUsageReceipts().map((receipt) => receipt.receiptKey)).toEqual(["message:direct-only"]) expect(repository.getTrainerState()).toEqual({ totalTokens: 1 }) } finally { @@ -83,15 +94,21 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => }) test("Given an idle-only evolution When reconciliation receives it Then one toast and durable receipt are recorded", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const delivered: VpetToastPayload[] = [] + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const delivered: DigitalPetToastPayload[] = [] try { spawn(repository) await dispatch(createHooks(repository, delivered, [completedMessage("idle-only")]), idleEvent) expect(delivered).toHaveLength(1) - expect(repository.getActivePartner()).toEqual(expect.objectContaining({ currentNodeId: "1-001", gauge: 0 })) + expect(repository.getActivePartner()).toEqual( + expect.objectContaining({ + currentNodeId: "1-001", + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }), + ) expect(repository.listUsageReceipts().map((receipt) => receipt.receiptKey)).toEqual(["message:idle-only"]) expect(repository.getTrainerState()).toEqual({ totalTokens: 1 }) } finally { @@ -100,8 +117,8 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => }) test("Given a direct receipt replayed by idle reconciliation When both hooks run Then SQLite suppresses the duplicate toast and state change", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const delivered: VpetToastPayload[] = [] + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const delivered: DigitalPetToastPayload[] = [] try { spawn(repository) @@ -121,8 +138,8 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => }) test("Given an idle receipt replayed by direct ingestion When both hooks run Then SQLite suppresses the duplicate toast and state change", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const delivered: VpetToastPayload[] = [] + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const delivered: DigitalPetToastPayload[] = [] try { spawn(repository) @@ -142,8 +159,8 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => }) test("Given an evolved receipt before repository reopen When idle replays it through a new hook Then persisted duplicate state prevents another toast", async () => { - const firstRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const delivered: VpetToastPayload[] = [] + const firstRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const delivered: DigitalPetToastPayload[] = [] spawn(firstRepository) await dispatch(createHooks(firstRepository, delivered), directEvent("reopen-replay")) @@ -153,7 +170,7 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => const expectedTrainer = firstRepository.getTrainerState() await firstRepository.close() - const reopenedRepository = await createSqliteVpetRepository({ databasePath }) + const reopenedRepository = await createSqliteDigitalPetRepository({ databasePath }) try { await dispatch(createHooks(reopenedRepository, delivered, [completedMessage("reopen-replay")]), idleEvent) @@ -167,8 +184,8 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => }) test("Given a notifier rejection after an evolution commits When the receipt replays after reopen Then the durable duplicate prevents retry and a second toast", async () => { - const firstRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const delivered: VpetToastPayload[] = [] + const firstRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const delivered: DigitalPetToastPayload[] = [] let attempts = 0 spawn(firstRepository) @@ -185,7 +202,7 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => const expectedTrainer = firstRepository.getTrainerState() await firstRepository.close() - const reopenedRepository = await createSqliteVpetRepository({ databasePath }) + const reopenedRepository = await createSqliteDigitalPetRepository({ databasePath }) try { await dispatch(createHooks(reopenedRepository, delivered, [completedMessage("notifier-failure")]), idleEvent) @@ -200,8 +217,8 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => }) test("Given two new completed messages and one replay in idle order When reconciliation processes them Then it emits only new evolutions in message order", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const delivered: VpetToastPayload[] = [] + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const delivered: DigitalPetToastPayload[] = [] try { spawn(repository) @@ -214,10 +231,7 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => idleEvent, ) - expect(delivered.map((payload) => payload.message)).toEqual([ - "Digiegg evolved into Argomon!", - "Argomon evolved into Argomon!", - ]) + expect(delivered.map((payload) => payload.title)).toEqual(["Digi-evolution", "Evolution battle"]) expect(repository.listUsageReceipts().map((receipt) => receipt.receiptKey)).toEqual([ "message:ordered-first", "message:ordered-second", diff --git a/tests/port-boundary.test.ts b/packages/opencode-digital-pet/tests/port-boundary.test.ts similarity index 78% rename from tests/port-boundary.test.ts rename to packages/opencode-digital-pet/tests/port-boundary.test.ts index 09b23d6..830982f 100644 --- a/tests/port-boundary.test.ts +++ b/packages/opencode-digital-pet/tests/port-boundary.test.ts @@ -4,11 +4,11 @@ import type { JSX } from "@opentui/solid" import { chmod, mkdir, readFile, writeFile } from "node:fs/promises" import { join } from "node:path" -import { MonsterFrame, MONSTER_FRAME_CATALOG } from "../src/data/monster-frame-catalog.ts" +import { MonsterFrame, MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" import entryPlugin, { createCommandConfig, plugin } from "../src/index.ts" import tuiPlugin from "../src/tui.tsx" -import type { MonsterAnimationOutput } from "../src/tui/monster-animation.ts" -import { mirrorMonsterFrame } from "../src/tui/monster-artwork-mirror.ts" +import { mirrorMonsterFrame } from "@jcendal/digital-pet-animation/idle/monster-artwork-mirror.ts" +import type { MonsterAnimationOutput } from "@jcendal/digital-pet-animation/idle/monster-animation.ts" import { createPackageFixture, fixtureEntryUrl, @@ -141,7 +141,7 @@ const renderBuiltSidebar = async ( slots: { register: (registered: { readonly slots: { readonly sidebar_content: () => JSX.Element } }) => { slot = registered.slots.sidebar_content - return "opencode-vpet" + return "opencode-digital-pet" }, }, }) @@ -190,19 +190,19 @@ describe("lightweight package boundary", () => { { timeout: 15_000 }, ) - test("Given the core entrypoint When OpenCode configures it Then it registers all VPet commands", async () => { - expect(entryPlugin).toEqual({ id: "opencode-vpet", server: plugin }) + test("Given the core entrypoint When OpenCode configures it Then it registers all Digital Pet commands", async () => { + expect(entryPlugin).toEqual({ id: "opencode-digital-pet", server: plugin }) expect(createCommandConfig()).toEqual({ - "vpet-spawn": { + "digital-pet-spawn": { template: "Spawn a new virtual pet.", }, - "vpet-freeze": { + "digital-pet-freeze": { template: "Freeze virtual pet progression.", }, - "vpet-unfreeze": { + "digital-pet-unfreeze": { template: "Unfreeze virtual pet progression.", }, - "vpet-set": { + "digital-pet-set": { template: "Set the virtual pet to a Digimon ID: $ARGUMENTS", }, }) @@ -213,7 +213,7 @@ describe("lightweight package boundary", () => { const builtModule = await import(fixtureEntryUrl(fixture, "index.js")) const builtEntry = builtModule.default - expect(builtEntry.id).toBe("opencode-vpet") + expect(builtEntry.id).toBe("opencode-digital-pet") expect(builtEntry.server).toBeFunction() expect(builtModule.createCommandConfig()).toEqual(createCommandConfig()) }) @@ -233,7 +233,8 @@ describe("lightweight package boundary", () => { Math.random = originalRandom } - expect(digitama.frame).toContain(" ▄▄▀▀▀▀▄▄") + expect(digitama.frame).toContain("Digitama") + expect(digitama.outputs.some((output) => output.kind === "digitama" && output.result.kind === "frame")).toBe(true) expect(digitama.subscriptions).toEqual(["message.updated", "message.part.updated", "session.status"]) expect(digitama.unsubscribeCalls).toBe(3) const rightFacingFrame = firstRightFacingAnimation(walking) @@ -262,6 +263,7 @@ describe("lightweight package boundary", () => { ]), ) expect(fixture.archiveMembers.some((member) => /\.(?:txt|png|npy)$/.test(member))).toBe(false) + expect(fixture.archiveMembers.some((member) => member.endsWith(".map"))).toBe(false) expect( fixture.archiveMembers.some( (member) => member.startsWith("package/src/") || member.startsWith("package/assets/"), @@ -272,15 +274,15 @@ describe("lightweight package boundary", () => { await Bun.write( consumerScript, ` -import entryPlugin, { createCommandConfig } from "opencode-vpet" -import serverPlugin from "opencode-vpet/server" +import entryPlugin, { createCommandConfig } from "opencode-digital-pet" +import serverPlugin from "opencode-digital-pet/server" import { testRender } from "@opentui/solid" const settings = { language: "jp", stageLabels: { en: { egg: "DigiEgg", babyI: "Baby I", babyII: "Baby II", child: "Child", adult: "Adult", perfect: "Perfect", ultimate: "Ultimate", superUltimate: "SuperUltimate" }, jp: { egg: "Digitama", babyI: "Baby I", babyII: "Baby II", child: "Child", adult: "Adult", perfect: "Perfect", ultimate: "Ultimate", superUltimate: "SuperUltimate" } }, stageThresholds: { egg: 1000, babyI: 5000, babyII: 25000, child: 100000, adult: 500000, perfect: 2500000, ultimate: 5000000, superUltimate: 10000000 } } const originalRandom = Math.random try { Math.random = () => 0 -const { default: tuiPlugin, createTui } = await import("opencode-vpet/tui") +const { default: tuiPlugin, createTui } = await import("opencode-digital-pet/tui") let registeredLayer; let dialogOpened = false; const partner = (sprite, stage) => ({ kind: "partner", node: { id: "3-001", nameEn: "Agumon", nameJp: "Agumon", nextEvolutions: [], sprite, stage, url: "https://example.test/partner" }, gauge: 42, isTerminal: false, trainerTotalTokens: 100 }) const render = async (loadInputs, ticks, refreshAfterMount) => { let tick; let poll @@ -289,18 +291,18 @@ const render = async (loadInputs, ticks, refreshAfterMount) => { let renderer const subscriptions = [] const outputs = [] - const fakeArchiveReader = { async *readVpetArchiveEvents() { yield { type: "vpet_spawned", timestamp: Date.now() } } }; await createTui(loadInputs, settings, { scheduleVisualInterval: (callback) => { tick = callback; return () => undefined }, schedulePollTimeout: (callback) => { poll = callback; return () => undefined }, onAnimation: (output) => outputs.push(output), archiveReader: fakeArchiveReader })({ event: { on: (type) => { subscriptions.push(type); return () => undefined } }, renderer: { requestRender: () => renderer?.requestRender() }, lifecycle: { onDispose: (callback) => { dispose = callback; return () => undefined } }, slots: { register: (registered) => { slot = registered.slots.sidebar_content; return "opencode-vpet" } }, keymap: { registerLayer: (layer) => { registeredLayer = layer; return () => undefined } }, ui: { dialog: { replace: (el) => { dialogOpened = true; return undefined }, clear: () => undefined, setSize: () => undefined }, Dialog: (props) => props.children }, theme: { spacing: { 1: 1 }, colors: { fg: "white" } } }) - try { const setup = await testRender(slot, { width: 81, height: 24 }); renderer = setup.renderer; await setup.flush(); setup.renderer.resize(80, 24); await setup.flush(); await Promise.resolve(); await Promise.resolve(); refreshAfterMount?.(); poll?.(); await Promise.resolve(); await Promise.resolve(); for (let index = 0; index < ticks; index += 1) tick(); await setup.renderOnce(); const frame = setup.captureCharFrame(); setup.renderer.destroy(); if (registeredLayer) { const dexCmd = registeredLayer.commands.find(c => c.name === "vpet.dex"); if (dexCmd) dexCmd.run(); } return { frame, subscriptions, outputs } } finally { dispose() } + const fakeArchiveReader = { async *readDigitalPetArchiveEvents() { yield { type: "digital_pet_spawned", timestamp: Date.now() } } }; await createTui(loadInputs, settings, { scheduleVisualInterval: (callback) => { tick = callback; return () => undefined }, schedulePollTimeout: (callback) => { poll = callback; return () => undefined }, onAnimation: (output) => outputs.push(output), archiveReader: fakeArchiveReader })({ event: { on: (type) => { subscriptions.push(type); return () => undefined } }, renderer: { requestRender: () => renderer?.requestRender() }, lifecycle: { onDispose: (callback) => { dispose = callback; return () => undefined } }, slots: { register: (registered) => { slot = registered.slots.sidebar_content; return "opencode-digital-pet" } }, keymap: { registerLayer: (layer) => { registeredLayer = layer; return () => undefined } }, ui: { dialog: { replace: (el) => { dialogOpened = true; return undefined }, clear: () => undefined, setSize: () => undefined }, Dialog: (props) => props.children }, theme: { spacing: { 1: 1 }, colors: { fg: "white" } } }) + try { const setup = await testRender(slot, { width: 81, height: 24 }); renderer = setup.renderer; await setup.flush(); setup.renderer.resize(80, 24); await setup.flush(); await Promise.resolve(); await Promise.resolve(); refreshAfterMount?.(); poll?.(); await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 50)); for (let index = 0; index < ticks; index += 1) tick(); await setup.renderOnce(); const frame = setup.captureCharFrame(); setup.renderer.destroy(); if (registeredLayer) { const dexCmd = registeredLayer.commands.find(c => c.name === "digital-pet.dex"); if (dexCmd) dexCmd.run(); } return { frame, subscriptions, outputs } } finally { dispose() } } -if (entryPlugin.id !== "opencode-vpet" || typeof entryPlugin.server !== "function") throw new Error("Core export failed") -if (serverPlugin.id !== "opencode-vpet" || typeof serverPlugin.server !== "function") throw new Error("Server export failed") -if (tuiPlugin.id !== "opencode-vpet" || typeof tuiPlugin.tui !== "function") throw new Error("TUI export failed") -const privateTuiRejected = !(await import("opencode-vpet/tui/monster-animation").then(() => true, () => false)) -const privateCommandRejected = !(await import("opencode-vpet/commands/vpet-freeze").then(() => true, () => false)) +if (entryPlugin.id !== "opencode-digital-pet" || typeof entryPlugin.server !== "function") throw new Error("Core export failed") +if (serverPlugin.id !== "opencode-digital-pet" || typeof serverPlugin.server !== "function") throw new Error("Server export failed") +if (tuiPlugin.id !== "opencode-digital-pet" || typeof tuiPlugin.tui !== "function") throw new Error("TUI export failed") +const privateTuiRejected = !(await import("opencode-digital-pet/tui/monster-animation").then(() => true, () => false)) +const privateCommandRejected = !(await import("opencode-digital-pet/commands/digital-pet-freeze").then(() => true, () => false)) const commandConfigs = Object.keys(createCommandConfig()).sort() if (!privateTuiRejected) throw new Error("Private TUI module exported") if (!privateCommandRejected) throw new Error("Private command module exported") -if (commandConfigs.join(",") !== "vpet-freeze,vpet-set,vpet-spawn,vpet-unfreeze") throw new Error("Packed command configuration missing") +if (commandConfigs.join(",") !== "digital-pet-freeze,digital-pet-set,digital-pet-spawn,digital-pet-unfreeze") throw new Error("Packed command configuration missing") const walking = await render(() => partner("agumon", 3), 58) if (!walking.outputs.some((output) => output.kind === "walking" && output.facing === "right" && output.offset === -32)) throw new Error("Right-facing animation transition failed") if (walking.subscriptions.join(",") !== "message.updated,message.part.updated,session.status") throw new Error("Activity subscriptions missing") @@ -322,26 +324,28 @@ console.log(JSON.stringify({ commandConfigs, privateCommandRejected, privateTuiR expect(new TextDecoder().decode(result.stderr)).toBe("") expect(new TextDecoder().decode(result.stdout)).toContain('"facing":"right"') expect(new TextDecoder().decode(result.stdout)).toContain( - '"commandConfigs":["vpet-freeze","vpet-set","vpet-spawn","vpet-unfreeze"]', + '"commandConfigs":["digital-pet-freeze","digital-pet-set","digital-pet-spawn","digital-pet-unfreeze"]', ) expect(new TextDecoder().decode(result.stdout)).toContain('"privateCommandRejected":true') expect(new TextDecoder().decode(result.stdout)).toContain('"privateTuiRejected":true') expect(new TextDecoder().decode(result.stdout)).toContain('"retainedMovement":true') expect(new TextDecoder().decode(result.stdout)).toContain('"retainedAction":true') - expect(new TextDecoder().decode(result.stdout)).toContain('"layerCommands":["vpet.dex","vpet.history"]') + expect(new TextDecoder().decode(result.stdout)).toContain( + '"layerCommands":["digital-pet.dex","digital-pet.history"]', + ) expect(new TextDecoder().decode(result.stdout)).toContain('"dialogOpened":true') }) test("Given the packed npm bin and a fake OpenCode executable When its CLI commands run Then only init and update execute the native exact-version installer", async () => { const fixture = getFixture() - const cliPath = join(fixture.consumerDirectory, "node_modules", "opencode-vpet", "dist", "cli.js") + const cliPath = join(fixture.consumerDirectory, "node_modules", "opencode-digital-pet", "dist", "cli.js") const fakeBinDirectory = join(fixture.root, "fake-bin") const callsPath = join(fixture.root, "opencode-calls.jsonl") const executablePath = join(fakeBinDirectory, "opencode") const manifest = JSON.parse( - await Bun.file(join(fixture.consumerDirectory, "node_modules", "opencode-vpet", "package.json")).text(), + await Bun.file(join(fixture.consumerDirectory, "node_modules", "opencode-digital-pet", "package.json")).text(), ) - const expected = ["plugin", `@sbugallo/opencode-vpet@${manifest.version}`, "--global", "--force"] + const expected = ["plugin", `@jcendal/opencode-digital-pet@${manifest.version}`, "--global", "--force"] await mkdir(fakeBinDirectory) await writeFile( executablePath, @@ -359,7 +363,7 @@ process.exit(Number(process.env.OPENCODE_FAKE_EXIT ?? "0")) OPENCODE_CONFIG_DIR: join(fixture.root, "opencode"), OPENCODE_FAKE_CALLS: callsPath, OPENCODE_FAKE_EXIT: String(exitCode), - PATH: `${fakeBinDirectory}:${process.env.PATH ?? ""}`, + PATH: `${fakeBinDirectory}:${process.env["PATH"] ?? ""}`, }) const runCli = (arguments_: readonly string[], exitCode = 0) => Bun.spawnSync([process.execPath, cliPath, ...arguments_], { @@ -374,7 +378,7 @@ process.exit(Number(process.env.OPENCODE_FAKE_EXIT ?? "0")) const updateDryRunResult = runCli(["update", "--dry-run"]) expect(helpResult.exitCode, new TextDecoder().decode(helpResult.stderr)).toBe(0) - expect(new TextDecoder().decode(helpResult.stdout)).toContain("Usage: opencode-vpet") + expect(new TextDecoder().decode(helpResult.stdout)).toContain("Usage: opencode-digital-pet") expect(initDryRunResult.exitCode, new TextDecoder().decode(initDryRunResult.stderr)).toBe(0) expect(updateDryRunResult.exitCode, new TextDecoder().decode(updateDryRunResult.stderr)).toBe(0) expect(new TextDecoder().decode(initDryRunResult.stdout)).toBe(`opencode ${expected.join(" ")}\n`) @@ -398,16 +402,16 @@ process.exit(Number(process.env.OPENCODE_FAKE_EXIT ?? "0")) expect(await Bun.file(join(fixture.root, "opencode", "tui.json")).exists()).toBe(false) }) - test("Given published package metadata When a registry reads it Then it describes all four VPet commands", async () => { + test("Given published package metadata When a registry reads it Then it describes all four Digital Pet commands", async () => { const manifest = await Bun.file(new URL("../package.json", import.meta.url)).text() expect(manifest).toContain( - '"description": "An OpenCode virtual-pet plugin with /vpet-spawn, /vpet-freeze, /vpet-unfreeze, /vpet-set, /vpet-dex, and /vpet-history commands."', + '"description": "An OpenCode virtual-pet plugin with /digital-pet-spawn, /digital-pet-freeze, /digital-pet-unfreeze, /digital-pet-set, /digital-pet-dex, and /digital-pet-history commands."', ) }) test("Given the TUI entrypoint When OpenCode loads it Then it is the compact plugin module", () => { - expect(tuiPlugin).toMatchObject({ id: "opencode-vpet" }) + expect(tuiPlugin).toMatchObject({ id: "opencode-digital-pet" }) expect(tuiPlugin.tui).toBeFunction() }) }) diff --git a/tests/record-usage.test.ts b/packages/opencode-digital-pet/tests/record-usage.test.ts similarity index 85% rename from tests/record-usage.test.ts rename to packages/opencode-digital-pet/tests/record-usage.test.ts index 432b4c3..cc03872 100644 --- a/tests/record-usage.test.ts +++ b/packages/opencode-digital-pet/tests/record-usage.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "bun:test" -import type { DigimonNode } from "../src/data/catalog.ts" -import type { Partner, PartnerProgression } from "../src/domain/partner.ts" -import type { UsageReceiptMetadata } from "../src/application/models/usage.ts" -import type { UsageLedger } from "../src/application/ports/usage-ledger.ts" -import { reconcileUsage } from "../src/application/use-cases/reconcile-usage.ts" -import { recordUsage, type CompletedUsage } from "../src/application/use-cases/record-usage.ts" -import { STAGE_GAUGE_THRESHOLDS, type StageThresholds } from "../src/domain/evolution.ts" +import type { DigimonNode } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { Partner, PartnerProgression } from "@jcendal/digital-pet-core/domain/partner.ts" +import type { UsageReceiptMetadata } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import { reconcileUsage } from "@jcendal/digital-pet-core/application/use-cases/reconcile-usage.ts" +import { recordUsage, type CompletedUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import { STAGE_GAUGE_THRESHOLDS, type StageThresholds } from "@jcendal/digital-pet-core/domain/evolution.ts" const currentNode: DigimonNode = { id: "current", @@ -28,12 +28,16 @@ const targetNode: DigimonNode = { url: "https://example.test/target", } +const catalogNodes = [currentNode, targetNode] + const activePartner: Partner = { partnerId: "partner-1", generation: 1, currentNodeId: currentNode.id, - gauge: 499_999, + gauge: 4_999_999, isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, } @@ -69,7 +73,7 @@ const createLedger = (outcome: "applied" | "duplicate" | "no_active_partner", pa } describe("record usage application use case", () => { - test("Given completed usage and an active partner When recording it Then the ledger applies the transaction-scoped evolution", () => { + test("Given completed usage and an active Digitama When recording it Then the ledger evolves immediately", () => { const ledger = createLedger("applied") const outcome = recordUsage({ @@ -79,6 +83,7 @@ describe("record usage application use case", () => { [currentNode.id, currentNode], [targetNode.id, targetNode], ]), + catalogNodes, selector: () => 0, thresholds: STAGE_GAUGE_THRESHOLDS, }) @@ -94,6 +99,8 @@ describe("record usage application use case", () => { currentNodeId: targetNode.id, gauge: 0, isTerminal: true, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }, ]) }) @@ -111,6 +118,7 @@ describe("record usage application use case", () => { selector: () => { throw new Error("selector must not run") }, + catalogNodes, thresholds: STAGE_GAUGE_THRESHOLDS, }) @@ -121,6 +129,8 @@ describe("record usage application use case", () => { currentNodeId: currentNode.id, gauge: 1, isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }, ]) }) @@ -135,6 +145,7 @@ describe("record usage application use case", () => { selector: () => { throw new Error("selector must not run") }, + catalogNodes, thresholds: STAGE_GAUGE_THRESHOLDS, }) @@ -152,6 +163,7 @@ describe("record usage application use case", () => { selector: () => { throw new Error("selector must not run") }, + catalogNodes, thresholds: STAGE_GAUGE_THRESHOLDS, }) @@ -178,6 +190,7 @@ describe("record usage application use case", () => { selector: () => { throw new Error("selector must not run") }, + catalogNodes, thresholds: STAGE_GAUGE_THRESHOLDS, }) @@ -201,6 +214,7 @@ describe("record usage application use case", () => { selector: () => { throw new Error("selector must not run") }, + catalogNodes, thresholds: STAGE_GAUGE_THRESHOLDS, }) @@ -210,6 +224,8 @@ describe("record usage application use case", () => { currentNodeId: targetNode.id, gauge: 0, isTerminal: true, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }, ]) }) @@ -233,6 +249,7 @@ describe("record usage application use case", () => { selector: () => { throw new Error("selector must not run") }, + catalogNodes, thresholds: STAGE_GAUGE_THRESHOLDS, }) @@ -254,6 +271,7 @@ describe("record usage application use case", () => { ledger, digimonById: new Map(), selector: () => 0, + catalogNodes, thresholds: STAGE_GAUGE_THRESHOLDS, }), ).toThrow("Persisted partner node missing is missing from the catalog") @@ -278,12 +296,13 @@ describe("record usage application use case", () => { [targetNode.id, targetNode], ]), selector: () => 0, + catalogNodes, thresholds: STAGE_GAUGE_THRESHOLDS, }), ).toThrow(writeFailure) }) - test("Given a custom child threshold When recording usage Then the ledger evolves at the supplied threshold", () => { + test("Given a custom Digitama threshold When recording usage Then the ledger evolves immediately", () => { const thresholds: StageThresholds = Object.freeze({ ...STAGE_GAUGE_THRESHOLDS, 0: 1, @@ -297,6 +316,7 @@ describe("record usage application use case", () => { [currentNode.id, currentNode], [targetNode.id, targetNode], ]), + catalogNodes, selector: () => 0, thresholds, }) @@ -311,6 +331,8 @@ describe("record usage application use case", () => { currentNodeId: targetNode.id, gauge: 0, isTerminal: true, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }, ]) }) diff --git a/tests/release-metadata.test.ts b/packages/opencode-digital-pet/tests/release-metadata.test.ts similarity index 60% rename from tests/release-metadata.test.ts rename to packages/opencode-digital-pet/tests/release-metadata.test.ts index a8e6b13..d79681c 100644 --- a/tests/release-metadata.test.ts +++ b/packages/opencode-digital-pet/tests/release-metadata.test.ts @@ -6,13 +6,14 @@ import { join, resolve } from "node:path" import { calculateNextDevelopmentVersion, calculateReleaseVersion, + extractReleaseNotes, insertReleaseIntoChangelog, -} from "../scripts/release-metadata.ts" +} from "../../../scripts/release-metadata.ts" const tempRoots: string[] = [] const createTempRoot = async (): Promise => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-release-metadata-")) + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-release-metadata-")) tempRoots.push(root) return root } @@ -31,6 +32,16 @@ describe("release metadata", () => { expect(releaseVersion).toBe("1.11.0") }) + test("Given product tags When calculating a release Then unrelated products and legacy tags do not change its version", () => { + expect( + calculateReleaseVersion( + ["v9.0.0", "opencode-digital-pet-v8.0.0", "cursor-digital-pet-v0.4.1"], + "patch", + "cursor-digital-pet-v", + ), + ).toBe("0.4.2") + }) + test.each([ ["patch", "0.0.1"], ["minor", "0.1.0"], @@ -43,8 +54,8 @@ describe("release metadata", () => { ) test.each([ - ["1.4.3", "1.5.0-dev.0"], - ["2.0.0", "2.1.0-dev.0"], + ["1.4.3", "1.4.4-dev.0"], + ["2.0.0", "2.0.1-dev.0"], ] as const)( "Given a stable release %s When calculating its next development version Then it is %s", (releaseVersion, expectedVersion) => { @@ -60,6 +71,13 @@ describe("release metadata", () => { ) }) + test("Given multiple releases When extracting notes Then only the requested product version is returned", () => { + const changelog = + "# Changelog\n\n## [Unreleased]\n\n## [0.2.0] - 2026-10-06\n\n### Added\n\n- Cursor sidebar\n\n## [0.1.0] - 2026-08-30\n\n- Old notes\n" + expect(extractReleaseNotes(changelog, "0.2.0")).toBe("### Added\n\n- Cursor sidebar\n") + expect(() => extractReleaseNotes(changelog, "0.3.0")).toThrow() + }) + test.each([ ["# Changelog\n", "1.2.3", "2026-08-30"], ["## [Unreleased]\n\n## [Unreleased]\n", "1.2.3", "2026-08-30"], @@ -84,14 +102,14 @@ describe("release metadata", () => { const changelogPath = join(root, "CHANGELOG.md") const outputPath = join(root, "github-output") await Promise.all([ - writeFile(packageJsonPath, '{\n "name": "fixture",\n "version": "1.5.0-dev.0"\n}\n'), + writeFile(packageJsonPath, '{\n "name": "fixture",\n "version": "1.4.6-dev.0"\n}\n'), writeFile(changelogPath, "# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Fixture\n"), ]) const result = Bun.spawnSync( [ "bun", - resolve(import.meta.dir, "../scripts/release-metadata.ts"), + resolve(import.meta.dir, "../../../scripts/release-metadata.ts"), "--package-json", packageJsonPath, "--changelog", @@ -109,12 +127,12 @@ describe("release metadata", () => { expect(result.exitCode).toBe(0) expect(JSON.parse(new TextDecoder().decode(result.stdout))).toEqual({ - nextDevVersion: "1.6.0-dev.0", + nextDevVersion: "1.5.1-dev.0", releaseVersion: "1.5.0", }) expect(await readFile(packageJsonPath, "utf8")).toContain('"version": "1.5.0"') expect(await readFile(changelogPath, "utf8")).toContain("## [1.5.0] - 2026-08-30") - expect(await readFile(outputPath, "utf8")).toBe("release_version=1.5.0\nnext_dev_version=1.6.0-dev.0\n") + expect(await readFile(outputPath, "utf8")).toBe("release_version=1.5.0\nnext_dev_version=1.5.1-dev.0\n") }) test("Given a latest stable tag and its expected development source When the helper CLI prepares a minor release Then it releases from that development source", async () => { @@ -122,14 +140,14 @@ describe("release metadata", () => { const packageJsonPath = join(root, "package.json") const changelogPath = join(root, "CHANGELOG.md") await Promise.all([ - writeFile(packageJsonPath, '{"name":"fixture","version":"1.5.0-dev.0"}\n'), + writeFile(packageJsonPath, '{"name":"fixture","version":"1.4.4-dev.0"}\n'), writeFile(changelogPath, "# Changelog\n\n## [Unreleased]\n"), ]) const result = Bun.spawnSync( [ "bun", - resolve(import.meta.dir, "../scripts/release-metadata.ts"), + resolve(import.meta.dir, "../../../scripts/release-metadata.ts"), "--package-json", packageJsonPath, "--changelog", @@ -144,7 +162,7 @@ describe("release metadata", () => { expect(result.exitCode).toBe(0) expect(JSON.parse(new TextDecoder().decode(result.stdout))).toEqual({ - nextDevVersion: "1.6.0-dev.0", + nextDevVersion: "1.5.1-dev.0", releaseVersion: "1.5.0", }) }) @@ -161,7 +179,7 @@ describe("release metadata", () => { const result = Bun.spawnSync( [ "bun", - resolve(import.meta.dir, "../scripts/release-metadata.ts"), + resolve(import.meta.dir, "../../../scripts/release-metadata.ts"), "--package-json", packageJsonPath, "--changelog", @@ -177,4 +195,79 @@ describe("release metadata", () => { expect(result.exitCode).not.toBe(0) expect(await readFile(packageJsonPath, "utf8")).toBe('{"name":"fixture","version":"1.5.1-dev.0"}\n') }) + + test.each([ + ["opencode-digital-pet-v", "v", "minor", "v0.1.1\ncursor-digital-pet-v9.0.0\n"], + ["cursor-digital-pet-v", undefined, "patch", "v0.1.1\nopencode-digital-pet-v9.0.0\n"], + ] as const)( + "Given %s has no product tags When preparing its first release Then it does not take another product's version", + async (tagPrefix, legacyPrefix, bump, tags) => { + const root = await createTempRoot() + const packageJsonPath = join(root, "package.json") + const changelogPath = join(root, "CHANGELOG.md") + await Promise.all([ + writeFile(packageJsonPath, '{"name":"fixture","version":"0.2.0-dev.0"}\n'), + writeFile(changelogPath, "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- Feature\n"), + ]) + + const args = [ + "bun", + resolve(import.meta.dir, "../../../scripts/release-metadata.ts"), + "--package-json", + packageJsonPath, + "--changelog", + changelogPath, + "--tag-prefix", + tagPrefix, + "--bump", + bump, + "--date", + "2026-08-30", + ] + if (legacyPrefix !== undefined) args.push("--legacy-tag-prefix", legacyPrefix) + const result = Bun.spawnSync(args, { + stdin: new Blob([tags]), + }) + + expect(result.exitCode).toBe(0) + expect(JSON.parse(new TextDecoder().decode(result.stdout))).toEqual({ + nextDevVersion: "0.2.1-dev.0", + releaseVersion: "0.2.0", + }) + }, + ) + + test("Given an OpenCode legacy candidate When the first product release uses another bump Then it rejects it", async () => { + const root = await createTempRoot() + const packageJsonPath = join(root, "package.json") + const changelogPath = join(root, "CHANGELOG.md") + const manifest = '{"name":"fixture","version":"0.2.0-dev.0"}\n' + await Promise.all([ + writeFile(packageJsonPath, manifest), + writeFile(changelogPath, "# Changelog\n\n## [Unreleased]\n\n- Feature\n"), + ]) + + const result = Bun.spawnSync( + [ + "bun", + resolve(import.meta.dir, "../../../scripts/release-metadata.ts"), + "--package-json", + packageJsonPath, + "--changelog", + changelogPath, + "--tag-prefix", + "opencode-digital-pet-v", + "--legacy-tag-prefix", + "v", + "--bump", + "patch", + "--date", + "2026-08-30", + ], + { stdin: new Blob(["v0.1.1\n"]) }, + ) + + expect(result.exitCode).not.toBe(0) + expect(await readFile(packageJsonPath, "utf8")).toBe(manifest) + }) }) diff --git a/tests/server-hooks-lifecycle.test.ts b/packages/opencode-digital-pet/tests/server-hooks-lifecycle.test.ts similarity index 73% rename from tests/server-hooks-lifecycle.test.ts rename to packages/opencode-digital-pet/tests/server-hooks-lifecycle.test.ts index 2ec20a5..08e0935 100644 --- a/tests/server-hooks-lifecycle.test.ts +++ b/packages/opencode-digital-pet/tests/server-hooks-lifecycle.test.ts @@ -1,26 +1,24 @@ import { describe, expect, test } from "bun:test" import type { Event } from "@opencode-ai/sdk" +import type { DigimonCatalog, DigimonNode } from "@jcendal/digital-pet-core/data/catalog.ts" +import { STAGE_GAUGE_THRESHOLDS } from "@jcendal/digital-pet-core/domain/evolution.ts" +import type { + ApplyUsageReceiptOutcome, + UsageReceiptMetadata, +} from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { PartnerLifecycle } from "@jcendal/digital-pet-core/application/ports/partner-lifecycle.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" + import { createServerHooks } from "../src/adapters/opencode/create-server-hooks.ts" -import type { VpetToastPayload } from "../src/adapters/opencode/vpet-toast.ts" -import type { DigimonCatalog, DigimonNode } from "../src/data/catalog.ts" -import { STAGE_GAUGE_THRESHOLDS } from "../src/domain/evolution.ts" -import type { ApplyUsageReceiptOutcome, UsageReceiptMetadata } from "../src/application/models/usage.ts" -import type { PartnerLifecycle } from "../src/application/ports/partner-lifecycle.ts" -import type { UsageLedger } from "../src/application/ports/usage-ledger.ts" -import type { VpetControl } from "../src/application/ports/vpet-control.ts" +import type { Partner, PartnerProgression } from "@jcendal/digital-pet-core/domain/partner.ts" +import type { DigitalPetToastPayload } from "../src/adapters/opencode/digital-pet-toast.ts" +import { testPartner } from "./partner-fixtures.ts" const createRepository = (outcome: ApplyUsageReceiptOutcome, onReceipt?: () => void) => ({ - spawnPartner: () => ({ - partnerId: "partner-1", - generation: 1, - currentNodeId: "0-001", - gauge: 0, - isTerminal: false, - createdAt: "2026-07-31T00:00:00.000Z", - retiredAt: null, - }), + spawnPartner: () => testPartner(), applyUsageReceipt: (_receipt: UsageReceiptMetadata) => { onReceipt?.() return outcome @@ -28,7 +26,7 @@ const createRepository = (outcome: ApplyUsageReceiptOutcome, onReceipt?: () => v freeze: () => ({ kind: "already_frozen" as const }), unfreeze: () => ({ kind: "already_unfrozen" as const }), setCheatNode: (cheatNodeId: string) => ({ kind: "already_set" as const, cheatNodeId }), - }) satisfies PartnerLifecycle & UsageLedger & VpetControl + }) satisfies PartnerLifecycle & UsageLedger & DigitalPetControl const toastNodes = [ { @@ -57,36 +55,9 @@ const createToastRepository = () => { let frozen = false let cheatNodeId: string | undefined return { - spawnPartner: () => ({ - partnerId: "partner-1", - generation: 1, - currentNodeId: "0-001", - gauge: 0, - isTerminal: false, - createdAt: "2026-07-31T00:00:00.000Z", - retiredAt: null, - }), - applyUsageReceipt: ( - _receipt: UsageReceiptMetadata, - evolve: (partner: { - readonly partnerId: string - readonly generation: number - readonly currentNodeId: string - readonly gauge: number - readonly isTerminal: boolean - readonly createdAt: string - readonly retiredAt: string | null - }) => { readonly currentNodeId: string; readonly gauge: number; readonly isTerminal: boolean }, - ) => { - evolve({ - partnerId: "partner-1", - generation: 1, - currentNodeId: "0-001", - gauge: 0, - isTerminal: false, - createdAt: "2026-07-31T00:00:00.000Z", - retiredAt: null, - }) + spawnPartner: () => testPartner(), + applyUsageReceipt: (_receipt: UsageReceiptMetadata, evolve: (partner: Partner) => PartnerProgression) => { + evolve(testPartner()) return { kind: "applied" as const } }, freeze: () => { @@ -104,7 +75,7 @@ const createToastRepository = () => { cheatNodeId = nodeId return { kind: "set" as const, cheatNodeId: nodeId } }, - } satisfies PartnerLifecycle & UsageLedger & VpetControl + } satisfies PartnerLifecycle & UsageLedger & DigitalPetControl } const completedAssistantEvent = { @@ -185,7 +156,7 @@ describe("server hook lifecycle", () => { }) test("Given default-enabled command transitions When each command commits Then it appends its legacy parts before exactly one formatted toast", async () => { - const delivered: VpetToastPayload[] = [] + const delivered: DigitalPetToastPayload[] = [] const hooks = createServerHooks({ repository: createToastRepository(), resource: { close: async () => {} }, @@ -197,27 +168,27 @@ describe("server hook lifecycle", () => { }) const commandHandler = hooks["command.execute.before"] if (commandHandler === undefined) throw new Error("Expected command hook") - const commands = ["vpet-spawn", "vpet-freeze", "vpet-unfreeze", "vpet-set"] as const + const commands = ["digital-pet-spawn", "digital-pet-freeze", "digital-pet-unfreeze", "digital-pet-set"] as const for (const command of commands) { const output = { parts: [] } await commandHandler( - { command, sessionID: "session-1", arguments: command === "vpet-set" ? "1-001" : "" }, + { command, sessionID: "session-1", arguments: command === "digital-pet-set" ? "1-001" : "" }, output, ) expect(output.parts).toHaveLength(1) } expect(delivered).toEqual([ - { title: "VPet", message: "Spawned Digitama (Generation 1).", variant: "success", duration: 5_000 }, - { title: "VPet", message: "Digimon progression frozen.", variant: "info", duration: 3_000 }, - { title: "VPet", message: "Digimon progression resumed.", variant: "info", duration: 3_000 }, - { title: "VPet", message: "VPet set to Koromon (1-001).", variant: "info", duration: 3_000 }, + { title: "Digital Pet", message: "Spawned Digitama (Generation 1).", variant: "success", duration: 5_000 }, + { title: "Digital Pet", message: "Digimon progression frozen.", variant: "info", duration: 3_000 }, + { title: "Digital Pet", message: "Digimon progression resumed.", variant: "info", duration: 3_000 }, + { title: "Digital Pet", message: "Digital Pet set to Koromon (1-001).", variant: "info", duration: 3_000 }, ]) }) test("Given committed direct and ordered idle evolutions When their receipts apply Then each evolution produces one formatted toast in processing order", async () => { - const delivered: VpetToastPayload[] = [] + const delivered: DigitalPetToastPayload[] = [] const hooks = createServerHooks({ repository: createToastRepository(), resource: { close: async () => {} }, @@ -240,9 +211,24 @@ describe("server hook lifecycle", () => { await eventHandler({ event: { type: "session.idle", properties: { sessionID: "session-1" } } satisfies Event }) expect(delivered).toEqual([ - { title: "Digi-evolution", message: "Digitama evolved into Koromon!", variant: "success", duration: 5_000 }, - { title: "Digi-evolution", message: "Digitama evolved into Koromon!", variant: "success", duration: 5_000 }, - { title: "Digi-evolution", message: "Digitama evolved into Koromon!", variant: "success", duration: 5_000 }, + { + title: "Digi-evolution", + message: "Digitama evolved into Koromon!", + variant: "success", + duration: 5_000, + }, + { + title: "Digi-evolution", + message: "Digitama evolved into Koromon!", + variant: "success", + duration: 5_000, + }, + { + title: "Digi-evolution", + message: "Digitama evolved into Koromon!", + variant: "success", + duration: 5_000, + }, ]) }) @@ -264,14 +250,14 @@ describe("server hook lifecycle", () => { const commandHandler = hooks["command.execute.before"] if (commandHandler === undefined) throw new Error("Expected command hook") - await commandHandler({ command: "vpet-spawn", sessionID: "session-1", arguments: "" }, { parts: [] }) + await commandHandler({ command: "digital-pet-spawn", sessionID: "session-1", arguments: "" }, { parts: [] }) expect(catalogLoads).toBe(0) expect(notifications).toBe(0) }) test("Given idempotent, invalid, duplicate, and missing-name paths When hooks complete Then they remain silent while preserving their committed result", async () => { - const delivered: VpetToastPayload[] = [] + const delivered: DigitalPetToastPayload[] = [] const hooks = createServerHooks({ repository: createRepository({ kind: "duplicate" }), resource: { close: async () => {} }, @@ -286,8 +272,8 @@ describe("server hook lifecycle", () => { if (commandHandler === undefined || eventHandler === undefined) throw new Error("Expected command and event hooks") const output = { parts: [] } - await commandHandler({ command: "vpet-freeze", sessionID: "session-1", arguments: "" }, output) - await commandHandler({ command: "vpet-set", sessionID: "session-1", arguments: "missing" }, output) + await commandHandler({ command: "digital-pet-freeze", sessionID: "session-1", arguments: "" }, output) + await commandHandler({ command: "digital-pet-set", sessionID: "session-1", arguments: "missing" }, output) await eventHandler({ event: completedAssistantEvent }) expect(output.parts).toHaveLength(2) @@ -314,7 +300,7 @@ describe("server hook lifecycle", () => { if (commandHandler === undefined) throw new Error("Expected command hook") const output = { parts: [] } - await commandHandler({ command: "vpet-freeze", sessionID: "session-1", arguments: "" }, output) + await commandHandler({ command: "digital-pet-freeze", sessionID: "session-1", arguments: "" }, output) expect(output.parts).toHaveLength(1) }, @@ -332,7 +318,7 @@ describe("server hook lifecycle", () => { if (commandHandler === undefined) throw new Error("Expected command hook") const output = { parts: [] } - await commandHandler({ command: "vpet-spawn", sessionID: "session-1", arguments: "" }, output) + await commandHandler({ command: "digital-pet-spawn", sessionID: "session-1", arguments: "" }, output) expect(output.parts).toHaveLength(1) }) diff --git a/tests/server-thresholds.test.ts b/packages/opencode-digital-pet/tests/server-thresholds.test.ts similarity index 69% rename from tests/server-thresholds.test.ts rename to packages/opencode-digital-pet/tests/server-thresholds.test.ts index cf1db3a..efe4f9e 100644 --- a/tests/server-thresholds.test.ts +++ b/packages/opencode-digital-pet/tests/server-thresholds.test.ts @@ -6,20 +6,23 @@ import { join, posix } from "node:path" import { createServerHooks, type ServerHookDependencies } from "../src/adapters/opencode/create-server-hooks.ts" import { createSessionMessagesFetcher } from "../src/adapters/opencode/session-messages.ts" -import type { UsageReceiptMetadata } from "../src/application/models/usage.ts" -import type { PartnerLifecycle } from "../src/application/ports/partner-lifecycle.ts" -import type { UsageLedger } from "../src/application/ports/usage-ledger.ts" -import type { VpetControl } from "../src/application/ports/vpet-control.ts" -import { createSqliteVpetRepository } from "../src/adapters/sqlite/sqlite-vpet-write-store.ts" -import { STAGE_GAUGE_THRESHOLDS, type StageThresholds } from "../src/domain/evolution.ts" -import type { Partner, PartnerProgression } from "../src/domain/partner.ts" -import { loadGlobalVpetSettings, resolveGlobalVpetConfigPath } from "../src/config/global-vpet-settings.ts" +import type { UsageReceiptMetadata } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { PartnerLifecycle } from "@jcendal/digital-pet-core/application/ports/partner-lifecycle.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" +import { createSqliteDigitalPetRepository } from "../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" +import { STAGE_GAUGE_THRESHOLDS, type StageThresholds } from "@jcendal/digital-pet-core/domain/evolution.ts" +import type { Partner, PartnerProgression } from "@jcendal/digital-pet-core/domain/partner.ts" +import { + loadGlobalDigitalPetSettings, + resolveGlobalDigitalPetConfigPath, +} from "../src/config/global-digital-pet-settings.ts" import { isBunSqliteAvailable } from "./sqlite-capability.ts" const tempRoots: string[] = [] const createTempRoot = async (): Promise => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-server-thresholds-")) + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-server-thresholds-")) tempRoots.push(root) return root } @@ -42,7 +45,9 @@ const completedAssistantMessage = (id: string): AssistantMessage => ({ tokens: { input: 1, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, }) -type TestRepository = PartnerLifecycle & UsageLedger & VpetControl & { readonly progressions: PartnerProgression[] } +type TestRepository = PartnerLifecycle & + UsageLedger & + DigitalPetControl & { readonly progressions: PartnerProgression[] } const createRepository = (partner: Partner): TestRepository => { const progressions: PartnerProgression[] = [] @@ -93,12 +98,12 @@ const hooks = await plugin({ experimental_workspace: { register: () => {} }, serverUrl: new URL("http://localhost"), $: Bun.$, -}, { appDataRoot: process.env["VPET_APP_DATA_ROOT"] }) +}, { appDataRoot: process.env["DIGITAL_PET_APP_DATA_ROOT"] }) const command = hooks["command.execute.before"] if (command === undefined) throw new Error("Expected spawn command hook") -await command({ command: "vpet-spawn", sessionID: "session-1", arguments: "" }, { parts: [] }) -if (process.env["VPET_UPDATED_THRESHOLD"] !== undefined) { - await writeFile(join(process.env["HOME"] ?? "", ".config", "opencode-vpet.json"), JSON.stringify({ stageThresholds: { egg: Number(process.env["VPET_UPDATED_THRESHOLD"]) } })) +await command({ command: "digital-pet-spawn", sessionID: "session-1", arguments: "" }, { parts: [] }) +if (process.env["DIGITAL_PET_UPDATED_THRESHOLD"] !== undefined) { + await writeFile(join(process.env["HOME"] ?? "", ".config", "opencode-digital-pet.json"), JSON.stringify({ stageThresholds: { egg: Number(process.env["DIGITAL_PET_UPDATED_THRESHOLD"]) } })) } const handler = hooks.event if (handler === undefined) throw new Error("Expected usage event hook") @@ -125,8 +130,8 @@ await hooks.dispose?.()`, env: { ...process.env, HOME: configHome, - VPET_APP_DATA_ROOT: appDataRoot, - ...(updatedThreshold === undefined ? {} : { VPET_UPDATED_THRESHOLD: String(updatedThreshold) }), + DIGITAL_PET_APP_DATA_ROOT: appDataRoot, + ...(updatedThreshold === undefined ? {} : { DIGITAL_PET_UPDATED_THRESHOLD: String(updatedThreshold) }), }, stderr: "pipe", }, @@ -141,12 +146,12 @@ describe("server threshold policy", () => { const injectedHome = "/temporary/injected-home" expect( - resolveGlobalVpetConfigPath({ + resolveGlobalDigitalPetConfigPath({ env: { HOME: injectedHome }, pathApi: posix, platform: "linux", }), - ).toBe(posix.join(homedir(), ".config", "opencode-vpet.json")) + ).toBe(posix.join(homedir(), ".config", "opencode-digital-pet.json")) }) test("Given a controlled child threshold When direct and idle usage arrive Then both paths evolve from the same supplied policy", async () => { @@ -156,6 +161,8 @@ describe("server threshold policy", () => { currentNodeId: "3-001", gauge: 0, isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, createdAt: "2026-08-21T00:00:00.000Z", retiredAt: null, } @@ -184,8 +191,15 @@ describe("server threshold policy", () => { await dispatch(idleHooks, { type: "session.idle", properties: { sessionID: "session-1" } } satisfies Event) expect(directRepository.progressions).toEqual(idleRepository.progressions) - expect(directRepository.progressions).toEqual([expect.objectContaining({ gauge: 0, isTerminal: false })]) - expect(directRepository.progressions[0]?.currentNodeId).not.toBe(partner.currentNodeId) + expect(directRepository.progressions).toEqual([ + expect.objectContaining({ + currentNodeId: partner.currentNodeId, + gauge: 1, + isTerminal: false, + pendingEvolutionTargetId: expect.any(String), + battleOpponentNodeId: expect.any(String), + }), + ]) }) test("Given default thresholds When direct usage arrives below the child threshold Then the existing timing is retained", async () => { @@ -195,6 +209,8 @@ describe("server threshold policy", () => { currentNodeId: "3-001", gauge: 0, isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, createdAt: "2026-08-21T00:00:00.000Z", retiredAt: null, } @@ -211,6 +227,8 @@ describe("server threshold policy", () => { currentNodeId: partner.currentNodeId, gauge: 1, isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }, ]) }) @@ -218,10 +236,10 @@ describe("server threshold policy", () => { test("Given a threshold file changed after hook construction When loading it once Then the original snapshot remains active", async () => { const root = await createTempRoot() const configDirectory = join(root, ".config") - const configPath = join(configDirectory, "opencode-vpet.json") + const configPath = join(configDirectory, "opencode-digital-pet.json") await mkdir(configDirectory) await writeFile(configPath, JSON.stringify({ stageThresholds: { child: 2 } })) - const settings = await loadGlobalVpetSettings({ home: root }) + const settings = await loadGlobalDigitalPetSettings({ home: root }) await writeFile(configPath, JSON.stringify({ stageThresholds: { child: 1 } })) expect(settings.stageThresholds.child).toBe(2) @@ -234,14 +252,26 @@ describe("server threshold policy", () => { const appDataRoot = await createTempRoot() const configDirectory = join(configRoot, ".config") await mkdir(configDirectory) - await writeFile(join(configDirectory, "opencode-vpet.json"), JSON.stringify({ stageThresholds: { egg: 1 } })) + await writeFile( + join(configDirectory, "opencode-digital-pet.json"), + JSON.stringify({ stageThresholds: { egg: 1 } }), + ) await runPluginWithConfigHome(configRoot, appDataRoot) - const repository = await createSqliteVpetRepository({ - databasePath: join(appDataRoot, "opencode-vpet", "pet.db"), + const repository = await createSqliteDigitalPetRepository({ + databasePath: join(appDataRoot, "opencode-digital-pet", "pet.db"), }) try { - expect(repository.getActivePartner()?.gauge).toBe(0) + const partner = repository.getActivePartner() + expect(partner).not.toBeNull() + expect(partner).toEqual( + expect.objectContaining({ + gauge: 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }), + ) + expect(partner?.currentNodeId).toMatch(/^1-/) } finally { await repository.close() } @@ -254,16 +284,25 @@ describe("server threshold policy", () => { const configRoot = await createTempRoot() const appDataRoot = await createTempRoot() const configDirectory = join(configRoot, ".config") - const configPath = join(configDirectory, "opencode-vpet.json") + const configPath = join(configDirectory, "opencode-digital-pet.json") await mkdir(configDirectory) await writeFile(configPath, JSON.stringify({ stageThresholds: { egg: 1 } })) await runPluginWithConfigHome(configRoot, appDataRoot, 2) - const repository = await createSqliteVpetRepository({ - databasePath: join(appDataRoot, "opencode-vpet", "pet.db"), + const repository = await createSqliteDigitalPetRepository({ + databasePath: join(appDataRoot, "opencode-digital-pet", "pet.db"), }) try { - expect(repository.getActivePartner()?.gauge).toBe(0) + const partner = repository.getActivePartner() + expect(partner).not.toBeNull() + expect(partner).toEqual( + expect.objectContaining({ + gauge: 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }), + ) + expect(partner?.currentNodeId).toMatch(/^1-/) } finally { await repository.close() } diff --git a/tests/sidebar-card.test.tsx b/packages/opencode-digital-pet/tests/sidebar-card.test.tsx similarity index 91% rename from tests/sidebar-card.test.tsx rename to packages/opencode-digital-pet/tests/sidebar-card.test.tsx index a6a48a5..724ee0a 100644 --- a/tests/sidebar-card.test.tsx +++ b/packages/opencode-digital-pet/tests/sidebar-card.test.tsx @@ -3,10 +3,11 @@ import { describe, expect, test } from "bun:test" import { createMockMouse } from "@opentui/core/testing" import { testRender } from "@opentui/solid" -import { MONSTER_FRAME_CATALOG, type MonsterFrame } from "../src/data/monster-frame-catalog.ts" -import { mirrorMonsterFrame } from "../src/tui/monster-artwork-mirror.ts" -import type { MonsterAnimationOutput } from "../src/tui/monster-animation.ts" -import { VpetSidebarCard } from "../src/tui/sidebar-card.tsx" +import { MONSTER_FRAME_CATALOG, type MonsterFrame } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" +import { mirrorMonsterFrame } from "@jcendal/digital-pet-animation/idle/monster-artwork-mirror.ts" +import type { MonsterAnimationOutput } from "@jcendal/digital-pet-animation/idle/monster-animation.ts" + +import { DigitalPetSidebarCard } from "../src/tui/sidebar-card.tsx" const agumonFrame = (): MonsterFrame => { const frame = MONSTER_FRAME_CATALOG.get("agumon", "angry") @@ -31,6 +32,7 @@ const partnerModel = () => ({ isTerminal: false, frozen: false, isSetOverride: false, + evolutionBattlePending: false, }) const frameOutput = (offset: number, facing: "left" | "right" = "left"): MonsterAnimationOutput => ({ @@ -40,11 +42,11 @@ const frameOutput = (offset: number, facing: "left" | "right" = "left"): Monster facing, }) -describe("VPet sidebar frame", () => { +describe("Digital Pet sidebar frame", () => { test("Given a persisted Agumon and animation frame When rendering at sidebar width Then it centers exact artwork above five status rows", async () => { const setup = await testRender( () => ( - ({ ...partnerModel(), })} @@ -84,7 +86,7 @@ describe("VPet sidebar frame", () => { [60, 22, 44], ] as const) { const setup = await testRender( - () => , + () => , { width, height: 24 }, ) @@ -105,7 +107,7 @@ describe("VPet sidebar frame", () => { [60, 22, 44], ] as const) { const setup = await testRender( - () => , + () => , { width, height: 24 }, ) @@ -122,7 +124,7 @@ describe("VPet sidebar frame", () => { const widths: number[] = [] const setup = await testRender( () => ( - widths.push(width)} @@ -152,7 +154,7 @@ describe("VPet sidebar frame", () => { "Given a %i-column artwork viewport When rendering a right-facing frame Then it pins at origin without disturbing status rows", async (width) => { const setup = await testRender( - () => , + () => , { width, height: 24 }, ) @@ -168,7 +170,7 @@ describe("VPet sidebar frame", () => { test("Given a normal stage at a tight sidebar width When rendering Then it retains the five existing status rows", async () => { const setup = await testRender( - () => , + () => , { width: 16, height: 24 }, ) @@ -186,7 +188,7 @@ describe("VPet sidebar frame", () => { test("Given a frozen stage at a tight sidebar width When rendering Then it truncates on the existing stage row", async () => { const setup = await testRender( () => ( - ({ ...partnerModel(), stage: "Child", @@ -215,7 +217,7 @@ describe("VPet sidebar frame", () => { test("Given localized terminal card models When rendering Then it displays exact configured name-stage pairs and placeholders", async () => { const japaneseSetup = await testRender( () => ( - ({ kind: "partner", name: "Tailmon", @@ -228,6 +230,7 @@ describe("VPet sidebar frame", () => { isTerminal: true, frozen: false, isSetOverride: false, + evolutionBattlePending: false, })} animation={animation(frameOutput(0))} /> @@ -245,7 +248,7 @@ describe("VPet sidebar frame", () => { const englishSetup = await testRender( () => ( - ({ kind: "partner", name: "Gatomon", @@ -258,6 +261,7 @@ describe("VPet sidebar frame", () => { isTerminal: true, frozen: false, isSetOverride: false, + evolutionBattlePending: false, })} animation={animation(frameOutput(0))} /> @@ -277,7 +281,7 @@ describe("VPet sidebar frame", () => { test("Given a terminal partner When rendering Then it shows terminal placeholders instead of check values", async () => { const setup = await testRender( () => ( - ({ kind: "partner", name: "Agumon", @@ -290,6 +294,7 @@ describe("VPet sidebar frame", () => { isTerminal: true, frozen: false, isSetOverride: false, + evolutionBattlePending: false, })} animation={animation(frameOutput(0))} /> @@ -312,7 +317,7 @@ describe("VPet sidebar frame", () => { test("Given a frozen canonical Digitama When rendering Then it appends frozen to the existing stage row without changing status rows", async () => { const setup = await testRender( () => ( - ({ kind: "partner", name: "Digitama", @@ -325,6 +330,7 @@ describe("VPet sidebar frame", () => { isTerminal: false, frozen: true, isSetOverride: false, + evolutionBattlePending: false, })} animation={animation(frameOutput(0))} /> @@ -348,7 +354,7 @@ describe("VPet sidebar frame", () => { test("Given a terminal cheat projection When rendering Then it suppresses frozen and never labels cheat", async () => { const setup = await testRender( () => ( - ({ kind: "partner", name: "Agumon", @@ -361,6 +367,7 @@ describe("VPet sidebar frame", () => { isTerminal: true, frozen: false, isSetOverride: false, + evolutionBattlePending: false, })} animation={animation(frameOutput(0))} /> @@ -386,7 +393,7 @@ describe("VPet sidebar frame", () => { test("Given a valid set-override card When rendering Then it appends set to the existing name row without touching stage or status rows", async () => { const setup = await testRender( () => ( - ({ ...partnerModel(), gauge: 0, @@ -417,7 +424,7 @@ describe("VPet sidebar frame", () => { test("Given a frozen set-override card When rendering Then set and frozen markers stay on their separate name and stage rows", async () => { const setup = await testRender( () => ( - ({ ...partnerModel(), frozen: true, @@ -443,7 +450,7 @@ describe("VPet sidebar frame", () => { test("Given a set-override name at a tight sidebar width When rendering Then it truncates on the existing name row without wrapping", async () => { const setup = await testRender( () => ( - ({ ...partnerModel(), name: "SuperUltimate", @@ -470,7 +477,7 @@ describe("VPet sidebar frame", () => { test("Given no active partner and blank animation When rendering Then the eight-row canvas is blank and its message remains below it", async () => { const setup = await testRender( () => ( - ({ kind: "no_partner", messageLine: "No active partner" })} animation={animation({ kind: "blank", result: { kind: "blank" }, offset: 0, facing: "left" })} /> @@ -498,7 +505,7 @@ describe("VPet sidebar frame", () => { async (_caseName, sprite, message) => { const setup = await testRender( () => ( - ({ kind: "partner", name: "Agumon", @@ -511,6 +518,7 @@ describe("VPet sidebar frame", () => { isTerminal: false, frozen: false, isSetOverride: false, + evolutionBattlePending: false, })} animation={animation({ kind: "unavailable", @@ -541,7 +549,7 @@ describe("VPet sidebar frame", () => { const openedUrls: string[] = [] const setup = await testRender( () => ( - openedUrls.push(url)} @@ -564,7 +572,7 @@ describe("VPet sidebar frame", () => { const openedUrls: string[] = [] const setup = await testRender( () => ( - ({ kind: "no_partner", messageLine: "No active partner" })} animation={animation({ kind: "blank", result: { kind: "blank" }, offset: 0, facing: "left" })} onUrlClick={(url) => openedUrls.push(url)} diff --git a/tests/sidebar-poll-loop.test.ts b/packages/opencode-digital-pet/tests/sidebar-poll-loop.test.ts similarity index 100% rename from tests/sidebar-poll-loop.test.ts rename to packages/opencode-digital-pet/tests/sidebar-poll-loop.test.ts diff --git a/tests/sidebar-state.test.ts b/packages/opencode-digital-pet/tests/sidebar-state.test.ts similarity index 87% rename from tests/sidebar-state.test.ts rename to packages/opencode-digital-pet/tests/sidebar-state.test.ts index 7729c1c..b9689ce 100644 --- a/tests/sidebar-state.test.ts +++ b/packages/opencode-digital-pet/tests/sidebar-state.test.ts @@ -5,13 +5,13 @@ import { join } from "node:path" import { Database } from "bun:sqlite" import { createSqliteSidebarSnapshotReader } from "../src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts" -import { createSqliteVpetRepository } from "../src/adapters/sqlite/sqlite-vpet-write-store.ts" +import { createSqliteDigitalPetRepository } from "../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" import { isBunSqliteAvailable } from "./sqlite-capability.ts" const tempRoots: string[] = [] const createTempRoot = async (): Promise => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-sidebar-")) + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-sidebar-")) tempRoots.push(root) return root } @@ -28,7 +28,7 @@ describe("SQLite sidebar snapshot reader", () => { expect(createSqliteSidebarSnapshotReader({ databasePath }).getSidebarSnapshot()).toBeNull() }) - test("Given a valid SQLite database without VPet tables When reading the sidebar snapshot Then the no-table failure returns null", async () => { + test("Given a valid SQLite database without Digital Pet tables When reading the sidebar snapshot Then the no-table failure returns null", async () => { const root = await createTempRoot() const databasePath = join(root, "empty.db") const result = Bun.spawnSync(["sqlite3", databasePath, "VACUUM"]) @@ -46,11 +46,11 @@ describe("SQLite sidebar snapshot reader", () => { }) test.if(isBunSqliteAvailable)( - "Given a populated VPet database When reading the sidebar snapshot Then it maps the active partner and trainer state", + "Given a populated Digital Pet database When reading the sidebar snapshot Then it maps the active partner and trainer state", async () => { const root = await createTempRoot() const databasePath = join(root, "pet.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) const closeSpy = spyOn(Database.prototype, "close") try { @@ -72,6 +72,8 @@ describe("SQLite sidebar snapshot reader", () => { currentNodeId: partner.currentNodeId, gauge: partner.gauge, isTerminal: partner.isTerminal, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, }), ) @@ -82,6 +84,8 @@ describe("SQLite sidebar snapshot reader", () => { frozen: false, isSetOverride: false, trainerTotalTokens: 100, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }) expect(closeSpy).toHaveBeenCalledTimes(1) } finally { @@ -96,7 +100,7 @@ describe("SQLite sidebar snapshot reader", () => { async () => { const root = await createTempRoot() const databasePath = join(root, "control-normal.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) try { repository.spawnPartner({ @@ -116,6 +120,8 @@ describe("SQLite sidebar snapshot reader", () => { frozen: true, isSetOverride: false, trainerTotalTokens: 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }) } finally { await repository.close() @@ -128,7 +134,7 @@ describe("SQLite sidebar snapshot reader", () => { async () => { const root = await createTempRoot() const databasePath = join(root, "control-cheat.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) try { repository.spawnPartner({ @@ -149,6 +155,8 @@ describe("SQLite sidebar snapshot reader", () => { frozen: false, isSetOverride: true, trainerTotalTokens: 900, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }) const withoutPartner = new Database(databasePath) @@ -162,6 +170,8 @@ describe("SQLite sidebar snapshot reader", () => { frozen: false, isSetOverride: true, trainerTotalTokens: 900, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }) } finally { await repository.close() @@ -174,7 +184,7 @@ describe("SQLite sidebar snapshot reader", () => { async () => { const root = await createTempRoot() const databasePath = join(root, "cheat-only-read.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) try { repository.spawnPartner({ @@ -202,6 +212,8 @@ describe("SQLite sidebar snapshot reader", () => { frozen: false, isSetOverride: true, trainerTotalTokens: 900, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }) const afterDatabase = new Database(databasePath) @@ -228,7 +240,7 @@ describe("SQLite sidebar snapshot reader", () => { async () => { const root = await createTempRoot() const databasePath = join(root, "stale-cheat.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) try { repository.spawnPartner({ @@ -248,6 +260,8 @@ describe("SQLite sidebar snapshot reader", () => { frozen: false, isSetOverride: true, trainerTotalTokens: 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }) const stored = new Database(databasePath) expect(stored.query("SELECT cheat_node_id FROM vpet_control_state WHERE control_id = 1").get()).toEqual({ diff --git a/tests/sidebar-view-model.test.ts b/packages/opencode-digital-pet/tests/sidebar-view-model.test.ts similarity index 81% rename from tests/sidebar-view-model.test.ts rename to packages/opencode-digital-pet/tests/sidebar-view-model.test.ts index bd34682..ae7d374 100644 --- a/tests/sidebar-view-model.test.ts +++ b/packages/opencode-digital-pet/tests/sidebar-view-model.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" -import { DEFAULT_VPET_SETTINGS } from "../src/config/defaults.ts" -import { normalizeVpetSettings } from "../src/config/normalize.ts" -import { buildSidebarCardModel } from "../src/tui/sidebar-view-model.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { normalizeDigitalPetSettings } from "@jcendal/digital-pet-core/config/normalize.ts" +import { buildSidebarCardModel } from "@jcendal/digital-pet-core/view-models/sidebar-view-model.ts" describe("static sidebar model", () => { const partnerInputs = { @@ -21,14 +21,16 @@ describe("static sidebar model", () => { frozen: false, isSetOverride: false, trainerTotalTokens: 25_000, + evolutionBattlePending: false, } as const test("Given default Japanese settings When building the sidebar model Then it renders catalog Japanese names and familiar labels", () => { - const model = buildSidebarCardModel(partnerInputs, DEFAULT_VPET_SETTINGS) + const model = buildSidebarCardModel(partnerInputs, DEFAULT_DIGITAL_PET_SETTINGS) if (model.kind !== "partner") throw new Error("Expected partner sidebar model") expect(Object.keys(model).sort()).toEqual([ + "evolutionBattlePending", "frozen", "gauge", "isSetOverride", @@ -46,7 +48,7 @@ describe("static sidebar model", () => { expect(model.stageNumber).toBe(4) expect(model.url).toBe("https://example.test/tailmon") expect(model.gauge).toBe(25_000) - expect(model.threshold).toBe(7_500_000) + expect(model.threshold).toBe(75_000_000) expect(model.isTerminal).toBe(false) expect(model.frozen).toBe(false) }) @@ -60,7 +62,7 @@ describe("static sidebar model", () => { ...partnerInputs, node: { ...partnerInputs.node, sprite }, }, - DEFAULT_VPET_SETTINGS, + DEFAULT_DIGITAL_PET_SETTINGS, ) if (model.kind !== "partner") throw new Error("Expected partner sidebar model") @@ -74,7 +76,7 @@ describe("static sidebar model", () => { ...partnerInputs, node: { ...partnerInputs.node, stage: 0, sprite: "not-an-egg" }, }, - DEFAULT_VPET_SETTINGS, + DEFAULT_DIGITAL_PET_SETTINGS, ) if (model.kind !== "partner") throw new Error("Expected partner sidebar model") @@ -85,7 +87,7 @@ describe("static sidebar model", () => { }) test("Given a frozen canonical partner When building the sidebar model Then it preserves the frozen display state", () => { - const model = buildSidebarCardModel({ ...partnerInputs, frozen: true }, DEFAULT_VPET_SETTINGS) + const model = buildSidebarCardModel({ ...partnerInputs, frozen: true }, DEFAULT_DIGITAL_PET_SETTINGS) if (model.kind !== "partner") throw new Error("Expected partner sidebar model") @@ -94,7 +96,7 @@ describe("static sidebar model", () => { }) test("Given a set-override cheat partner When building the sidebar model Then it preserves the set display state", () => { - const model = buildSidebarCardModel({ ...partnerInputs, isSetOverride: true }, DEFAULT_VPET_SETTINGS) + const model = buildSidebarCardModel({ ...partnerInputs, isSetOverride: true }, DEFAULT_DIGITAL_PET_SETTINGS) if (model.kind !== "partner") throw new Error("Expected partner sidebar model") @@ -103,7 +105,7 @@ describe("static sidebar model", () => { }) test("Given English settings with a custom threshold When building the sidebar model Then one language selects both English display fields", () => { - const settings = normalizeVpetSettings({ language: "en", stageThresholds: { adult: 777 } }) + const settings = normalizeDigitalPetSettings({ language: "en", stageThresholds: { adult: 777 } }) const model = buildSidebarCardModel(partnerInputs, settings) if (model.kind !== "partner") throw new Error("Expected partner sidebar model") @@ -114,14 +116,14 @@ describe("static sidebar model", () => { }) test("Given invalid settings and a terminal partner When building the sidebar model Then it uses the resolved default presentation values", () => { - const settings = normalizeVpetSettings({ language: "fr", stageThresholds: { adult: 0 } }) + const settings = normalizeDigitalPetSettings({ language: "fr", stageThresholds: { adult: 0 } }) const model = buildSidebarCardModel({ ...partnerInputs, isTerminal: true }, settings) if (model.kind !== "partner") throw new Error("Expected partner sidebar model") expect(model.name).toBe("Tailmon") expect(model.stage).toBe("Adult") - expect(model.threshold).toBe(7_500_000) + expect(model.threshold).toBe(75_000_000) expect(model.isTerminal).toBe(true) }) @@ -130,7 +132,7 @@ describe("static sidebar model", () => { { kind: "no_partner", }, - DEFAULT_VPET_SETTINGS, + DEFAULT_DIGITAL_PET_SETTINGS, ) expect(model).toEqual({ kind: "no_partner", messageLine: "No active partner" }) diff --git a/tests/sqlite-capability.ts b/packages/opencode-digital-pet/tests/sqlite-capability.ts similarity index 100% rename from tests/sqlite-capability.ts rename to packages/opencode-digital-pet/tests/sqlite-capability.ts diff --git a/tests/sqlite-vpet-archive-reader.test.ts b/packages/opencode-digital-pet/tests/sqlite-digital-pet-archive-reader.test.ts similarity index 83% rename from tests/sqlite-vpet-archive-reader.test.ts rename to packages/opencode-digital-pet/tests/sqlite-digital-pet-archive-reader.test.ts index fb28de1..ae01341 100644 --- a/tests/sqlite-vpet-archive-reader.test.ts +++ b/packages/opencode-digital-pet/tests/sqlite-digital-pet-archive-reader.test.ts @@ -5,17 +5,17 @@ import { join } from "node:path" import { Database } from "bun:sqlite" import { - createSqliteVpetArchiveReader, + createSqliteDigitalPetArchiveReader, isRecoverableSqliteReadError, - readSqliteVpetArchive, -} from "../src/adapters/sqlite/sqlite-vpet-archive-reader.ts" -import { createSqliteVpetRepository } from "../src/adapters/sqlite/sqlite-vpet-write-store.ts" + readSqliteDigitalPetArchive, +} from "../src/adapters/sqlite/sqlite-digital-pet-archive-reader.ts" +import { createSqliteDigitalPetRepository } from "../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" import { isBunSqliteAvailable } from "./sqlite-capability.ts" const tempRoots: string[] = [] const createTempRoot = async (): Promise => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-archive-")) + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-archive-")) tempRoots.push(root) return root } @@ -24,13 +24,13 @@ afterEach(async () => { await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) -describe("SQLite VPet archive reader", () => { +describe("SQLite Digital Pet archive reader", () => { test("Given a generic error mentioning sqlite and database When reading an archive Then it rethrows after closing", () => { const error = new Error("sqlite database reader interrupted") let closeCount = 0 expect(() => - readSqliteVpetArchive( + readSqliteDigitalPetArchive( { all: (): never => { throw error @@ -59,7 +59,7 @@ describe("SQLite VPet archive reader", () => { const root = await createTempRoot() const databasePath = join(root, "missing", "pet.db") - expect(createSqliteVpetArchiveReader({ databasePath }).getArchive()).toEqual({ kind: "empty" }) + expect(createSqliteDigitalPetArchiveReader({ databasePath }).getArchive()).toEqual({ kind: "empty" }) }) test.if(isBunSqliteAvailable)( @@ -69,9 +69,9 @@ describe("SQLite VPet archive reader", () => { const databasePath = join(root, "invalid.db") await Bun.write(databasePath, "not a sqlite database") - expect(createSqliteVpetArchiveReader({ databasePath }).getArchive()).toEqual({ + expect(createSqliteDigitalPetArchiveReader({ databasePath }).getArchive()).toEqual({ kind: "unavailable", - message: "VPet archive is unavailable.", + message: "Digital Pet archive is unavailable.", }) }, ) @@ -84,9 +84,9 @@ describe("SQLite VPet archive reader", () => { const result = Bun.spawnSync(["sqlite3", databasePath, "VACUUM"]) expect(result.exitCode).toBe(0) - expect(createSqliteVpetArchiveReader({ databasePath }).getArchive()).toEqual({ + expect(createSqliteDigitalPetArchiveReader({ databasePath }).getArchive()).toEqual({ kind: "unavailable", - message: "VPet archive is unavailable.", + message: "Digital Pet archive is unavailable.", }) }, ) @@ -96,10 +96,10 @@ describe("SQLite VPet archive reader", () => { async () => { const root = await createTempRoot() const databasePath = join(root, "empty-archive.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) try { - expect(createSqliteVpetArchiveReader({ databasePath }).getArchive()).toEqual({ kind: "empty" }) + expect(createSqliteDigitalPetArchiveReader({ databasePath }).getArchive()).toEqual({ kind: "empty" }) } finally { await repository.close() } @@ -111,7 +111,7 @@ describe("SQLite VPet archive reader", () => { async () => { const root = await createTempRoot() const databasePath = join(root, "pet.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) const database = new Database(databasePath) try { @@ -139,7 +139,7 @@ describe("SQLite VPet archive reader", () => { const closeSpy = spyOn(Database.prototype, "close") try { - expect(createSqliteVpetArchiveReader({ databasePath }).getArchive()).toEqual({ + expect(createSqliteDigitalPetArchiveReader({ databasePath }).getArchive()).toEqual({ kind: "available", partners: [ { @@ -181,14 +181,14 @@ describe("SQLite VPet archive reader", () => { async () => { const root = await createTempRoot() const databasePath = join(root, "interrupted-read.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) const querySpy = spyOn(Database.prototype, "query").mockImplementation((): never => { throw new Error("sqlite database reader interrupted") }) const closeSpy = spyOn(Database.prototype, "close") try { - expect(() => createSqliteVpetArchiveReader({ databasePath }).getArchive()).toThrow( + expect(() => createSqliteDigitalPetArchiveReader({ databasePath }).getArchive()).toThrow( "sqlite database reader interrupted", ) expect(closeSpy).toHaveBeenCalledTimes(1) diff --git a/packages/opencode-digital-pet/tests/tsconfig.json b/packages/opencode-digital-pet/tests/tsconfig.json new file mode 100644 index 0000000..f34f24f --- /dev/null +++ b/packages/opencode-digital-pet/tests/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.test.json", + "include": ["./**/*"], + "exclude": ["../node_modules", "../dist"] +} diff --git a/tests/tui-composition-fixture.ts b/packages/opencode-digital-pet/tests/tui-composition-fixture.ts similarity index 94% rename from tests/tui-composition-fixture.ts rename to packages/opencode-digital-pet/tests/tui-composition-fixture.ts index 6dc9e7a..fcfc0ac 100644 --- a/tests/tui-composition-fixture.ts +++ b/packages/opencode-digital-pet/tests/tui-composition-fixture.ts @@ -7,10 +7,11 @@ import type { import type { TuiDialogProps, TuiTheme } from "@opencode-ai/plugin/tui" import type { JSX } from "@opentui/solid" -import type { SidebarCardInputs } from "../src/application/models/sidebar-card-inputs.ts" -import { DEFAULT_VPET_SETTINGS } from "../src/config/defaults.ts" -import type { DigimonStage } from "../src/domain/stage.ts" -import type { MonsterAnimationOutput } from "../src/tui/monster-animation.ts" +import type { SidebarCardInputs } from "@jcendal/digital-pet-core/application/models/sidebar-card-inputs.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import type { DigimonStage } from "@jcendal/digital-pet-core/domain/stage.ts" +import type { MonsterAnimationOutput } from "@jcendal/digital-pet-animation/idle/monster-animation.ts" + import { createTui, type TuiCompositionApi } from "../src/tui.tsx" type TuiCompositionKeymap = NonNullable @@ -55,6 +56,7 @@ export const partnerInputs = (sprite: string, options: PartnerInputOptions = {}) frozen: false, isSetOverride: options.isSetOverride ?? false, trainerTotalTokens: 100, + evolutionBattlePending: false, }) export const settle = async (): Promise => { @@ -232,7 +234,7 @@ export class TuiCompositionHarness { for (const layer of this.layers) { if (!this.#activeLayers.has(layer) || layer.commands === undefined) continue for (const cmd of layer.commands) { - if (cmd.name === name || cmd.slashName === name) { + if (cmd["name"] === name || cmd["slashName"] === name) { if (cmd.run) { type RunFn = (context?: typeof ctx) => void const run = cmd.run as RunFn @@ -253,7 +255,7 @@ export class TuiCompositionHarness { options: { readonly random?: () => number } = {}, ): Promise { await this.startWith( - createTui(loadInputs, DEFAULT_VPET_SETTINGS, { + createTui(loadInputs, DEFAULT_DIGITAL_PET_SETTINGS, { scheduleVisualInterval: this.scheduler.scheduleVisualInterval, schedulePollTimeout: this.scheduler.schedulePollTimeout, nowMs: this.scheduler.nowMs, @@ -287,7 +289,7 @@ export class TuiCompositionHarness { slots: { register: (plugin: Parameters[0]) => { this.slot = plugin.slots.sidebar_content - return "opencode-vpet" + return "opencode-digital-pet" }, }, keymap: this.keymap, diff --git a/tests/tui-composition.test.ts b/packages/opencode-digital-pet/tests/tui-composition.test.ts similarity index 90% rename from tests/tui-composition.test.ts rename to packages/opencode-digital-pet/tests/tui-composition.test.ts index 9bbf027..d141ea3 100644 --- a/tests/tui-composition.test.ts +++ b/packages/opencode-digital-pet/tests/tui-composition.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from "bun:test" import { testRender } from "@opentui/solid" -import type { SidebarCardInputs } from "../src/application/models/sidebar-card-inputs.ts" -import type { VpetArchiveResult } from "../src/application/models/vpet-archive.ts" -import type { VpetArchiveReader } from "../src/application/ports/vpet-archive.ts" -import { DEFAULT_VPET_SETTINGS } from "../src/config/defaults.ts" -import { MONSTER_FRAME_CATALOG } from "../src/data/monster-frame-catalog.ts" +import type { SidebarCardInputs } from "@jcendal/digital-pet-core/application/models/sidebar-card-inputs.ts" +import type { DigitalPetArchiveResult } from "@jcendal/digital-pet-core/application/models/digital-pet-archive.ts" +import type { DigitalPetArchiveReader } from "@jcendal/digital-pet-core/application/ports/digital-pet-archive.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" import { createTui } from "../src/tui.tsx" import { messagePartUpdated, @@ -49,7 +49,7 @@ const sleepAtThreshold = (harness: TuiCompositionHarness, now: number): void => expect(lastKind(harness)).toBe("sleeping") } -const archiveWithAgumon: VpetArchiveResult = { +const archiveWithAgumon: DigitalPetArchiveResult = { kind: "available", partners: [ { @@ -71,34 +71,34 @@ const archiveWithAgumon: VpetArchiveResult = { describe("TUI composition", () => { test("Given an archive reader When the Dex and history commands run Then they register palette metadata, reread, replace one dialog, clear on close, and become inert after disposal", async () => { let reads = 0 - const archiveReader: VpetArchiveReader = { + const archiveReader: DigitalPetArchiveReader = { getArchive: () => { reads += 1 return archiveWithAgumon }, } const harness = new TuiCompositionHarness() - const compose = createTui(() => partnerInputs("agumon"), DEFAULT_VPET_SETTINGS, { archiveReader }) + const compose = createTui(() => partnerInputs("agumon"), DEFAULT_DIGITAL_PET_SETTINGS, { archiveReader }) await harness.startWith(compose) const layer = harness.layers[0] const commands = layer?.commands - if (commands === undefined) throw new Error("Expected VPet command layer") + if (commands === undefined) throw new Error("Expected Digital Pet command layer") expect( commands.map((command) => ({ - name: command.name, - slashName: command.slashName, - namespace: command.namespace, - category: command.category, + name: command["name"], + slashName: command["slashName"], + namespace: command["namespace"], + category: command["category"], })), ).toEqual([ - { name: "vpet.dex", slashName: "vpet-dex", namespace: "palette", category: "VPet" }, - { name: "vpet.history", slashName: "vpet-history", namespace: "palette", category: "VPet" }, + { name: "digital-pet.dex", slashName: "digital-pet-dex", namespace: "palette", category: "Digital Pet" }, + { name: "digital-pet.history", slashName: "digital-pet-history", namespace: "palette", category: "Digital Pet" }, ]) - harness.invokeCommand("vpet-dex") - harness.invokeCommand("vpet.history") + harness.invokeCommand("digital-pet-dex") + harness.invokeCommand("digital-pet.history") expect(reads).toBe(2) expect(harness.dialogStack.replaceCount).toBe(2) @@ -109,26 +109,26 @@ describe("TUI composition", () => { close() expect(harness.dialogStack.clearCount).toBe(1) - harness.invokeCommand("vpet-history") + harness.invokeCommand("digital-pet-history") expect(reads).toBe(3) expect(harness.dialogStack.replaceCount).toBe(3) await harness.dispose?.() - harness.invokeCommand("vpet.dex") + harness.invokeCommand("digital-pet.dex") expect(reads).toBe(3) expect(harness.dialogStack.replaceCount).toBe(3) }) test("Given an archive reader that throws When either archive command runs Then it opens one safe unavailable dialog instead of rejecting", async () => { - const archiveReader: VpetArchiveReader = { + const archiveReader: DigitalPetArchiveReader = { getArchive: () => { throw new Error("reader interrupted") }, } const harness = new TuiCompositionHarness() - await harness.startWith(createTui(() => partnerInputs("agumon"), DEFAULT_VPET_SETTINGS, { archiveReader })) + await harness.startWith(createTui(() => partnerInputs("agumon"), DEFAULT_DIGITAL_PET_SETTINGS, { archiveReader })) - harness.invokeCommand("vpet.dex") + harness.invokeCommand("digital-pet.dex") expect(harness.dialogStack.replaceCount).toBe(1) expect(harness.dialogStack.closeCallbacks).toHaveLength(1) await harness.dispose?.() @@ -140,8 +140,8 @@ describe("TUI composition", () => { expect(harness.layers).toHaveLength(1) const layer = harness.layers[0] - expect(layer?.name).toBe("opencode-vpet.layer") - expect(layer?.namespace).toBe("opencode-vpet") + expect(layer?.["name"]).toBe("opencode-digital-pet.layer") + expect(layer?.["namespace"]).toBe("opencode-digital-pet") expect(layer?.commands).toEqual([]) let testCommandRun = 0 @@ -281,7 +281,7 @@ describe("TUI composition", () => { await harness.dispose?.() }) - test("Given project-wide activity events When parent and child sessions wake a sleeping partner Then all accepted events are equivalent and idle is inert", async () => { + test("Given project-wide activity events When parent and child sessions wake a sleeping partner Then all accepted events are equivalent and idle plays feed", async () => { const harness = new TuiCompositionHarness() await harness.start(() => partnerInputs("agumon")) const accepted = [ @@ -304,8 +304,8 @@ describe("TUI composition", () => { harness.eventBus.emit(sessionStatus("parent", { type: "idle" })) - expect(lastKind(harness)).toBe("sleeping") - expect(harness.renderRequests).toBe(rendersBeforeIdle) + expect(lastKind(harness)).toBe("action") + expect(harness.renderRequests).toBe(rendersBeforeIdle + 1) expect(harness.eventBus.subscriptions).toEqual(["message.updated", "message.part.updated", "session.status"]) await harness.dispose?.() }) @@ -390,6 +390,7 @@ describe("TUI composition", () => { expect(loadCalls).toBe(2) resolveRefresh?.() await settle() + await settle() await setup.renderOnce() expect(loadCalls).toBe(3) @@ -440,16 +441,16 @@ describe("TUI composition", () => { test("Given an open dialog When the harness is disposed Then it handles it without errors", async () => { let _reads = 0 - const archiveReader: VpetArchiveReader = { + const archiveReader: DigitalPetArchiveReader = { getArchive: () => { _reads += 1 return archiveWithAgumon }, } const harness = new TuiCompositionHarness() - await harness.startWith(createTui(() => partnerInputs("agumon"), DEFAULT_VPET_SETTINGS, { archiveReader })) + await harness.startWith(createTui(() => partnerInputs("agumon"), DEFAULT_DIGITAL_PET_SETTINGS, { archiveReader })) - harness.invokeCommand("vpet-dex") + harness.invokeCommand("digital-pet-dex") expect(harness.dialogStack.renders).toHaveLength(1) await harness.dispose?.() diff --git a/tests/tui-settings-composition.test.ts b/packages/opencode-digital-pet/tests/tui-settings-composition.test.ts similarity index 80% rename from tests/tui-settings-composition.test.ts rename to packages/opencode-digital-pet/tests/tui-settings-composition.test.ts index 1c1c2f5..4740c3c 100644 --- a/tests/tui-settings-composition.test.ts +++ b/packages/opencode-digital-pet/tests/tui-settings-composition.test.ts @@ -7,12 +7,12 @@ import { testRender } from "@opentui/solid" import type { JSX } from "@opentui/solid" import { createSqliteSidebarSnapshotReader } from "../src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts" -import type { SidebarCardInputs } from "../src/application/models/sidebar-card-inputs.ts" -import type { VpetArchiveReader } from "../src/application/ports/vpet-archive.ts" -import { getSidebarCardInputs } from "../src/application/use-cases/get-sidebar-card-inputs.ts" -import { DEFAULT_VPET_SETTINGS } from "../src/config/defaults.ts" -import { loadGlobalVpetSettings } from "../src/config/global-vpet-settings.ts" -import { DIGIMON_CATALOG } from "../src/data/catalog.ts" +import type { SidebarCardInputs } from "@jcendal/digital-pet-core/application/models/sidebar-card-inputs.ts" +import type { DigitalPetArchiveReader } from "@jcendal/digital-pet-core/application/ports/digital-pet-archive.ts" +import { getSidebarCardInputs } from "@jcendal/digital-pet-core/application/use-cases/get-sidebar-card-inputs.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { loadGlobalDigitalPetSettings } from "../src/config/global-digital-pet-settings.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" import { createTui } from "../src/tui.tsx" import { TuiCompositionHarness } from "./tui-composition-fixture.ts" @@ -24,7 +24,7 @@ const event = { on: () => () => undefined } describe("TUI settings composition", () => { test("Given English startup settings When the archive command opens after configuration changes Then its history retains startup localization", async () => { - const archiveReader: VpetArchiveReader = { + const archiveReader: DigitalPetArchiveReader = { getArchive: () => ({ kind: "available", partners: [ @@ -38,19 +38,19 @@ describe("TUI settings composition", () => { ], }), } - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-history-settings-")) + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-history-settings-")) const configDirectory = join(root, ".config") - const configPath = join(configDirectory, "opencode-vpet.json") + const configPath = join(configDirectory, "opencode-digital-pet.json") const harness = new TuiCompositionHarness() try { await mkdir(configDirectory) await writeFile(configPath, JSON.stringify({ language: "en" })) - const settings = await loadGlobalVpetSettings({ home: root }) + const settings = await loadGlobalDigitalPetSettings({ home: root }) await harness.startWith(createTui(() => ({ kind: "no_partner" }), settings, { archiveReader })) await writeFile(configPath, JSON.stringify({ language: "jp" })) - harness.invokeCommand("vpet-history") + harness.invokeCommand("digital-pet-history") const dialog = harness.dialogStack.renders[0] if (dialog === undefined) throw new Error("Expected history dialog render") const setup = await testRender(() => dialog(), { width: 80, height: 24 }) @@ -82,13 +82,14 @@ describe("TUI settings composition", () => { isTerminal: false, frozen: false, isSetOverride: false, + evolutionBattlePending: false, trainerTotalTokens: 100, } let slot: (() => unknown) | undefined let dispose: (() => void | Promise) | undefined let renderRequests = 0 - const tui = createTui(() => inputs, DEFAULT_VPET_SETTINGS) + const tui = createTui(() => inputs, DEFAULT_DIGITAL_PET_SETTINGS) await tui({ event, @@ -106,7 +107,7 @@ describe("TUI settings composition", () => { slots: { register: (plugin) => { slot = plugin.slots.sidebar_content - return "opencode-vpet" + return "opencode-digital-pet" }, }, }) @@ -119,8 +120,8 @@ describe("TUI settings composition", () => { }) test("Given an English startup config When its file changes after initialization Then every poll retains the original sidebar projection", async () => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-tui-config-")) - const configPath = join(root, ".config", "opencode-vpet.json") + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-tui-config-")) + const configPath = join(root, ".config", "opencode-digital-pet.json") const inputs: SidebarCardInputs = { kind: "partner", node: { @@ -136,6 +137,7 @@ describe("TUI settings composition", () => { isTerminal: false, frozen: false, isSetOverride: false, + evolutionBattlePending: false, trainerTotalTokens: 777, } let slot: (() => JSX.Element) | undefined @@ -148,7 +150,7 @@ describe("TUI settings composition", () => { configPath, JSON.stringify({ language: "en", notifications: false, stageThresholds: { adult: 777 } }), ) - const settings = await loadGlobalVpetSettings({ home: root }) + const settings = await loadGlobalDigitalPetSettings({ home: root }) expect(settings.notifications).toBeFalse() const tui = createTui(() => { loadCalls += 1 @@ -167,7 +169,7 @@ describe("TUI settings composition", () => { slots: { register: (plugin) => { slot = plugin.slots.sidebar_content - return "opencode-vpet" + return "opencode-digital-pet" }, }, }) @@ -194,25 +196,25 @@ describe("TUI settings composition", () => { }) test.each([ - ["missing", async (_configPath: string) => undefined], + ["missing", async (_configPath: string): Promise => undefined], [ "invalid", - async (configPath: string) => + async (configPath: string): Promise => writeFile(configPath, JSON.stringify({ language: "fr", stageThresholds: { adult: 0 } })), ], ] as const)( "Given a %s startup config When the TUI composes its snapshot Then it renders JP defaults", async (_name, prepareConfig) => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-tui-defaults-")) - const configPath = join(root, ".config", "opencode-vpet.json") + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-tui-defaults-")) + const configPath = join(root, ".config", "opencode-digital-pet.json") let dispose: (() => void | Promise) | undefined let slot: (() => JSX.Element) | undefined try { await mkdir(join(root, ".config")) await prepareConfig(configPath) - const settings = await loadGlobalVpetSettings({ home: root }) - expect(settings).toEqual(DEFAULT_VPET_SETTINGS) + const settings = await loadGlobalDigitalPetSettings({ home: root }) + expect(settings).toEqual(DEFAULT_DIGITAL_PET_SETTINGS) const tui = createTui( () => ({ kind: "partner", @@ -225,11 +227,12 @@ describe("TUI settings composition", () => { stage: 4, url: "https://example.test/tailmon", }, - gauge: 7_500_000, + gauge: 75_000_000, isTerminal: false, frozen: false, isSetOverride: false, - trainerTotalTokens: 7_500_000, + evolutionBattlePending: false, + trainerTotalTokens: 75_000_000, }), settings, ) @@ -246,7 +249,7 @@ describe("TUI settings composition", () => { slots: { register: (plugin) => { slot = plugin.slots.sidebar_content - return "opencode-vpet" + return "opencode-digital-pet" }, }, }) @@ -259,7 +262,7 @@ describe("TUI settings composition", () => { expect(frame).toContain("Tailmon") expect(frame).toContain("Adult") - expect(frame).toContain("7,500,000/7,500,000") + expect(frame).toContain("75,000,000/75,000,000") expect(frame).not.toContain("Gatomon") expect(frame).not.toContain("Champion") setup.renderer.destroy() @@ -271,7 +274,7 @@ describe("TUI settings composition", () => { ) test("Given a missing database at startup When the independently composed reader and query load Then the TUI renders no partner without creating it", async () => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-tui-")) + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-tui-")) const databasePath = join(root, "missing", "pet.db") let slot: (() => unknown) | undefined let dispose: (() => void | Promise) | undefined @@ -279,7 +282,7 @@ describe("TUI settings composition", () => { try { const reader = createSqliteSidebarSnapshotReader({ databasePath }) - const tui = createTui(() => getSidebarCardInputs(reader, DIGIMON_CATALOG), DEFAULT_VPET_SETTINGS) + const tui = createTui(() => getSidebarCardInputs(reader, DIGIMON_CATALOG), DEFAULT_DIGITAL_PET_SETTINGS) await tui({ event, @@ -297,7 +300,7 @@ describe("TUI settings composition", () => { slots: { register: (plugin) => { slot = plugin.slots.sidebar_content - return "opencode-vpet" + return "opencode-digital-pet" }, }, }) @@ -322,7 +325,7 @@ describe("TUI settings composition", () => { resolveQuery = resolve }) return { kind: "no_partner" } - }, DEFAULT_VPET_SETTINGS) + }, DEFAULT_DIGITAL_PET_SETTINGS) await tui({ event, @@ -337,7 +340,7 @@ describe("TUI settings composition", () => { return () => undefined }, }, - slots: { register: () => "opencode-vpet" }, + slots: { register: () => "opencode-digital-pet" }, }) await dispose?.() resolveQuery?.() diff --git a/tests/usage-event-ingestion.test.ts b/packages/opencode-digital-pet/tests/usage-event-ingestion.test.ts similarity index 91% rename from tests/usage-event-ingestion.test.ts rename to packages/opencode-digital-pet/tests/usage-event-ingestion.test.ts index 0016870..458290b 100644 --- a/tests/usage-event-ingestion.test.ts +++ b/packages/opencode-digital-pet/tests/usage-event-ingestion.test.ts @@ -4,10 +4,13 @@ import type { AssistantMessage, Event, Message, UserMessage } from "@opencode-ai import { toCompletedUsageFromEvent, toCompletedUsageFromMessage } from "../src/adapters/opencode/usage-event-mapper.ts" import { createServerHooks } from "../src/adapters/opencode/create-server-hooks.ts" import { createSessionMessagesFetcher } from "../src/adapters/opencode/session-messages.ts" -import type { ApplyUsageReceiptOutcome, UsageReceiptMetadata } from "../src/application/models/usage.ts" -import type { PartnerLifecycle } from "../src/application/ports/partner-lifecycle.ts" -import type { UsageLedger } from "../src/application/ports/usage-ledger.ts" -import type { VpetControl } from "../src/application/ports/vpet-control.ts" +import type { + ApplyUsageReceiptOutcome, + UsageReceiptMetadata, +} from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { PartnerLifecycle } from "@jcendal/digital-pet-core/application/ports/partner-lifecycle.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" const completedAssistantEvent = { type: "message.updated", @@ -143,6 +146,8 @@ describe("usage event ingestion", () => { isTerminal: false, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), applyUsageReceipt(receipt: UsageReceiptMetadata): ApplyUsageReceiptOutcome { appliedReceipts.push(receipt) @@ -151,7 +156,7 @@ describe("usage event ingestion", () => { freeze: () => ({ kind: "already_frozen" as const }), unfreeze: () => ({ kind: "already_unfrozen" as const }), setCheatNode: (cheatNodeId: string) => ({ kind: "already_set" as const, cheatNodeId }), - } satisfies PartnerLifecycle & UsageLedger & VpetControl + } satisfies PartnerLifecycle & UsageLedger & DigitalPetControl const hooks = createServerHooks({ repository, resource: { close: async () => {} }, evolutionSelector: () => 0 }) const handleEvent = hooks.event @@ -173,6 +178,8 @@ describe("usage event ingestion", () => { isTerminal: false, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), applyUsageReceipt(receipt: UsageReceiptMetadata): ApplyUsageReceiptOutcome { appliedReceipts.push(receipt) @@ -181,7 +188,7 @@ describe("usage event ingestion", () => { freeze: () => ({ kind: "already_frozen" as const }), unfreeze: () => ({ kind: "already_unfrozen" as const }), setCheatNode: (cheatNodeId: string) => ({ kind: "already_set" as const, cheatNodeId }), - } satisfies PartnerLifecycle & UsageLedger & VpetControl + } satisfies PartnerLifecycle & UsageLedger & DigitalPetControl const fetchMessages = createSessionMessagesFetcher({ session: { async messages({ path }) { diff --git a/tests/usage-event-processing-time.test.ts b/packages/opencode-digital-pet/tests/usage-event-processing-time.test.ts similarity index 82% rename from tests/usage-event-processing-time.test.ts rename to packages/opencode-digital-pet/tests/usage-event-processing-time.test.ts index 4f90a33..eb738a3 100644 --- a/tests/usage-event-processing-time.test.ts +++ b/packages/opencode-digital-pet/tests/usage-event-processing-time.test.ts @@ -2,10 +2,13 @@ import { describe, expect, test } from "bun:test" import type { Event, Message } from "@opencode-ai/sdk" import { createServerHooks } from "../src/adapters/opencode/create-server-hooks.ts" -import type { ApplyUsageReceiptOutcome, UsageReceiptMetadata } from "../src/application/models/usage.ts" -import type { PartnerLifecycle } from "../src/application/ports/partner-lifecycle.ts" -import type { UsageLedger } from "../src/application/ports/usage-ledger.ts" -import type { VpetControl } from "../src/application/ports/vpet-control.ts" +import type { + ApplyUsageReceiptOutcome, + UsageReceiptMetadata, +} from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { PartnerLifecycle } from "@jcendal/digital-pet-core/application/ports/partner-lifecycle.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" const completedAssistantEvent = { type: "message.updated", @@ -44,6 +47,8 @@ describe("usage event processing time", () => { isTerminal: false, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), applyUsageReceipt(receipt: UsageReceiptMetadata): ApplyUsageReceiptOutcome { appliedModes.push({ receiptKey: receipt.receiptKey, mode: processingMode }) @@ -52,7 +57,7 @@ describe("usage event processing time", () => { freeze: () => ({ kind: "already_frozen" as const }), unfreeze: () => ({ kind: "already_unfrozen" as const }), setCheatNode: (cheatNodeId: string) => ({ kind: "already_set" as const, cheatNodeId }), - } satisfies PartnerLifecycle & UsageLedger & VpetControl + } satisfies PartnerLifecycle & UsageLedger & DigitalPetControl const fetchMessages = async (): Promise => [ completedAssistantMessage("message-idle-processing-time", 300), ] diff --git a/tsconfig.build.json b/packages/opencode-digital-pet/tsconfig.build.json similarity index 65% rename from tsconfig.build.json rename to packages/opencode-digital-pet/tsconfig.build.json index 2d0bd49..7e13ec8 100644 --- a/tsconfig.build.json +++ b/packages/opencode-digital-pet/tsconfig.build.json @@ -2,9 +2,10 @@ "extends": "./tsconfig.json", "compilerOptions": { "declaration": true, - "declarationMap": true, + "declarationMap": false, "emitDeclarationOnly": true, "noEmit": false }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/dev"] } diff --git a/packages/opencode-digital-pet/tsconfig.digital-pet-animation-declarations.json b/packages/opencode-digital-pet/tsconfig.digital-pet-animation-declarations.json new file mode 100644 index 0000000..ea030ef --- /dev/null +++ b/packages/opencode-digital-pet/tsconfig.digital-pet-animation-declarations.json @@ -0,0 +1,12 @@ +{ + "extends": "../digital-pet-animation/tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "noEmit": false, + "outDir": "dist/digital-pet-animation", + "rootDir": "build/digital-pet-animation-src" + }, + "include": ["build/digital-pet-animation-src"] +} diff --git a/packages/opencode-digital-pet/tsconfig.digital-pet-core-declarations.json b/packages/opencode-digital-pet/tsconfig.digital-pet-core-declarations.json new file mode 100644 index 0000000..dd97a3d --- /dev/null +++ b/packages/opencode-digital-pet/tsconfig.digital-pet-core-declarations.json @@ -0,0 +1,12 @@ +{ + "extends": "../digital-pet-core/tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "noEmit": false, + "outDir": "dist/digital-pet-core", + "rootDir": "build/digital-pet-core-src" + }, + "include": ["build/digital-pet-core-src"] +} diff --git a/packages/opencode-digital-pet/tsconfig.json b/packages/opencode-digital-pet/tsconfig.json new file mode 100644 index 0000000..cad9cc8 --- /dev/null +++ b/packages/opencode-digital-pet/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "types": ["bun", "node"], + "jsx": "react-jsx", + "jsxImportSource": "@opentui/solid", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/opencode-digital-pet/tsconfig.scripts.json b/packages/opencode-digital-pet/tsconfig.scripts.json new file mode 100644 index 0000000..b36d54c --- /dev/null +++ b/packages/opencode-digital-pet/tsconfig.scripts.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "target": "ESNext", + "lib": ["ESNext"], + "types": ["bun", "node"], + "noEmit": true, + "rootDir": "scripts", + "skipLibCheck": true + }, + "include": ["scripts/**/*.ts"] +} diff --git a/tsconfig.test.json b/packages/opencode-digital-pet/tsconfig.test.json similarity index 54% rename from tsconfig.test.json rename to packages/opencode-digital-pet/tsconfig.test.json index 5f3ad31..aadf5af 100644 --- a/tsconfig.test.json +++ b/packages/opencode-digital-pet/tsconfig.test.json @@ -2,8 +2,10 @@ "extends": "./tsconfig.json", "compilerOptions": { "noEmit": true, + "rootDir": ".", "jsx": "react-jsx", "jsxImportSource": "@opentui/solid" }, - "include": ["src", "tests"] + "include": ["src", "tests"], + "exclude": ["node_modules", "dist", "tests/release-metadata.test.ts"] } diff --git a/scripts/build.ts b/scripts/build.ts deleted file mode 100644 index e81b42e..0000000 --- a/scripts/build.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { rm } from "node:fs/promises" - -await rm("dist", { recursive: true, force: true }) - -const pluginResult = await Bun.build({ - entrypoints: ["./src/index.ts", "./src/tui.tsx"], - outdir: "./dist", - target: "bun", - format: "esm", - splitting: false, - external: [ - "@opencode-ai/plugin", - "@opencode-ai/plugin/tui", - "@opencode-ai/sdk", - "@opencode-ai/sdk/v2", - "@opentui/core", - "@opentui/keymap", - "@opentui/solid", - "solid-js", - ], - sourcemap: "linked", -}) - -const cliResult = await Bun.build({ - entrypoints: ["./src/cli.ts"], - outdir: "./dist", - target: "node", - format: "esm", - splitting: false, - sourcemap: "linked", -}) - -if (!pluginResult.success || !cliResult.success) { - await rm("dist", { recursive: true, force: true }) - throw new Error("Build failed.") -} - -const declarations = Bun.spawnSync(["bunx", "tsc", "-p", "tsconfig.build.json"]) -if (declarations.exitCode !== 0) throw new Error(new TextDecoder().decode(declarations.stderr)) diff --git a/scripts/check-workspace-dependencies.mjs b/scripts/check-workspace-dependencies.mjs new file mode 100644 index 0000000..edb633d --- /dev/null +++ b/scripts/check-workspace-dependencies.mjs @@ -0,0 +1,30 @@ +import { readFile } from "node:fs/promises" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +import semver from "semver" + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const workspaces = ["digital-pet-core", "digital-pet-animation", "opencode-digital-pet", "cursor-digital-pet"] +const manifests = new Map() + +for (const workspace of workspaces) { + const path = resolve(root, "packages", workspace, "package.json") + const manifest = JSON.parse(await readFile(path, "utf8")) + if (semver.valid(manifest.version) !== manifest.version) { + throw new Error(`${workspace} must have an exact SemVer version.`) + } + manifests.set(manifest.name, manifest) +} + +for (const manifest of manifests.values()) { + for (const [name, version] of Object.entries(manifest.dependencies ?? {})) { + const internal = manifests.get(name) + if (internal?.private !== true) continue + if (version !== internal.version) { + throw new Error(`${manifest.name} depends on ${name}@${version}, but the workspace is ${internal.version}.`) + } + } +} + +console.log("Workspace dependency versions match their private packages.") diff --git a/scripts/migrate-imports.mjs b/scripts/migrate-imports.mjs new file mode 100644 index 0000000..bff98ef --- /dev/null +++ b/scripts/migrate-imports.mjs @@ -0,0 +1,51 @@ +import { readdir, readFile, writeFile } from "node:fs/promises" +import { join } from "node:path" + +const ROOT = join(import.meta.dirname, "..", "packages", "opencode-digital-pet") + +const REPLACEMENTS = [ + [/from "(\.\.\/)+domain\//g, 'from "@jcendal/digital-pet-core/domain/'], + [/from "\.\/domain\//g, 'from "@jcendal/digital-pet-core/domain/'], + [/from "(\.\.\/)+application\//g, 'from "@jcendal/digital-pet-core/application/'], + [/from "\.\/application\//g, 'from "@jcendal/digital-pet-core/application/'], + [/from "(\.\.\/)+data\//g, 'from "@jcendal/digital-pet-core/data/'], + [/from "\.\/data\//g, 'from "@jcendal/digital-pet-core/data/'], + [/from "(\.\.\/)+config\/types\.ts"/g, 'from "@jcendal/digital-pet-core/config/types.ts"'], + [/from "\.\/config\/types\.ts"/g, 'from "@jcendal/digital-pet-core/config/types.ts"'], + [/from "(\.\.\/)+config\/defaults\.ts"/g, 'from "@jcendal/digital-pet-core/config/defaults.ts"'], + [/from "\.\/config\/defaults\.ts"/g, 'from "@jcendal/digital-pet-core/config/defaults.ts"'], + [/from "(\.\.\/)+config\/normalize\.ts"/g, 'from "@jcendal/digital-pet-core/config/normalize.ts"'], + [/from "\.\/config\/normalize\.ts"/g, 'from "@jcendal/digital-pet-core/config/normalize.ts"'], + [/from "\.\/sidebar-view-model\.ts"/g, 'from "@jcendal/digital-pet-core/view-models/sidebar-view-model.ts"'], + [/from "\.\/dex-view-model\.ts"/g, 'from "@jcendal/digital-pet-core/view-models/dex-view-model.ts"'], + [/from "\.\/history-view-model\.ts"/g, 'from "@jcendal/digital-pet-core/view-models/history-view-model.ts"'], + [/from "\.\.\/tui\/sidebar-view-model\.ts"/g, 'from "@jcendal/digital-pet-core/view-models/sidebar-view-model.ts"'], + [/from "\.\.\/tui\/dex-view-model\.ts"/g, 'from "@jcendal/digital-pet-core/view-models/dex-view-model.ts"'], + [/from "\.\.\/tui\/history-view-model\.ts"/g, 'from "@jcendal/digital-pet-core/view-models/history-view-model.ts"'], + [/from "\.\.\/src\/domain\//g, 'from "@jcendal/digital-pet-core/domain/'], + [/from "\.\.\/\.\.\/src\/domain\//g, 'from "@jcendal/digital-pet-core/domain/'], + [/from "\.\.\/src\/application\//g, 'from "@jcendal/digital-pet-core/application/'], + [/from "\.\.\/\.\.\/src\/application\//g, 'from "@jcendal/digital-pet-core/application/'], + [/from "\.\.\/src\/data\//g, 'from "@jcendal/digital-pet-core/data/'], + [/from "\.\.\/\.\.\/src\/data\//g, 'from "@jcendal/digital-pet-core/data/'], + [/from "\.\.\/src\/data\/digimon-data"/g, 'from "@jcendal/digital-pet-core/data/digimon-data.ts"'], +] + +const walk = async (dir) => { + const entries = await readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + const path = join(dir, entry.name) + if (entry.isDirectory()) await walk(path) + else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) { + let content = await readFile(path, "utf8") + for (const [pattern, replacement] of REPLACEMENTS) { + content = content.replace(pattern, replacement) + } + await writeFile(path, content) + } + } +} + +await walk(join(ROOT, "src")) +await walk(join(ROOT, "tests")) +console.log("Import migration complete") diff --git a/scripts/prepack.ts b/scripts/prepack.ts deleted file mode 100644 index 1de84fa..0000000 --- a/scripts/prepack.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { resolve } from "node:path" - -type JsonObject = { readonly [key: string]: unknown } - -const isJsonObject = (value: unknown): value is JsonObject => - typeof value === "object" && value !== null && !Array.isArray(value) - -const collectPaths = (value: unknown, paths: Set): void => { - if (typeof value === "string") { - paths.add(value) - return - } - if (!isJsonObject(value)) return - for (const nestedValue of Object.values(value)) collectPaths(nestedValue, paths) -} - -await Bun.$`bun run build` - -const packageRoot = resolve(import.meta.dir, "..") -const packageJson: unknown = JSON.parse(await Bun.file(resolve(packageRoot, "package.json")).text()) -if (!isJsonObject(packageJson)) throw new Error("package.json must contain an object.") - -const publishedPaths = new Set() -collectPaths(packageJson.main, publishedPaths) -collectPaths(packageJson.types, publishedPaths) -collectPaths(packageJson.exports, publishedPaths) -collectPaths(packageJson.bin, publishedPaths) - -for (const publishedPath of publishedPaths) { - const artifactPath = resolve(packageRoot, publishedPath) - const artifact = Bun.file(artifactPath) - if (!(await artifact.exists()) || artifact.size === 0) { - throw new Error(`Published artifact is missing or empty: ${publishedPath}`) - } -} - -const binPaths = new Set() -collectPaths(packageJson.bin, binPaths) -const cliPath = binPaths.values().next().value -if (typeof cliPath !== "string") throw new Error("Package metadata does not define a bin path.") - -const cliResult = Bun.spawnSync(["node", resolve(packageRoot, cliPath), "--help"], { - stdout: "inherit", - stderr: "inherit", -}) -if (cliResult.exitCode !== 0) throw new Error(`Node CLI help failed: ${cliPath}`) diff --git a/scripts/release-metadata.ts b/scripts/release-metadata.ts index 5fcbdf7..5813168 100644 --- a/scripts/release-metadata.ts +++ b/scripts/release-metadata.ts @@ -10,7 +10,9 @@ type ReleaseMetadataCliOptions = { readonly bump: ReleaseBump readonly changelogPath: string readonly date: string + readonly legacyTagPrefix?: string readonly packageJsonPath: string + readonly tagPrefix: string } type JsonObject = { @@ -44,14 +46,19 @@ const parseReleaseBump = (value: string | undefined): ReleaseBump => { return value } -const getStableTagVersions = (tags: readonly string[]): readonly string[] => +const getStableTagVersions = (tags: readonly string[], prefix: string): readonly string[] => tags.flatMap((tag) => { - const match = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(tag) - return match === null ? [] : [`${match[1]}.${match[2]}.${match[3]}`] + if (!tag.startsWith(prefix)) return [] + const version = tag.slice(prefix.length) + return semver.valid(version) === version && semver.prerelease(version) === null ? [version] : [] }) -const getLatestStableVersion = (tags: readonly string[]): string | undefined => - semver.rsort([...getStableTagVersions(tags)])[0] +const getLatestStableVersion = (tags: readonly string[], prefix: string): string | undefined => + semver.rsort([...getStableTagVersions(tags, prefix)])[0] + +const getReleaseLineVersion = (tags: readonly string[], prefix: string, legacyPrefix?: string): string | undefined => + getLatestStableVersion(tags, prefix) ?? + (legacyPrefix === undefined ? undefined : getLatestStableVersion(tags, legacyPrefix)) const parsePackageMetadata = (source: string): PackageMetadata => { const value: unknown = JSON.parse(source) @@ -80,15 +87,18 @@ const parseCliOptions = (arguments_: readonly string[]): ReleaseMetadataCliOptio const packageJsonPath = values.get("--package-json") const changelogPath = values.get("--changelog") const date = values.get("--date") + const tagPrefix = values.get("--tag-prefix") ?? "v" + const legacyTagPrefix = values.get("--legacy-tag-prefix") if (packageJsonPath === undefined || changelogPath === undefined || date === undefined) { throw new Error("Expected --package-json, --changelog, and --date.") } + if (tagPrefix.length === 0 || legacyTagPrefix === "") throw new Error("Tag prefixes must not be empty.") - return { bump, changelogPath, date, packageJsonPath } + return { bump, changelogPath, date, legacyTagPrefix, packageJsonPath, tagPrefix } } -export const calculateReleaseVersion = (tags: readonly string[], bump: ReleaseBump): string => { - const latestVersion = getLatestStableVersion(tags) ?? "0.0.0" +export const calculateReleaseVersion = (tags: readonly string[], bump: ReleaseBump, prefix = "v"): string => { + const latestVersion = getLatestStableVersion(tags, prefix) ?? "0.0.0" const releaseVersion = semver.inc(latestVersion, bump) if (releaseVersion === null) throw new Error(`Cannot calculate ${bump} release from ${latestVersion}.`) return releaseVersion @@ -96,22 +106,39 @@ export const calculateReleaseVersion = (tags: readonly string[], bump: ReleaseBu export const calculateNextDevelopmentVersion = (releaseVersion: string): string => { if (!isStableVersion(releaseVersion)) throw new Error(`Release version must be stable SemVer: ${releaseVersion}.`) - const nextDevVersion = semver.inc(releaseVersion, "preminor", "dev") + const nextDevVersion = semver.inc(releaseVersion, "prepatch", "dev") if (nextDevVersion === null) throw new Error(`Cannot calculate next development version from ${releaseVersion}.`) return nextDevVersion } -const validatePrepareSourceVersion = (sourceVersion: string, tags: readonly string[]): void => { - const latestStableVersion = getLatestStableVersion(tags) +const resolveReleaseVersion = ( + sourceVersion: string, + tags: readonly string[], + options: ReleaseMetadataCliOptions, +): string => { + const latestProductVersion = getLatestStableVersion(tags, options.tagPrefix) + const latestStableVersion = + latestProductVersion ?? getReleaseLineVersion(tags, options.tagPrefix, options.legacyTagPrefix) if (latestStableVersion === undefined) { - if (!isStableVersion(sourceVersion)) - throw new Error(`Initial release source must be stable SemVer: ${sourceVersion}.`) - return + if (isStableVersion(sourceVersion)) return calculateReleaseVersion([], options.bump) + const initialVersion = sourceVersion.replace(/-dev\.0$/, "") + if (initialVersion === sourceVersion || !isStableVersion(initialVersion)) { + throw new Error(`Initial release source must be stable SemVer or an exact -dev.0 candidate: ${sourceVersion}.`) + } + return initialVersion } const expectedSourceVersion = calculateNextDevelopmentVersion(latestStableVersion) - if (sourceVersion !== expectedSourceVersion) { + const legacySourceVersion = semver.inc(latestStableVersion, "preminor", "dev") + const migratingLegacyTags = latestProductVersion === undefined && options.legacyTagPrefix !== undefined + if (sourceVersion !== expectedSourceVersion && !(migratingLegacyTags && sourceVersion === legacySourceVersion)) { throw new Error(`Release source version must match the expected development version: ${expectedSourceVersion}.`) } + const releaseVersion = semver.inc(latestStableVersion, options.bump) + if (releaseVersion === null) throw new Error(`Cannot calculate ${options.bump} release from ${latestStableVersion}.`) + if (migratingLegacyTags && sourceVersion === legacySourceVersion && sourceVersion !== `${releaseVersion}-dev.0`) { + throw new Error(`The first product release must match its existing development candidate: ${sourceVersion}.`) + } + return releaseVersion } export const insertReleaseIntoChangelog = (changelog: string, version: string, date: string): string => { @@ -127,19 +154,32 @@ export const insertReleaseIntoChangelog = (changelog: string, version: string, d return `${changelog.slice(0, insertionPoint)}\n\n## [${version}] - ${date}${changelog.slice(insertionPoint)}` } +export const extractReleaseNotes = (changelog: string, version: string): string => { + if (!isStableVersion(version)) throw new Error(`Release version must be stable SemVer: ${version}.`) + const heading = new RegExp(`^## \\[${version.replace(/\./g, "\\.")}\\] - \\d{4}-\\d{2}-\\d{2}$`, "m") + const match = heading.exec(changelog) + if (match === null) throw new Error(`CHANGELOG.md is missing release ${version}.`) + const rest = changelog.slice(match.index + match[0].length) + const nextHeading = /^## /m.exec(rest) + const notes = rest.slice(0, nextHeading?.index).trim() + if (notes.length === 0) throw new Error(`CHANGELOG.md has no notes for release ${version}.`) + return `${notes}\n` +} + export const runReleaseMetadataCli = async ( arguments_: readonly string[], ): Promise<{ readonly nextDevVersion: string; readonly releaseVersion: string }> => { const options = parseCliOptions(arguments_) + let tagInput = "" + for await (const chunk of process.stdin) tagInput += chunk.toString() const [packageJsonSource, changelog, tags] = await Promise.all([ readFile(options.packageJsonPath, "utf8"), readFile(options.changelogPath, "utf8"), - Bun.stdin.text(), + Promise.resolve(tagInput), ]) const stableTags = tags.split(/\r?\n/).filter(Boolean) const packageMetadata = parsePackageMetadata(packageJsonSource) - validatePrepareSourceVersion(packageMetadata.version, stableTags) - const releaseVersion = calculateReleaseVersion(stableTags, options.bump) + const releaseVersion = resolveReleaseVersion(packageMetadata.version, stableTags, options) const nextDevVersion = calculateNextDevelopmentVersion(releaseVersion) const releasePackageJson = `${JSON.stringify({ ...packageMetadata, version: releaseVersion }, null, 2)}\n` const releaseChangelog = insertReleaseIntoChangelog(changelog, releaseVersion, options.date) @@ -150,6 +190,15 @@ export const runReleaseMetadataCli = async ( return { nextDevVersion, releaseVersion } } +export const setStablePackageVersion = async (packageJsonPath: string, version: string): Promise => { + if (!isStableVersion(version)) { + throw new Error(`Package version must be stable SemVer: ${version}.`) + } + const packageMetadata = parsePackageMetadata(await readFile(packageJsonPath, "utf8")) + if (packageMetadata.version === version) return + await writeFile(packageJsonPath, `${JSON.stringify({ ...packageMetadata, version }, null, 2)}\n`) +} + export const setPackageVersion = async (packageJsonPath: string, version: string): Promise => { if (semver.valid(version) !== version || semver.prerelease(version) === null) { throw new Error(`Package version must be an exact prerelease SemVer version: ${version}.`) @@ -173,15 +222,27 @@ const writeGithubOutputs = async (result: { } if (import.meta.main) { - const [command, ...arguments_] = Bun.argv.slice(2) - if (command === "set-package-version") { + const [command, ...arguments_] = process.argv.slice(2) + if (command === "release-notes") { + const [changelogPath, version] = arguments_ + if (changelogPath === undefined || version === undefined || arguments_.length !== 2) { + throw new Error("Expected release-notes .") + } + process.stdout.write(extractReleaseNotes(await readFile(changelogPath, "utf8"), version)) + } else if (command === "set-package-stable-version") { + const [packageJsonPath, version] = arguments_ + if (packageJsonPath === undefined || version === undefined || arguments_.length !== 2) { + throw new Error("Expected set-package-stable-version .") + } + await setStablePackageVersion(packageJsonPath, version) + } else if (command === "set-package-version") { const [packageJsonPath, version] = arguments_ if (packageJsonPath === undefined || version === undefined || arguments_.length !== 2) { throw new Error("Expected set-package-version .") } await setPackageVersion(packageJsonPath, version) } else { - const result = await runReleaseMetadataCli(Bun.argv.slice(2)) + const result = await runReleaseMetadataCli(process.argv.slice(2)) await writeGithubOutputs(result) process.stdout.write(`${JSON.stringify(result)}\n`) } diff --git a/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts b/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts deleted file mode 100644 index 115e367..0000000 --- a/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { existsSync } from "node:fs" - -import type { SidebarSnapshot, SidebarSnapshotReader } from "../../application/ports/sidebar-snapshot.ts" -import { resolveHostDatabasePath, type HostPathOptions } from "./app-data-path.ts" -import { createExecutor, openReadonlyDatabase } from "./bun-sqlite-driver.ts" -import { - ACTIVE_PARTNER_SELECT, - toPartner, - type PersistedPartnerRow, - type TrainerStateRow, -} from "./sqlite-vpet-schema.ts" - -export type CreateSqliteSidebarSnapshotReaderOptions = HostPathOptions & { readonly databasePath?: string } - -const TRAINER_STATE_SELECT = "SELECT total_tokens FROM trainer_state WHERE trainer_id = 1" -const CONTROL_STATE_SELECT = "SELECT frozen, cheat_node_id FROM vpet_control_state WHERE control_id = 1" - -type ControlStateRow = { - readonly frozen: number - readonly cheat_node_id: string | null -} - -const isRecoverableReadError = (error: unknown): boolean => { - return ( - error instanceof Error && - (error.message.includes("sqlite") || error.message.includes("database") || error.message.includes("no such table")) - ) -} - -export const createSqliteSidebarSnapshotReader = ( - options: CreateSqliteSidebarSnapshotReaderOptions = {}, -): SidebarSnapshotReader => { - const databasePath = options.databasePath ?? resolveHostDatabasePath(options) - - return { - getSidebarSnapshot(): SidebarSnapshot | null { - if (!existsSync(databasePath)) return null - - try { - const database = openReadonlyDatabase(databasePath) - try { - const executor = createExecutor(database) - const trainer = executor.get(TRAINER_STATE_SELECT) - const control = executor.get(CONTROL_STATE_SELECT) - if (control?.cheat_node_id !== null && control?.cheat_node_id !== undefined) { - return { - currentNodeId: control.cheat_node_id, - gauge: 0, - isTerminal: true, - frozen: false, - isSetOverride: true, - trainerTotalTokens: trainer?.total_tokens ?? 0, - } - } - - const partnerRow = executor.get(ACTIVE_PARTNER_SELECT) - if (partnerRow === null) return null - - const partner = toPartner(partnerRow) - return { - currentNodeId: partner.currentNodeId, - gauge: partner.gauge, - isTerminal: partner.isTerminal, - frozen: control?.frozen === 1, - isSetOverride: false, - trainerTotalTokens: trainer?.total_tokens ?? 0, - } - } finally { - database.close() - } - } catch (error) { - if (isRecoverableReadError(error)) return null - throw error - } - }, - } -} diff --git a/src/application/models/vpet-control.ts b/src/application/models/vpet-control.ts deleted file mode 100644 index 8ff8f37..0000000 --- a/src/application/models/vpet-control.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type FreezeVpetOutcome = { readonly kind: "frozen" } | { readonly kind: "already_frozen" } - -export type UnfreezeVpetOutcome = { readonly kind: "unfrozen" } | { readonly kind: "already_unfrozen" } - -export type SetCheatNodeOutcome = - | { readonly kind: "set"; readonly cheatNodeId: string } - | { readonly kind: "already_set"; readonly cheatNodeId: string } diff --git a/src/application/ports/vpet-archive.ts b/src/application/ports/vpet-archive.ts deleted file mode 100644 index 461f4a3..0000000 --- a/src/application/ports/vpet-archive.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { VpetArchiveResult } from "../models/vpet-archive.ts" - -export type VpetArchiveReader = { - getArchive: () => VpetArchiveResult -} diff --git a/src/application/ports/vpet-control.ts b/src/application/ports/vpet-control.ts deleted file mode 100644 index 9f78de1..0000000 --- a/src/application/ports/vpet-control.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { FreezeVpetOutcome, SetCheatNodeOutcome, UnfreezeVpetOutcome } from "../models/vpet-control.ts" - -export type VpetControl = { - freeze: () => FreezeVpetOutcome - unfreeze: () => UnfreezeVpetOutcome - setCheatNode: (validatedNodeId: string) => SetCheatNodeOutcome -} diff --git a/src/application/use-cases/freeze-vpet.ts b/src/application/use-cases/freeze-vpet.ts deleted file mode 100644 index bbd8ffc..0000000 --- a/src/application/use-cases/freeze-vpet.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { FreezeVpetOutcome } from "../models/vpet-control.ts" -import type { VpetControl } from "../ports/vpet-control.ts" - -export const freezeVpet = (control: Pick): FreezeVpetOutcome => control.freeze() diff --git a/src/application/use-cases/set-vpet-cheat-node.ts b/src/application/use-cases/set-vpet-cheat-node.ts deleted file mode 100644 index 642982d..0000000 --- a/src/application/use-cases/set-vpet-cheat-node.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { SetCheatNodeOutcome } from "../models/vpet-control.ts" -import type { VpetControl } from "../ports/vpet-control.ts" - -export const setVpetCheatNode = ( - control: Pick, - validatedNodeId: string, -): SetCheatNodeOutcome => control.setCheatNode(validatedNodeId) diff --git a/src/application/use-cases/unfreeze-vpet.ts b/src/application/use-cases/unfreeze-vpet.ts deleted file mode 100644 index 10d4001..0000000 --- a/src/application/use-cases/unfreeze-vpet.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { UnfreezeVpetOutcome } from "../models/vpet-control.ts" -import type { VpetControl } from "../ports/vpet-control.ts" - -export const unfreezeVpet = (control: Pick): UnfreezeVpetOutcome => control.unfreeze() diff --git a/src/commands/vpet-freeze.ts b/src/commands/vpet-freeze.ts deleted file mode 100644 index 337cbd6..0000000 --- a/src/commands/vpet-freeze.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { VpetControl } from "../application/ports/vpet-control.ts" -import { freezeVpet } from "../application/use-cases/freeze-vpet.ts" -import type { VpetCommandResult } from "./vpet-command-result.ts" - -export type VpetFreezeContext = { - readonly sessionID: string - readonly messageID: string -} - -export const runVpetFreezeCommand = async ( - control: Pick, - context: VpetFreezeContext, -): Promise => { - const outcome = freezeVpet(control) - const parts = [ - { - id: context.messageID, - sessionID: context.sessionID, - messageID: context.messageID, - type: "text" as const, - text: "VPet frozen.", - }, - ] - - switch (outcome.kind) { - case "frozen": - return { parts, event: { kind: "frozen" } } - case "already_frozen": - return { parts } - default: { - const unexpectedOutcome: never = outcome - return unexpectedOutcome - } - } -} diff --git a/src/commands/vpet-unfreeze.ts b/src/commands/vpet-unfreeze.ts deleted file mode 100644 index 1383760..0000000 --- a/src/commands/vpet-unfreeze.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { VpetControl } from "../application/ports/vpet-control.ts" -import { unfreezeVpet } from "../application/use-cases/unfreeze-vpet.ts" -import type { VpetCommandResult } from "./vpet-command-result.ts" - -export type VpetUnfreezeContext = { - readonly sessionID: string - readonly messageID: string -} - -export const runVpetUnfreezeCommand = async ( - control: Pick, - context: VpetUnfreezeContext, -): Promise => { - const outcome = unfreezeVpet(control) - const parts = [ - { - id: context.messageID, - sessionID: context.sessionID, - messageID: context.messageID, - type: "text" as const, - text: "VPet unfrozen.", - }, - ] - - switch (outcome.kind) { - case "unfrozen": - return { parts, event: { kind: "unfrozen" } } - case "already_unfrozen": - return { parts } - default: { - const unexpectedOutcome: never = outcome - return unexpectedOutcome - } - } -} diff --git a/src/config/global-vpet-settings.ts b/src/config/global-vpet-settings.ts deleted file mode 100644 index 5529952..0000000 --- a/src/config/global-vpet-settings.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { readFileSync } from "node:fs" -import { readFile } from "node:fs/promises" -import { homedir } from "node:os" -import { join } from "node:path" - -import { DEFAULT_VPET_SETTINGS } from "./defaults.ts" -import { normalizeVpetSettings } from "./normalize.ts" -import type { ResolvedVpetSettings } from "./types.ts" - -type PathApi = Readonly<{ - join: (...paths: readonly string[]) => string -}> - -export type GlobalVpetConfigOptions = Readonly<{ - readonly env?: Readonly<{ - readonly APPDATA?: string - }> - readonly home?: string - readonly pathApi?: PathApi - readonly platform?: NodeJS.Platform -}> - -export const resolveGlobalVpetConfigPath = (options: GlobalVpetConfigOptions = {}): string => { - const pathApi = options.pathApi ?? { join } - const env = options.env ?? process.env - const home = options.home ?? homedir() - - if ((options.platform ?? process.platform) === "win32") { - const { APPDATA } = env - return pathApi.join(APPDATA ?? pathApi.join(home, "AppData", "Roaming"), "opencode-vpet.json") - } - - return pathApi.join(home, ".config", "opencode-vpet.json") -} - -export const loadGlobalVpetSettings = async (options: GlobalVpetConfigOptions = {}): Promise => { - try { - return normalizeVpetSettings(JSON.parse(await readFile(resolveGlobalVpetConfigPath(options), "utf8"))) - } catch { - return DEFAULT_VPET_SETTINGS - } -} - -export const loadGlobalVpetSettingsSync = (options: GlobalVpetConfigOptions = {}): ResolvedVpetSettings => { - try { - return normalizeVpetSettings(JSON.parse(readFileSync(resolveGlobalVpetConfigPath(options), "utf8"))) - } catch { - return DEFAULT_VPET_SETTINGS - } -} diff --git a/src/domain/evolution.ts b/src/domain/evolution.ts deleted file mode 100644 index 25f0f5b..0000000 --- a/src/domain/evolution.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { DigimonNode } from "./digimon-node.ts" -import { DIGIMON_STAGES, type DigimonStage } from "./stage.ts" - -export type StageThresholds = Readonly> - -export const STAGE_GAUGE_THRESHOLDS = Object.freeze({ - 0: 500_000, - 1: 1_000_000, - 2: 2_000_000, - 3: 4_000_000, - 4: 7_500_000, - 5: 12_500_000, - 6: 20_000_000, - 7: 30_000_000, -} as const satisfies StageThresholds) - -export type PartnerEvolutionState = { - readonly current: DigimonNode - readonly gauge: number - readonly isTerminal: boolean -} - -export type EvolutionSelector = () => number - -const isRecord = (value: unknown): value is Readonly> => { - return typeof value === "object" && value !== null -} - -const isStageThresholds = (thresholds: unknown): thresholds is StageThresholds => { - if (!isRecord(thresholds) || !Object.isFrozen(thresholds)) return false - - return DIGIMON_STAGES.every((stage) => { - const threshold = thresholds[stage] - return typeof threshold === "number" && Number.isFinite(threshold) && threshold > 0 - }) -} - -export const applyTokenProgress = ( - state: PartnerEvolutionState, - tokenDelta: number, - selector: EvolutionSelector, - digimonById: ReadonlyMap, - thresholds: unknown, -): PartnerEvolutionState => { - if (!isStageThresholds(thresholds)) { - throw new Error("Evolution thresholds must be a complete frozen policy of positive finite numbers") - } - if (state.isTerminal) return state - - const gauge = state.gauge + tokenDelta - if (gauge < thresholds[state.current.stage]) return { ...state, gauge } - if (state.current.nextEvolutions.length === 0) return { ...state, gauge: 0, isTerminal: true } - - const selection = selector() - if (!Number.isFinite(selection) || selection < 0 || selection >= 1) { - throw new Error(`Evolution selector must return a finite number in [0, 1), received ${selection}`) - } - - const targetId = state.current.nextEvolutions[Math.floor(selection * state.current.nextEvolutions.length)] - if (targetId === undefined) throw new Error("Evolution target selection failed") - const current = digimonById.get(targetId) - if (current === undefined) throw new Error(`Evolution target ${targetId} is missing from the catalog`) - - return { current, gauge: 0, isTerminal: current.nextEvolutions.length === 0 } -} diff --git a/src/tui.tsx b/src/tui.tsx deleted file mode 100644 index c9a0583..0000000 --- a/src/tui.tsx +++ /dev/null @@ -1,252 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import type { PluginOptions } from "@opencode-ai/plugin" -import type { EventMessagePartUpdated, EventMessageUpdated, EventSessionStatus } from "@opencode-ai/sdk/v2" -import type { TuiPlugin, TuiDialogProps, TuiDialogStack, TuiTheme, TuiKeymap } from "@opencode-ai/plugin/tui" -import type { JSX } from "@opentui/solid" -import { createSignal } from "solid-js" - -import { createSqliteSidebarSnapshotReader } from "./adapters/sqlite/sqlite-sidebar-snapshot-reader.ts" -import { createSqliteVpetArchiveReader } from "./adapters/sqlite/sqlite-vpet-archive-reader.ts" -import type { SidebarCardInputs } from "./application/models/sidebar-card-inputs.ts" -import type { VpetArchiveReader } from "./application/ports/vpet-archive.ts" -import { getSidebarCardInputs } from "./application/use-cases/get-sidebar-card-inputs.ts" -import { loadGlobalVpetSettings } from "./config/global-vpet-settings.ts" -import type { ResolvedVpetSettings } from "./config/types.ts" -import { DIGIMON_CATALOG } from "./data/catalog.ts" -import { MONSTER_FRAME_CATALOG } from "./data/monster-frame-catalog.ts" -import { VpetSidebarCard } from "./tui/sidebar-card.tsx" -import { MonsterAnimationController, type MonsterAnimationOutput } from "./tui/monster-animation.ts" -import { createSidebarPollLoop } from "./tui/sidebar-poll-loop.ts" -import { buildSidebarCardModel } from "./tui/sidebar-view-model.ts" -import { registerVpetCommandLayer } from "./tui/vpet-command-layer.tsx" - -const VISUAL_INTERVAL_MS = 500 - -const getStringOption = (options: PluginOptions | undefined, name: string): string | undefined => { - const value = options?.[name] - return typeof value === "string" ? value : undefined -} - -const readerOptions = (options: PluginOptions | undefined): Parameters[0] => { - const appDataRoot = getStringOption(options, "appDataRoot") - const databasePath = getStringOption(options, "databasePath") - return { - ...(appDataRoot === undefined ? {} : { appDataRoot }), - ...(databasePath === undefined ? {} : { databasePath }), - } -} - -export type TuiCompositionApi = { - readonly event: { readonly on: (...subscription: ActivitySubscription) => () => void } - readonly renderer: { readonly requestRender: () => void } - readonly lifecycle: { readonly onDispose: (callback: () => void) => () => void } - readonly slots: { - readonly register: (plugin: { readonly slots: { readonly sidebar_content: () => JSX.Element } }) => string - } - readonly keymap?: { readonly registerLayer: TuiKeymap["registerLayer"] } - readonly ui?: { - readonly dialog: Pick - readonly Dialog: (props: TuiDialogProps) => JSX.Element - } - readonly theme?: TuiTheme -} - -type ActivitySubscription = - | readonly [type: "message.updated", handler: (event: EventMessageUpdated) => void] - | readonly [type: "message.part.updated", handler: (event: EventMessagePartUpdated) => void] - | readonly [type: "session.status", handler: (event: EventSessionStatus) => void] - -type TuiSchedulingOptions = { - readonly scheduleVisualInterval?: (callback: () => void, intervalMs: number) => () => void - readonly schedulePollTimeout?: (callback: () => void, intervalMs: number) => () => void - readonly nowMs?: () => number - readonly random?: () => number - readonly onAnimation?: (output: MonsterAnimationOutput) => void - readonly archiveReader?: VpetArchiveReader -} - -const sameAnimation = (left: MonsterAnimationOutput, right: MonsterAnimationOutput): boolean => - left.kind === right.kind && - left.result.kind === right.result.kind && - (left.result.kind !== "frame" || right.result.kind !== "frame" || left.result.frame === right.result.frame) && - (left.result.kind !== "unavailable" || - right.result.kind !== "unavailable" || - left.result.sprite === right.result.sprite) && - left.offset === right.offset && - left.facing === right.facing - -export const createTui = - ( - loadInputs: () => SidebarCardInputs | Promise, - settings: ResolvedVpetSettings, - scheduling: TuiSchedulingOptions = {}, - ) => - async (api: TuiCompositionApi): Promise => { - const [inputs, setInputs] = createSignal({ kind: "no_partner" }) - const [animation, setAnimation] = createSignal({ - kind: "blank", - result: { kind: "blank" }, - offset: 0, - facing: "left", - }) - const controller = new MonsterAnimationController( - MONSTER_FRAME_CATALOG, - scheduling.random ?? Math.random, - scheduling.nowMs, - ) - let disposed = false - const publish = (nextAnimation: MonsterAnimationOutput): boolean => { - if (sameAnimation(animation(), nextAnimation)) return false - setAnimation(nextAnimation) - return true - } - const syncAnimation = (nextInputs: SidebarCardInputs): void => { - const cardModel = buildSidebarCardModel(nextInputs, settings) - publish( - controller.dispatch({ - kind: "partner_changed", - partner: - cardModel.kind === "partner" - ? { sprite: cardModel.sprite, isDigitama: cardModel.stageNumber === 0 } - : undefined, - }), - ) - } - const poller = createSidebarPollLoop({ - intervalMs: VISUAL_INTERVAL_MS, - load: async () => loadInputs(), - apply: (nextInputs) => { - setInputs(nextInputs) - syncAnimation(nextInputs) - api.renderer.requestRender() - }, - schedule: (callback, intervalMs) => - ( - scheduling.schedulePollTimeout ?? - ((scheduledCallback, delay) => { - const handle = setTimeout(scheduledCallback, delay) - return () => clearTimeout(handle) - }) - )(callback, intervalMs), - clear: (stop) => stop(), - }) - const stopVisualInterval = ( - scheduling.scheduleVisualInterval ?? - ((callback, intervalMs) => { - const handle = setInterval(callback, intervalMs) - return () => clearInterval(handle) - }) - )(() => { - if (disposed) return - const nextAnimation = controller.dispatch({ kind: "tick" }) - publish(nextAnimation) - scheduling.onAnimation?.(nextAnimation) - api.renderer.requestRender() - }, VISUAL_INTERVAL_MS) - - const activity = (): void => { - if (disposed) return - poller.refresh() - if (publish(controller.dispatch({ kind: "activity" }))) api.renderer.requestRender() - } - const unsubscribes = [ - api.event.on("message.updated", activity), - api.event.on("message.part.updated", activity), - api.event.on("session.status", (event: EventSessionStatus) => { - switch (event.properties.status.type) { - case "busy": - case "retry": - activity() - return - case "idle": - return - default: { - const unexpectedStatus: never = event.properties.status - return unexpectedStatus - } - } - }), - ] - - poller.start() - - const disposeLayer = - api.keymap === undefined || api.ui === undefined || api.theme === undefined - ? () => undefined - : scheduling.archiveReader === undefined - ? api.keymap.registerLayer({ - name: "opencode-vpet.layer", - namespace: "opencode-vpet", - commands: [], - }) - : registerVpetCommandLayer({ - api: { keymap: api.keymap, ui: api.ui, theme: api.theme }, - reader: scheduling.archiveReader, - catalog: DIGIMON_CATALOG, - settings, - isDisposed: () => disposed, - }) - - api.lifecycle.onDispose(() => { - if (disposed) return - disposed = true - disposeLayer() - poller.dispose() - stopVisualInterval() - for (const unsubscribe of unsubscribes) unsubscribe() - }) - - api.slots.register({ - slots: { - sidebar_content() { - return ( - buildSidebarCardModel(inputs(), settings)} - animation={animation} - onArtworkWidthChange={(width) => { - if (disposed) return - if (publish(controller.dispatch({ kind: "viewport_resized", width }))) api.renderer.requestRender() - }} - /> - ) - }, - }, - }) - } - -export const tui: TuiPlugin = async (api, options) => { - const reader = createSqliteSidebarSnapshotReader(readerOptions(options)) - const archiveReader = createSqliteVpetArchiveReader(readerOptions(options)) - const settings = await loadGlobalVpetSettings() - await createTui(() => getSidebarCardInputs(reader, DIGIMON_CATALOG), settings, { archiveReader })({ - event: { - on: (...subscription) => { - switch (subscription[0]) { - case "message.updated": - return api.event.on(subscription[0], subscription[1]) - case "message.part.updated": - return api.event.on(subscription[0], subscription[1]) - case "session.status": - return api.event.on(subscription[0], subscription[1]) - } - }, - }, - renderer: api.renderer, - lifecycle: api.lifecycle, - slots: api.slots, - keymap: { registerLayer: api.keymap.registerLayer.bind(api.keymap) }, - ui: { - dialog: { - replace: api.ui.dialog.replace.bind(api.ui.dialog), - clear: api.ui.dialog.clear.bind(api.ui.dialog), - setSize: api.ui.dialog.setSize.bind(api.ui.dialog), - }, - Dialog: api.ui.Dialog, - }, - theme: api.theme, - }) -} - -const plugin = { id: "opencode-vpet", tui } as const - -export default plugin diff --git a/src/tui/vpet-command-layer.tsx b/src/tui/vpet-command-layer.tsx deleted file mode 100644 index 47fbe37..0000000 --- a/src/tui/vpet-command-layer.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import type { TuiDialogStack, TuiKeymap, TuiTheme } from "@opencode-ai/plugin/tui" - -import type { VpetArchiveResult } from "../application/models/vpet-archive.ts" -import type { VpetArchiveReader } from "../application/ports/vpet-archive.ts" -import type { ResolvedVpetSettings } from "../config/types.ts" -import type { DigimonCatalog } from "../data/catalog.ts" -import { buildDexViewModel } from "./dex-view-model.ts" -import { buildHistoryViewModel } from "./history-view-model.ts" -import { VpetDexDialog } from "./vpet-dex-dialog.tsx" -import { VpetHistoryDialog } from "./vpet-history-dialog.tsx" - -export type VpetCommandLayerApi = { - readonly keymap: Pick - readonly ui: { - readonly dialog: Pick - } - readonly theme: TuiTheme -} - -export type VpetCommandLayerDependencies = { - readonly api: VpetCommandLayerApi - readonly reader: VpetArchiveReader - readonly catalog: DigimonCatalog - readonly settings: ResolvedVpetSettings - readonly isDisposed: () => boolean -} - -const unavailableArchive = (): VpetArchiveResult => ({ - kind: "unavailable", - message: "VPet archive is unavailable.", -}) - -const readArchive = (reader: VpetArchiveReader): VpetArchiveResult => { - try { - return reader.getArchive() - } catch { - return unavailableArchive() - } -} - -export const registerVpetCommandLayer = ({ - api, - reader, - catalog, - settings, - isDisposed, -}: VpetCommandLayerDependencies): ReturnType => { - let isDialogOpen = false - const closeDialog = (): void => { - if (isDisposed() || !isDialogOpen) return - isDialogOpen = false - api.ui.dialog.clear() - } - - const openDex = (): void => { - if (isDisposed()) return - const model = buildDexViewModel(readArchive(reader), catalog, settings) - isDialogOpen = true - api.ui.dialog.replace(() => , closeDialog) - api.ui.dialog.setSize("medium") - } - const openHistory = (): void => { - if (isDisposed()) return - const model = buildHistoryViewModel(readArchive(reader), catalog, settings) - isDialogOpen = true - api.ui.dialog.replace( - () => , - closeDialog, - ) - api.ui.dialog.setSize("medium") - } - - const disposeLayer = api.keymap.registerLayer({ - name: "opencode-vpet.layer", - namespace: "opencode-vpet", - commands: [ - { - name: "vpet.dex", - title: "VPet Dex", - description: "Browse the VPet discovery archive.", - category: "VPet", - namespace: "palette", - slashName: "vpet-dex", - run: openDex, - }, - { - name: "vpet.history", - title: "VPet History", - description: "Browse VPet generation history.", - category: "VPet", - namespace: "palette", - slashName: "vpet-history", - run: openHistory, - }, - ], - }) - - return () => { - if (isDialogOpen) { - api.ui.dialog.clear() - isDialogOpen = false - } - disposeLayer() - } -} diff --git a/tests/monster-animation.test.ts b/tests/monster-animation.test.ts deleted file mode 100644 index 0483089..0000000 --- a/tests/monster-animation.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, expect, test } from "bun:test" - -import { MonsterFrame } from "../src/data/monster-frame-catalog.ts" -import { MonsterAnimationController } from "../src/tui/monster-animation.ts" -import { catalogFor, frameContent, partnerChanged } from "./monster-animation-test-utils.ts" - -describe("MonsterAnimationController identity", () => { - test("Given no active partner When it is selected Then animation is blank at the centered origin", () => { - const controller = new MonsterAnimationController(catalogFor(new Map())) - - const output = controller.dispatch(partnerChanged(undefined)) - - expect(output).toEqual({ kind: "blank", result: { kind: "blank" }, offset: 0, facing: "left" }) - expect(controller.get()).toEqual({ kind: "blank" }) - }) - - test.each(["unknown", ""])( - "Given active %p artwork without walk frames When selected Then it is unavailable with the raw key", - (sprite) => { - const controller = new MonsterAnimationController(catalogFor(new Map())) - - const output = controller.dispatch(partnerChanged({ sprite, isDigitama: false })) - - expect(output).toEqual({ - kind: "unavailable", - result: { kind: "unavailable", sprite }, - offset: 0, - facing: "left", - }) - }, - ) - - test("Given an unavailable partner When every event occurs Then no hidden semantic motion accumulates", () => { - const controller = new MonsterAnimationController(catalogFor(new Map())) - const identity = { sprite: "missing", isDigitama: false } as const - - controller.dispatch(partnerChanged(identity)) - const ticked = controller.dispatch({ kind: "tick" }) - const active = controller.dispatch({ kind: "activity" }) - const resized = controller.dispatch({ kind: "viewport_resized", width: 80 }) - - expect(ticked).toEqual(active) - expect(active).toEqual(resized) - expect(resized).toEqual({ - kind: "unavailable", - result: { kind: "unavailable", sprite: "missing" }, - offset: 0, - facing: "left", - }) - }) - - test("Given a non-Digitama partner with two walk frames When selected Then walking starts on walk_1 facing left", () => { - const controller = new MonsterAnimationController(catalogFor(new Map([["agumon", ["angry", "walk_1", "walk_2"]]]))) - - const output = controller.dispatch(partnerChanged({ sprite: "agumon", isDigitama: false })) - - expect(output).toEqual({ - kind: "walking", - result: { kind: "frame", frame: new MonsterFrame("agumon:walk_1") }, - offset: 0, - facing: "left", - }) - }) - - test("Given a non-Digitama partner with only walk_2 When selected Then walking starts on its available walk frame", () => { - const controller = new MonsterAnimationController(catalogFor(new Map([["sparse", ["happy", "walk_2"]]]))) - - const output = controller.dispatch(partnerChanged({ sprite: "sparse", isDigitama: false })) - - expect(frameContent(output)).toBe("sparse:walk_2") - expect(output.kind).toBe("walking") - }) - - test("Given a stage-zero identity with a non-egg sprite When ticks occur Then explicit Digitama mode loops walk frames at the origin", () => { - const controller = new MonsterAnimationController( - catalogFor(new Map([["unexpected", ["angry", "walk_1", "walk_2"]]])), - ) - - const first = controller.dispatch(partnerChanged({ sprite: "unexpected", isDigitama: true })) - const second = controller.dispatch({ kind: "tick" }) - const wrapped = controller.dispatch({ kind: "tick" }) - - expect([first, second, wrapped].map(frameContent)).toEqual([ - "unexpected:walk_1", - "unexpected:walk_2", - "unexpected:walk_1", - ]) - expect([first, second, wrapped].map(({ kind, offset, facing }) => ({ kind, offset, facing }))).toEqual([ - { kind: "digitama", offset: 0, facing: "left" }, - { kind: "digitama", offset: 0, facing: "left" }, - { kind: "digitama", offset: 0, facing: "left" }, - ]) - }) - - test("Given a selected identity at its second frame When the same identity is selected Then its phase is preserved", () => { - const controller = new MonsterAnimationController(catalogFor(new Map([["egg", ["walk_1", "walk_2"]]]))) - const identity = { sprite: "egg", isDigitama: true } as const - - controller.dispatch(partnerChanged(identity)) - controller.dispatch({ kind: "tick" }) - const preserved = controller.dispatch(partnerChanged(identity)) - - expect(frameContent(preserved)).toBe("egg:walk_2") - }) - - test.each([ - ["sprite change", { sprite: "gatomon", isDigitama: false }], - ["Digitama-status change", { sprite: "agumon", isDigitama: true }], - ] as const)( - "Given a walking partner at its second frame When its %s occurs Then all transient state resets", - (_change, nextIdentity) => { - const controller = new MonsterAnimationController( - catalogFor( - new Map([ - ["agumon", ["walk_1", "walk_2"]], - ["gatomon", ["walk_1", "walk_2"]], - ]), - ), - ) - - controller.dispatch(partnerChanged({ sprite: "agumon", isDigitama: false })) - controller.dispatch({ kind: "tick" }) - const reset = controller.dispatch(partnerChanged(nextIdentity)) - - expect(frameContent(reset)).toBe(`${nextIdentity.sprite}:walk_1`) - expect(reset.offset).toBe(0) - expect(reset.facing).toBe("left") - }, - ) - - test("Given compatibility callers When sprite selection and advancement are used Then they observe semantic walk frames", () => { - const controller = new MonsterAnimationController(catalogFor(new Map([["agumon", ["walk_1", "walk_2"]]]))) - - controller.setSprite("agumon") - const first = controller.get() - controller.advance() - const second = controller.get() - - expect(first).toEqual({ kind: "frame", frame: new MonsterFrame("agumon:walk_1") }) - expect(second).toEqual({ kind: "frame", frame: new MonsterFrame("agumon:walk_2") }) - }) -}) diff --git a/tests/persistence/sqlite-vpet-repository.persistence.ts b/tests/persistence/sqlite-vpet-repository.persistence.ts deleted file mode 100644 index 6b37753..0000000 --- a/tests/persistence/sqlite-vpet-repository.persistence.ts +++ /dev/null @@ -1,4 +0,0 @@ -import "./sqlite-vpet-repository.lifecycle.persistence.ts" -import "./sqlite-vpet-repository.receipts.persistence.ts" -import "./sqlite-vpet-repository.control-ledger.persistence.ts" -import "./sqlite-vpet-archive-history.persistence.ts" diff --git a/tsconfig.json b/tsconfig.json index 6e32e0e..73b32d4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,29 +1,13 @@ { - "compilerOptions": { - "strict": true, - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noPropertyAccessFromIndexSignature": true, - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "isolatedModules": true, - "target": "ESNext", - "lib": ["ESNext", "DOM"], - "types": ["bun"], - "jsx": "react-jsx", - "jsxImportSource": "@opentui/solid", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "dist", - "rootDir": "src", - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src"], - "exclude": ["node_modules", "dist", "tests"] + "files": [], + "references": [ + { "path": "./packages/digital-pet-core" }, + { "path": "./packages/opencode-digital-pet" }, + { "path": "./packages/opencode-digital-pet/tsconfig.scripts.json" }, + { "path": "./packages/opencode-digital-pet/tsconfig.test.json" }, + { "path": "./packages/cursor-digital-pet" }, + { "path": "./packages/cursor-digital-pet/tsconfig.scripts.json" }, + { "path": "./packages/cursor-digital-pet/tsconfig.test.json" }, + { "path": "./tsconfig.release-metadata.json" } + ] } diff --git a/tsconfig.release-metadata.json b/tsconfig.release-metadata.json index a49dcd4..392d44a 100644 --- a/tsconfig.release-metadata.json +++ b/tsconfig.release-metadata.json @@ -1,8 +1,13 @@ { - "extends": "./tsconfig.json", "compilerOptions": { + "strict": true, + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ESNext", + "types": ["bun", "node"], "noEmit": true, - "rootDir": "scripts" + "rootDir": "scripts", + "skipLibCheck": true }, - "include": ["scripts/release-metadata.ts"] + "include": ["scripts/release-metadata.ts", "scripts/stage-changelog.ts"] }