From 53db9f3c0c279a4ca112b4977a3d0077a441ed82 Mon Sep 17 00:00:00 2001 From: Colossal Tuna Date: Wed, 11 Feb 2026 21:36:48 +0000 Subject: [PATCH 1/4] Add DevContainer Configuration --- .devcontainer/Containerfile | 20 ++++++++++++++++++++ .devcontainer/devcontainer.json | 15 +++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 .devcontainer/Containerfile create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/Containerfile b/.devcontainer/Containerfile new file mode 100644 index 000000000..ad37dce60 --- /dev/null +++ b/.devcontainer/Containerfile @@ -0,0 +1,20 @@ +FROM docker.io/redhat/ubi9:9.7 + +RUN yum install -y \ + unzip \ + make \ + git \ + xz \ + golang \ + && \ + mkdir /opt/node && \ + curl -fsSL https://nodejs.org/dist/v24.13.1/node-v24.13.1-linux-x64.tar.xz | tar xJ -C /opt/node && \ + mkdir /opt/protoc && cd /opt/protoc && \ + curl -fsSL https://github.com/protocolbuffers/protobuf/releases/download/v33.5/protoc-33.5-linux-x86_64.zip -o protoc.zip && \ + unzip protoc.zip && rm protoc.zip && \ + go install github.com/pseudomuto/protoc-gen-doc/cmd/protoc-gen-doc@latest && \ + curl -fsSL https://get.pnpm.io/install.sh | ENV="$HOME/.bashrc" SHELL="$(which bash)" bash - && \ + mkdir /opt/ets && \ + curl -fsSL https://github.com/zmwangx/ets/releases/download/v0.2.2/ets_0.2.2_linux_amd64.tar.gz | tar zx -C /opt/ets + +ENV PATH=$PATH:/opt/protoc/bin:/opt/ets:/opt/node/node-v24.13.1-linux-x64/bin diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..5dec72820 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,15 @@ +{ + "name": "WASMEGG", + "build": { + "dockerfile": "Containerfile", + "args": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "vue.volar", + "pbkit.vscode-pbkit" + ] + } + } +} From dcf46e44bf97d6d35dbe8634afac073b6c5718a7 Mon Sep 17 00:00:00 2001 From: Colossal Tuna Date: Wed, 11 Feb 2026 21:37:44 +0000 Subject: [PATCH 2/4] Add pnpm store to ignore list --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0cbc178a9..5f1feced1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ dist.frozen __target__ *.swp .agent -lib/.claude \ No newline at end of file +lib/.claude +.pnpm-store/ From f3af46b9a2f4d6df1aae0c6a9e88fbebdca0d6df Mon Sep 17 00:00:00 2001 From: ColossalTuna Date: Fri, 12 Jun 2026 11:47:26 -0500 Subject: [PATCH 3/4] Eliminate browser-dependent logic to enable unit tests --- lib/artifacts/recommendation.ts | 4 +++- lib/goatcounter.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/artifacts/recommendation.ts b/lib/artifacts/recommendation.ts index c39b11e81..c00164639 100644 --- a/lib/artifacts/recommendation.ts +++ b/lib/artifacts/recommendation.ts @@ -36,7 +36,6 @@ import { ArtifactAssemblyStatus, ArtifactAssemblyStatusNonMissing, ArtifactSet, - ei, Farm, getNumProphecyEggs, Inventory, @@ -44,6 +43,9 @@ import { Item, newItem, } from '..'; +// Separated from other barrel imports due to module level references +// causing circular import crashes when running test frameworks +import { ei } from '../proto'; const debug = import.meta.env.DEV || import.meta.env.VITE_APP_BETA; import Name = ei.ArtifactSpec.Name; diff --git a/lib/goatcounter.ts b/lib/goatcounter.ts index c2be44876..c12208812 100644 --- a/lib/goatcounter.ts +++ b/lib/goatcounter.ts @@ -8,4 +8,4 @@ declare global { } } -export const goatcounter = window.goatcounter; +export const goatcounter = typeof window === 'undefined' ? undefined : window.goatcounter; From c214f26bce17bcabc5ccf717aee7ad896aa9c511 Mon Sep 17 00:00:00 2001 From: ColossalTuna Date: Fri, 24 Apr 2026 15:31:50 +0000 Subject: [PATCH 4/4] Add tooling to optimize legendary drops on PoV This tooling provides optimization logic to maximize the chances of obtaining a legendary copy of an artifact while on the PoV. The optimal sets remain optimal on the home farm where the solution is time-bound rather than fuel bound. Nearly every part of this tooling is user-configurable so a user could effectively simulate how worth it it would be to level up a ship or bump up their crafting level. Changelog: feature --- .claude/settings.local.json | 3 +- .devcontainer/Containerfile | 14 +- .devcontainer/devcontainer.json | 17 +- lib/missions.ts | 7 + pnpm-lock.yaml | 779 +++++++++++------- wasmegg/artifact-explorer/package.json | 8 +- wasmegg/artifact-explorer/src/App.vue | 38 +- .../src/components/ArtifactDropStats.vue | 2 +- .../components/ArtifactMissionOptimizer.vue | 181 ++++ .../src/components/PlayerOverridesModal.vue | 578 +++++++++++++ .../src/components/ShipStars.ts | 68 ++ .../src/components/TankArtifactSelector.vue | 39 + .../optimizer/OptimizerChoiceList.vue | 25 + .../optimizer/OptimizerExpectedDrops.vue | 31 + .../optimizer/OptimizerInventoryPanel.vue | 36 + .../OptimizerProbabilityBreakdown.vue | 101 +++ .../optimizer/OptimizerSolutionCard.vue | 78 ++ .../components/optimizer/OptimizerToolbar.vue | 67 ++ wasmegg/artifact-explorer/src/lib/filter.ts | 17 +- wasmegg/artifact-explorer/src/lib/index.ts | 152 ++++ .../artifact-explorer/src/lib/loot.spec.ts | 298 +++++++ wasmegg/artifact-explorer/src/lib/loot.ts | 38 +- wasmegg/artifact-explorer/src/lib/lp.spec.ts | 100 +++ wasmegg/artifact-explorer/src/lib/lp.ts | 193 +++++ .../src/lib/multi-target.spec.ts | 104 +++ .../src/lib/optimizer-core.spec.ts | 336 ++++++++ .../src/lib/optimizer-core.ts | 724 ++++++++++++++++ .../src/lib/optimizer-views.spec.ts | 297 +++++++ .../src/lib/optimizer-views.ts | 164 ++++ wasmegg/artifact-explorer/src/lib/phases.ts | 149 ++++ .../src/lib/pipeline.spec.ts | 107 +++ .../artifact-explorer/src/lib/spec-helpers.ts | 41 + wasmegg/artifact-explorer/src/lib/types.ts | 90 ++ .../src/lib/value-function.spec.ts | 165 ++++ .../src/lib/value-function.ts | 210 +++++ wasmegg/artifact-explorer/src/main.ts | 3 + wasmegg/artifact-explorer/src/router.ts | 9 + wasmegg/artifact-explorer/src/store/index.ts | 313 ++++++- wasmegg/artifact-explorer/src/store/schema.ts | 84 ++ .../artifact-explorer/src/store/store.spec.ts | 88 ++ .../src/views/FuelTankPlanner.vue | 183 ++++ wasmegg/artifact-explorer/src/views/Main.vue | 26 +- wasmegg/artifact-explorer/tsconfig.json | 1 + wasmegg/artifact-explorer/vitest.config.ts | 20 + 44 files changed, 5671 insertions(+), 313 deletions(-) create mode 100644 wasmegg/artifact-explorer/src/components/ArtifactMissionOptimizer.vue create mode 100644 wasmegg/artifact-explorer/src/components/PlayerOverridesModal.vue create mode 100644 wasmegg/artifact-explorer/src/components/ShipStars.ts create mode 100644 wasmegg/artifact-explorer/src/components/TankArtifactSelector.vue create mode 100644 wasmegg/artifact-explorer/src/components/optimizer/OptimizerChoiceList.vue create mode 100644 wasmegg/artifact-explorer/src/components/optimizer/OptimizerExpectedDrops.vue create mode 100644 wasmegg/artifact-explorer/src/components/optimizer/OptimizerInventoryPanel.vue create mode 100644 wasmegg/artifact-explorer/src/components/optimizer/OptimizerProbabilityBreakdown.vue create mode 100644 wasmegg/artifact-explorer/src/components/optimizer/OptimizerSolutionCard.vue create mode 100644 wasmegg/artifact-explorer/src/components/optimizer/OptimizerToolbar.vue create mode 100644 wasmegg/artifact-explorer/src/lib/loot.spec.ts create mode 100644 wasmegg/artifact-explorer/src/lib/lp.spec.ts create mode 100644 wasmegg/artifact-explorer/src/lib/lp.ts create mode 100644 wasmegg/artifact-explorer/src/lib/multi-target.spec.ts create mode 100644 wasmegg/artifact-explorer/src/lib/optimizer-core.spec.ts create mode 100644 wasmegg/artifact-explorer/src/lib/optimizer-core.ts create mode 100644 wasmegg/artifact-explorer/src/lib/optimizer-views.spec.ts create mode 100644 wasmegg/artifact-explorer/src/lib/optimizer-views.ts create mode 100644 wasmegg/artifact-explorer/src/lib/phases.ts create mode 100644 wasmegg/artifact-explorer/src/lib/pipeline.spec.ts create mode 100644 wasmegg/artifact-explorer/src/lib/spec-helpers.ts create mode 100644 wasmegg/artifact-explorer/src/lib/types.ts create mode 100644 wasmegg/artifact-explorer/src/lib/value-function.spec.ts create mode 100644 wasmegg/artifact-explorer/src/lib/value-function.ts create mode 100644 wasmegg/artifact-explorer/src/store/schema.ts create mode 100644 wasmegg/artifact-explorer/src/store/store.spec.ts create mode 100644 wasmegg/artifact-explorer/src/views/FuelTankPlanner.vue create mode 100644 wasmegg/artifact-explorer/vitest.config.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index fbff1b08a..d421ffb3b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,8 @@ "Bash(python3:*)", "Bash(pnpm build:*)", "Bash(grep:*)", - "Bash(node -e:*)" + "Bash(node -e:*)", + "Bash(pnpm test *)" ] } } diff --git a/.devcontainer/Containerfile b/.devcontainer/Containerfile index ad37dce60..2528700fb 100644 --- a/.devcontainer/Containerfile +++ b/.devcontainer/Containerfile @@ -6,15 +6,23 @@ RUN yum install -y \ git \ xz \ golang \ + libatomic \ + gpg \ && \ + useradd dev && \ mkdir /opt/node && \ - curl -fsSL https://nodejs.org/dist/v24.13.1/node-v24.13.1-linux-x64.tar.xz | tar xJ -C /opt/node && \ - mkdir /opt/protoc && cd /opt/protoc && \ + mkdir /opt/protoc && \ + mkdir /opt/ets && \ + chown -R dev:dev /opt/ + +USER dev + +RUN curl -fsSL https://nodejs.org/dist/v24.13.1/node-v24.13.1-linux-x64.tar.xz | tar xJ -C /opt/node && \ + cd /opt/protoc && \ curl -fsSL https://github.com/protocolbuffers/protobuf/releases/download/v33.5/protoc-33.5-linux-x86_64.zip -o protoc.zip && \ unzip protoc.zip && rm protoc.zip && \ go install github.com/pseudomuto/protoc-gen-doc/cmd/protoc-gen-doc@latest && \ curl -fsSL https://get.pnpm.io/install.sh | ENV="$HOME/.bashrc" SHELL="$(which bash)" bash - && \ - mkdir /opt/ets && \ curl -fsSL https://github.com/zmwangx/ets/releases/download/v0.2.2/ets_0.2.2_linux_amd64.tar.gz | tar zx -C /opt/ets ENV PATH=$PATH:/opt/protoc/bin:/opt/ets:/opt/node/node-v24.13.1-linux-x64/bin diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5dec72820..caf31e24d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,14 +2,25 @@ "name": "WASMEGG", "build": { "dockerfile": "Containerfile", - "args": {} + "args": {} + }, + "remoteUser": "dev", + "remoteEnv": { + "CLAUDE_CONFIG_DIR": "/opt/.claude" }, "customizations": { + "vscode": { "extensions": [ "vue.volar", "pbkit.vscode-pbkit" ] } - } -} + }, + "features": { + "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {} + }, + "mounts": [ + "source=claude-code-config,target=/opt/.claude,type=volume" + ] +} \ No newline at end of file diff --git a/lib/missions.ts b/lib/missions.ts index 3e9d982e2..99da21615 100644 --- a/lib/missions.ts +++ b/lib/missions.ts @@ -499,6 +499,13 @@ export class MissionType { ); } + maxBoostedCapacity(): number { + return Math.floor( + (this.defaultCapacity + this.params.levelCapacityBump * this.maxLevel) * + (1.5 ) // 10 Zero-G Levels + ); + } + boostedQuality(config: ShipsConfig): number { return ( Math.round((this.params.quality + this.params.levelQualityBump * config.shipLevels[this.shipType]) * 100) / 100 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ece2eca45..fa35a84e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,22 +10,22 @@ importers: devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.1.0(jiti@2.4.2)) + version: 10.0.1(eslint@10.1.0(jiti@1.21.7)) '@typescript-eslint/parser': specifier: ^8.58.0 - version: 8.58.0(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2) + version: 8.58.0(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2) eslint: specifier: ^10.1.0 - version: 10.1.0(jiti@2.4.2) + version: 10.1.0(jiti@1.21.7) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.1.0(jiti@2.4.2)) + version: 10.1.8(eslint@10.1.0(jiti@1.21.7)) eslint-plugin-prettier: specifier: ^5.5.5 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@2.4.2)))(eslint@10.1.0(jiti@2.4.2))(prettier@3.8.1) + version: 5.5.5(eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@1.21.7)))(eslint@10.1.0(jiti@1.21.7))(prettier@3.8.1) eslint-plugin-vue: specifier: ^10.8.0 - version: 10.8.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2))(eslint@10.1.0(jiti@2.4.2))(vue-eslint-parser@10.4.0(eslint@10.1.0(jiti@2.4.2))) + version: 10.8.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2))(eslint@10.1.0(jiti@1.21.7))(vue-eslint-parser@10.4.0(eslint@10.1.0(jiti@1.21.7))) globals: specifier: ^17.4.0 version: 17.4.0 @@ -43,10 +43,10 @@ importers: version: 6.0.2 typescript-eslint: specifier: ^8.58.0 - version: 8.58.0(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2) + version: 8.58.0(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2) vue-eslint-parser: specifier: ^10.4.0 - version: 10.4.0(eslint@10.1.0(jiti@2.4.2)) + version: 10.4.0(eslint@10.1.0(jiti@1.21.7)) eicoop: dependencies: @@ -95,13 +95,13 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.17) '@types/node': specifier: ^25.5.0 version: 25.5.0 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -128,19 +128,19 @@ importers: version: 8.0.0 tailwindcss: specifier: ^3.4.17 - version: 3.4.17(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.17 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vite-plugin-image-optimizer: specifier: ^2.0.3 - version: 2.0.3(sharp@0.34.5)(svgo@4.0.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3)) + version: 2.0.3(sharp@0.34.5)(svgo@4.0.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3)) vite-plugin-mock: specifier: ^3.0.2 - version: 3.0.2(esbuild@0.27.4)(mockjs@1.1.0)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3)) + version: 3.0.2(esbuild@0.27.4)(mockjs@1.1.0)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3)) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -192,7 +192,7 @@ importers: version: 30.3.0(@babel/core@7.29.0) jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 30.3.0(@types/node@25.5.0) pinia: specifier: ^3.0.4 version: 3.0.4(typescript@6.0.2)(vue@3.5.31(typescript@6.0.2)) @@ -201,7 +201,7 @@ importers: version: 2.0.0(protobufjs@8.0.0) ts-jest: specifier: ^29.4.9 - version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)))(typescript@6.0.2) + version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.5.0))(typescript@6.0.2) vue: specifier: ^3.5.31 version: 3.5.31(typescript@6.0.2) @@ -244,10 +244,10 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.4) '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -265,16 +265,16 @@ importers: version: 4.0.1 tailwindcss: specifier: ^3.4.4 - version: 3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.4 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vite-plugin-image-optimizer: specifier: ^2.0.3 - version: 2.0.3(sharp@0.34.5)(svgo@4.0.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3)) + version: 2.0.3(sharp@0.34.5)(svgo@4.0.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3)) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -314,31 +314,40 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.4) '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 5.1.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + '@vitest/coverage-v8': + specifier: ^4.1.8 + version: 4.1.8(vitest@4.1.8) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 autoprefixer: specifier: ^10.4.27 version: 10.4.27(postcss@8.5.8) + pinia: + specifier: ^3.0.4 + version: 3.0.4(typescript@6.0.2)(vue@3.5.31(typescript@6.0.2)) postcss: specifier: ^8.5.8 version: 8.5.8 tailwindcss: specifier: ^3.4.4 - version: 3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.4 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) + vitest: + specifier: ^4.0.0 + version: 4.1.8(@types/node@25.5.0)(@vitest/coverage-v8@4.1.8)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3)) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -381,13 +390,13 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.4) '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 5.1.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -399,13 +408,13 @@ importers: version: 8.5.8 tailwindcss: specifier: ^3.4.4 - version: 3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.4 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -436,13 +445,13 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.17) '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 5.1.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -454,13 +463,13 @@ importers: version: 8.5.8 tailwindcss: specifier: ^3.4.4 - version: 3.4.17(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.17 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -491,10 +500,10 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.4) '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -506,13 +515,13 @@ importers: version: 8.5.8 tailwindcss: specifier: ^3.4.4 - version: 3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.4 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -558,10 +567,10 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.4) '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -576,13 +585,13 @@ importers: version: 8.5.8 tailwindcss: specifier: ^3.4.4 - version: 3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.4 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -622,13 +631,13 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.4) '@types/uuid': specifier: ^11.0.0 version: 11.0.0 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -646,13 +655,13 @@ importers: version: 8.0.0 tailwindcss: specifier: ^3.4.4 - version: 3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.4 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -686,10 +695,10 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.4) '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -701,13 +710,13 @@ importers: version: 8.5.8 tailwindcss: specifier: ^3.4.4 - version: 3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.4 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -747,13 +756,13 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.4) '@types/fontfaceobserver': specifier: ^2.1.3 version: 2.1.3 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -768,13 +777,13 @@ importers: version: 8.5.8 tailwindcss: specifier: ^3.4.4 - version: 3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.4 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -832,7 +841,7 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.4) '@types/diff': specifier: ^8.0.0 version: 8.0.0 @@ -841,10 +850,10 @@ importers: version: 3.0.5 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 5.1.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -856,13 +865,13 @@ importers: version: 8.5.8 tailwindcss: specifier: ^3.4.4 - version: 3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.4 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -893,10 +902,10 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.4) '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -908,13 +917,13 @@ importers: version: 8.5.8 tailwindcss: specifier: ^3.4.4 - version: 3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.4 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -945,7 +954,7 @@ importers: devDependencies: '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -960,7 +969,7 @@ importers: version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -1000,10 +1009,10 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.4) '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -1018,13 +1027,13 @@ importers: version: 8.5.8 tailwindcss: specifier: ^3.4.4 - version: 3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.4 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -1146,13 +1155,13 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.4) '@types/sql.js': specifier: ^1.4.10 version: 1.4.10 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -1164,13 +1173,13 @@ importers: version: 8.5.8 tailwindcss: specifier: ^3.4.4 - version: 3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.4 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -1231,13 +1240,13 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.4) '@types/uuid': specifier: ^11.0.0 version: 11.0.0 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -1258,16 +1267,16 @@ importers: version: 4.0.1 tailwindcss: specifier: ^3.4.4 - version: 3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.4 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vite-plugin-image-optimizer: specifier: ^2.0.3 - version: 2.0.3(sharp@0.34.5)(svgo@4.0.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3)) + version: 2.0.3(sharp@0.34.5)(svgo@4.0.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3)) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -1301,10 +1310,10 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.4) '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -1319,13 +1328,13 @@ importers: version: 8.5.8 tailwindcss: specifier: ^3.4.4 - version: 3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.4 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -1362,16 +1371,16 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.4) '@types/uuid': specifier: ^11.0.0 version: 11.0.0 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vitejs/plugin-vue-jsx': specifier: ^5.1.5 - version: 5.1.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 5.1.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -1386,13 +1395,13 @@ importers: version: 8.5.8 tailwindcss: specifier: ^3.4.4 - version: 3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.4 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -1432,13 +1441,13 @@ importers: devDependencies: '@tailwindcss/forms': specifier: ^0.5.10 - version: 0.5.10(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))) + version: 0.5.10(tailwindcss@3.4.17) '@types/uuid': specifier: ^11.0.0 version: 11.0.0 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) + version: 6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2)) '@vue/compiler-sfc': specifier: ^3.5.31 version: 3.5.31 @@ -1456,13 +1465,13 @@ importers: version: 8.0.0 tailwindcss: specifier: ^3.4.4 - version: 3.4.17(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + version: 3.4.17 typescript: specifier: ^6.0.2 version: 6.0.2 vite: specifier: ^8.0.5 - version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + version: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@6.0.2) @@ -1543,10 +1552,18 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -1560,6 +1577,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-async-generators@7.8.4': resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} peerDependencies: @@ -1675,9 +1697,17 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@codemirror/autocomplete@6.20.1': resolution: {integrity: sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==} @@ -1702,10 +1732,6 @@ packages: '@codemirror/view@6.40.0': resolution: {integrity: sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==} - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} - '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} @@ -2232,9 +2258,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@jsdoc/salty@0.2.7': resolution: {integrity: sha512-mh8LbS9d4Jq84KLw8pzho7XC2q2/IJGiJss3xwRoLD1A+EE16SjN4PfaG4jRCzKegTFLlN0Zd8SdUPE6XdoPFg==} engines: {node: '>=v12.0.0'} @@ -2439,6 +2462,9 @@ packages: '@sinonjs/fake-timers@15.1.1': resolution: {integrity: sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/forms@0.5.10': resolution: {integrity: sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==} peerDependencies: @@ -2457,18 +2483,6 @@ packages: peerDependencies: vue: ^2.7.0 || ^3.0.0 - '@tsconfig/node10@1.0.12': - resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} - - '@tsconfig/node12@1.0.11': - resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} - - '@tsconfig/node14@1.0.3': - resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} - - '@tsconfig/node16@1.0.4': - resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -2484,6 +2498,12 @@ packages: '@types/babel__traverse@7.20.5': resolution: {integrity: sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/diff@8.0.0': resolution: {integrity: sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==} deprecated: This is a stub types definition. diff provides its own type definitions, so you do not need this installed. @@ -2762,6 +2782,44 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 + '@vitest/coverage-v8@4.1.8': + resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} + peerDependencies: + '@vitest/browser': 4.1.8 + vitest: 4.1.8 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -2860,10 +2918,6 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn-walk@8.3.5: - resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} - engines: {node: '>=0.4.0'} - acorn@8.14.0: resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} engines: {node: '>=0.4.0'} @@ -2923,9 +2977,6 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - arg@4.1.3: - resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -2939,10 +2990,17 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-kit@2.2.0: resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} engines: {node: '>=20.19.0'} + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + ast-walker-scope@0.8.3: resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==} engines: {node: '>=20.19.0'} @@ -3087,6 +3145,10 @@ packages: resolution: {integrity: sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==} engines: {node: '>= 10'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@1.1.3: resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==} engines: {node: '>=0.10.0'} @@ -3178,9 +3240,6 @@ packages: resolution: {integrity: sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A==} engines: {node: '>=12'} - create-require@1.1.1: - resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -3287,10 +3346,6 @@ packages: resolution: {integrity: sha512-u9gfn+BlbHcyO7vItCIC4z49LJDUt31tODzOfAuJ5R1E7IdlRL6KjugcB9zOpejD+XiR+dDZbsnHSQ3g6A/u8A==} engines: {node: '>=12'} - diff@4.0.4: - resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} - engines: {node: '>=0.3.1'} - diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -3362,6 +3417,9 @@ packages: error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + esbuild@0.27.4: resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} @@ -3489,6 +3547,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -3501,6 +3562,10 @@ packages: resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} engines: {node: '>= 0.8.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + expect@30.3.0: resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -3809,6 +3874,10 @@ packages: resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} engines: {node: '>=8'} + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -3948,13 +4017,12 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true - jiti@2.4.2: - resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} - hasBin: true - js-sha256@0.11.1: resolution: {integrity: sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4159,6 +4227,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -4320,6 +4391,10 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + obug@2.1.2: + resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} + engines: {node: '>=12.20.0'} + on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} engines: {node: '>= 0.8'} @@ -4722,6 +4797,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -4758,10 +4836,16 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-length@4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} @@ -4917,10 +5001,21 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tippy.js@6.3.7: resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} @@ -4977,20 +5072,6 @@ packages: jest-util: optional: true - ts-node@10.9.2: - resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} - hasBin: true - peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '*' - typescript: '>=2.7' - peerDependenciesMeta: - '@swc/core': - optional: true - '@swc/wasm': - optional: true - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -5094,9 +5175,6 @@ packages: resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} hasBin: true - v8-compile-cache-lib@3.0.1: - resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - v8-to-istanbul@9.2.0: resolution: {integrity: sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==} engines: {node: '>=10.12.0'} @@ -5165,6 +5243,47 @@ packages: yaml: optional: true + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} @@ -5233,6 +5352,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -5287,10 +5411,6 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yn@3.1.1: - resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} - engines: {node: '>=6'} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -5412,8 +5532,12 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.29.2': @@ -5425,6 +5549,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5552,8 +5680,15 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@0.2.3': {} + '@bcoe/v8-coverage@1.0.2': {} + '@codemirror/autocomplete@6.20.1': dependencies: '@codemirror/language': 6.12.3 @@ -5605,11 +5740,6 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - optional: true - '@emnapi/core@1.9.1': dependencies: '@emnapi/wasi-threads': 1.2.0 @@ -5704,9 +5834,9 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@2.4.2))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@1.21.7))': dependencies: - eslint: 10.1.0(jiti@2.4.2) + eslint: 10.1.0(jiti@1.21.7) eslint-visitor-keys: 3.4.3 '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': @@ -5746,9 +5876,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@10.0.1(eslint@10.1.0(jiti@2.4.2))': + '@eslint/js@10.0.1(eslint@10.1.0(jiti@1.21.7))': optionalDependencies: - eslint: 10.1.0(jiti@2.4.2) + eslint: 10.1.0(jiti@1.21.7) '@eslint/js@8.57.1': {} @@ -5913,7 +6043,7 @@ snapshots: jest-util: 30.3.0 slash: 3.0.0 - '@jest/core@30.3.0(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2))': + '@jest/core@30.3.0': dependencies: '@jest/console': 30.3.0 '@jest/pattern': 30.0.1 @@ -5928,7 +6058,7 @@ snapshots: exit-x: 0.2.2 graceful-fs: 4.2.11 jest-changed-files: 30.3.0 - jest-config: 30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + jest-config: 30.3.0(@types/node@25.5.0) jest-haste-map: 30.3.0 jest-message-util: 30.3.0 jest-regex-util: 30.0.1 @@ -6117,12 +6247,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - optional: true - '@jsdoc/salty@0.2.7': dependencies: lodash: 4.17.23 @@ -6275,15 +6399,17 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@tailwindcss/forms@0.5.10(tailwindcss@3.4.17(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)))': + '@standard-schema/spec@1.1.0': {} + + '@tailwindcss/forms@0.5.10(tailwindcss@3.4.17)': dependencies: mini-svg-data-uri: 1.4.4 - tailwindcss: 3.4.17(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + tailwindcss: 3.4.17 - '@tailwindcss/forms@0.5.10(tailwindcss@3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)))': + '@tailwindcss/forms@0.5.10(tailwindcss@3.4.4)': dependencies: mini-svg-data-uri: 1.4.4 - tailwindcss: 3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + tailwindcss: 3.4.4 '@tailwindcss/forms@0.5.11(tailwindcss@3.4.19(yaml@2.8.3))': dependencies: @@ -6297,18 +6423,6 @@ snapshots: '@tanstack/virtual-core': 3.13.23 vue: 3.5.31(typescript@6.0.2) - '@tsconfig/node10@1.0.12': - optional: true - - '@tsconfig/node12@1.0.11': - optional: true - - '@tsconfig/node14@1.0.3': - optional: true - - '@tsconfig/node16@1.0.4': - optional: true - '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -6335,6 +6449,13 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/diff@8.0.0': dependencies: diff: 8.0.4 @@ -6400,15 +6521,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2))(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2)': + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2))(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2) + '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2) '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2) - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2) + '@typescript-eslint/type-utils': 8.58.0(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2) + '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2) '@typescript-eslint/visitor-keys': 8.58.0 - eslint: 10.1.0(jiti@2.4.2) + eslint: 10.1.0(jiti@1.21.7) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.2) @@ -6429,14 +6550,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2)': + '@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2)': dependencies: '@typescript-eslint/scope-manager': 8.58.0 '@typescript-eslint/types': 8.58.0 '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 - eslint: 10.1.0(jiti@2.4.2) + eslint: 10.1.0(jiti@1.21.7) typescript: 6.0.2 transitivePeerDependencies: - supports-color @@ -6464,13 +6585,13 @@ snapshots: dependencies: typescript: 6.0.2 - '@typescript-eslint/type-utils@8.58.0(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2)': + '@typescript-eslint/type-utils@8.58.0(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2)': dependencies: '@typescript-eslint/types': 8.58.0 '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2) + '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2) debug: 4.4.3 - eslint: 10.1.0(jiti@2.4.2) + eslint: 10.1.0(jiti@1.21.7) ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 transitivePeerDependencies: @@ -6510,13 +6631,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.58.0(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2)': + '@typescript-eslint/utils@8.58.0(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@1.21.7)) '@typescript-eslint/scope-manager': 8.58.0 '@typescript-eslint/types': 8.58.0 '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) - eslint: 10.1.0(jiti@2.4.2) + eslint: 10.1.0(jiti@1.21.7) typescript: 6.0.2 transitivePeerDependencies: - supports-color @@ -6592,14 +6713,14 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitejs/plugin-vue-jsx@5.1.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2))': + '@vitejs/plugin-vue-jsx@5.1.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) '@rolldown/pluginutils': 1.0.0-rc.10 '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.0) - vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vue: 3.5.31(typescript@6.0.2) transitivePeerDependencies: - supports-color @@ -6610,11 +6731,60 @@ snapshots: vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) vue: 3.5.31(typescript@6.0.2) - '@vitejs/plugin-vue@6.0.5(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3))(vue@3.5.31(typescript@6.0.2))': + '@vitest/coverage-v8@4.1.8(vitest@4.1.8)': dependencies: - '@rolldown/pluginutils': 1.0.0-rc.2 - vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) - vue: 3.5.31(typescript@6.0.2) + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.8 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.2 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.8(@types/node@25.5.0)(@vitest/coverage-v8@4.1.8)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3)) + + '@vitest/expect@4.1.8': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.8(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) + + '@vitest/pretty-format@4.1.8': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.8': + dependencies: + '@vitest/utils': 4.1.8 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.8': {} + + '@vitest/utils@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 '@volar/language-core@2.4.28': dependencies: @@ -6776,11 +6946,6 @@ snapshots: dependencies: acorn: 8.16.0 - acorn-walk@8.3.5: - dependencies: - acorn: 8.16.0 - optional: true - acorn@8.14.0: {} acorn@8.16.0: {} @@ -6823,9 +6988,6 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 - arg@4.1.3: - optional: true - arg@5.0.2: {} argparse@1.0.10: @@ -6836,11 +6998,19 @@ snapshots: array-union@2.1.0: {} + assertion-error@2.0.1: {} + ast-kit@2.2.0: dependencies: '@babel/parser': 7.29.2 pathe: 2.0.3 + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + ast-walker-scope@0.8.3: dependencies: '@babel/parser': 7.29.2 @@ -7003,6 +7173,8 @@ snapshots: dependencies: lodash: 4.17.23 + chai@6.2.2: {} + chalk@1.1.3: dependencies: ansi-styles: 2.2.1 @@ -7097,9 +7269,6 @@ snapshots: copy-text-to-clipboard@3.2.2: {} - create-require@1.1.1: - optional: true - crelt@1.0.6: {} cross-spawn@7.0.6: @@ -7185,9 +7354,6 @@ snapshots: optionalDependencies: highlight.js: 11.11.1 - diff@4.0.4: - optional: true - diff@8.0.4: {} dir-glob@3.0.1: @@ -7250,6 +7416,8 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-module-lexer@2.1.0: {} + esbuild@0.27.4: optionalDependencies: '@esbuild/aix-ppc64': 0.27.4 @@ -7298,31 +7466,31 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@2.4.2)): + eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@1.21.7)): dependencies: - eslint: 10.1.0(jiti@2.4.2) + eslint: 10.1.0(jiti@1.21.7) - eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@2.4.2)))(eslint@10.1.0(jiti@2.4.2))(prettier@3.8.1): + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@1.21.7)))(eslint@10.1.0(jiti@1.21.7))(prettier@3.8.1): dependencies: - eslint: 10.1.0(jiti@2.4.2) + eslint: 10.1.0(jiti@1.21.7) prettier: 3.8.1 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@10.1.0(jiti@2.4.2)) + eslint-config-prettier: 10.1.8(eslint@10.1.0(jiti@1.21.7)) - eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2))(eslint@10.1.0(jiti@2.4.2))(vue-eslint-parser@10.4.0(eslint@10.1.0(jiti@2.4.2))): + eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2))(eslint@10.1.0(jiti@1.21.7))(vue-eslint-parser@10.4.0(eslint@10.1.0(jiti@1.21.7))): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.4.2)) - eslint: 10.1.0(jiti@2.4.2) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@1.21.7)) + eslint: 10.1.0(jiti@1.21.7) natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 7.1.1 semver: 7.7.4 - vue-eslint-parser: 10.4.0(eslint@10.1.0(jiti@2.4.2)) + vue-eslint-parser: 10.4.0(eslint@10.1.0(jiti@1.21.7)) xml-name-validator: 4.0.0 optionalDependencies: - '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2) + '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2) eslint-scope@7.2.2: dependencies: @@ -7340,9 +7508,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.1.0(jiti@2.4.2): + eslint@10.1.0(jiti@1.21.7): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@1.21.7)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.3 '@eslint/config-helpers': 0.5.3 @@ -7373,7 +7541,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.4.2 + jiti: 1.21.7 transitivePeerDependencies: - supports-color @@ -7448,6 +7616,10 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} execa@5.1.1: @@ -7464,6 +7636,8 @@ snapshots: exit-x@0.2.2: {} + expect-type@1.3.0: {} + expect@30.3.0: dependencies: '@jest/expect-utils': 30.3.0 @@ -7782,6 +7956,11 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -7820,15 +7999,15 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)): + jest-cli@30.3.0(@types/node@25.5.0): dependencies: - '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + '@jest/core': 30.3.0 '@jest/test-result': 30.3.0 '@jest/types': 30.3.0 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + jest-config: 30.3.0(@types/node@25.5.0) jest-util: 30.3.0 jest-validate: 30.3.0 yargs: 17.7.2 @@ -7839,7 +8018,7 @@ snapshots: - supports-color - ts-node - jest-config@30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)): + jest-config@30.3.0(@types/node@25.5.0): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -7866,7 +8045,6 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 25.5.0 - ts-node: 10.9.2(@types/node@25.5.0)(typescript@6.0.2) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -8086,12 +8264,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)): + jest@30.3.0(@types/node@25.5.0): dependencies: - '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + '@jest/core': 30.3.0 '@jest/types': 30.3.0 import-local: 3.2.0 - jest-cli: 30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + jest-cli: 30.3.0(@types/node@25.5.0) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -8103,11 +8281,10 @@ snapshots: jiti@1.21.7: {} - jiti@2.4.2: - optional: true - js-sha256@0.11.1: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@3.14.1: @@ -8287,6 +8464,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + make-dir@4.0.0: dependencies: semver: 7.7.4 @@ -8423,6 +8606,8 @@ snapshots: object-hash@3.0.0: {} + obug@2.1.2: {} + on-finished@2.3.0: dependencies: ee-first: 1.1.1 @@ -8575,13 +8760,12 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.8 - postcss-load-config@4.0.2(postcss@8.5.8)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)): + postcss-load-config@4.0.2(postcss@8.5.8): dependencies: lilconfig: 3.1.3 yaml: 2.7.0 optionalDependencies: postcss: 8.5.8 - ts-node: 10.9.2(@types/node@25.5.0)(typescript@6.0.2) postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8)(yaml@2.8.3): dependencies: @@ -8844,6 +9028,8 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -8869,8 +9055,12 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stackback@0.0.2: {} + statuses@1.5.0: {} + std-env@4.1.0: {} + string-length@4.0.2: dependencies: char-regex: 1.0.2 @@ -8993,7 +9183,7 @@ snapshots: syncpack-windows-arm64: 14.3.0 syncpack-windows-x64: 14.3.0 - tailwindcss@3.4.17(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)): + tailwindcss@3.4.17: dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -9012,7 +9202,7 @@ snapshots: postcss: 8.5.8 postcss-import: 15.1.0(postcss@8.5.8) postcss-js: 4.0.1(postcss@8.5.8) - postcss-load-config: 4.0.2(postcss@8.5.8)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + postcss-load-config: 4.0.2(postcss@8.5.8) postcss-nested: 6.2.0(postcss@8.5.8) postcss-selector-parser: 6.1.2 resolve: 1.22.10 @@ -9048,7 +9238,7 @@ snapshots: - tsx - yaml - tailwindcss@3.4.4(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)): + tailwindcss@3.4.4: dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -9067,7 +9257,7 @@ snapshots: postcss: 8.5.8 postcss-import: 15.1.0(postcss@8.5.8) postcss-js: 4.0.1(postcss@8.5.8) - postcss-load-config: 4.0.2(postcss@8.5.8)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + postcss-load-config: 4.0.2(postcss@8.5.8) postcss-nested: 6.0.1(postcss@8.5.8) postcss-selector-parser: 6.0.16 resolve: 1.22.8 @@ -9095,11 +9285,17 @@ snapshots: dependencies: any-promise: 1.3.0 + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + tippy.js@6.3.7: dependencies: '@popperjs/core': 2.11.8 @@ -9122,12 +9318,12 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)))(typescript@6.0.2): + ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@30.3.0(@types/node@25.5.0))(typescript@6.0.2): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.9 - jest: 30.3.0(@types/node@25.5.0)(ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2)) + jest: 30.3.0(@types/node@25.5.0) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -9142,25 +9338,6 @@ snapshots: babel-jest: 30.3.0(@babel/core@7.29.0) jest-util: 30.3.0 - ts-node@10.9.2(@types/node@25.5.0)(typescript@6.0.2): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.12 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 25.5.0 - acorn: 8.16.0 - acorn-walk: 8.3.5 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.4 - make-error: 1.3.6 - typescript: 6.0.2 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optional: true - tslib@2.8.1: {} type-check@0.3.2: @@ -9179,13 +9356,13 @@ snapshots: type-fest@4.41.0: {} - typescript-eslint@8.58.0(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2): + typescript-eslint@8.58.0(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2): dependencies: - '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2))(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2) - '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2) + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2))(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2) + '@typescript-eslint/parser': 8.58.0(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2) '@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2) - '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@2.4.2))(typescript@6.0.2) - eslint: 10.1.0(jiti@2.4.2) + '@typescript-eslint/utils': 8.58.0(eslint@10.1.0(jiti@1.21.7))(typescript@6.0.2) + eslint: 10.1.0(jiti@1.21.7) typescript: 6.0.2 transitivePeerDependencies: - supports-color @@ -9267,25 +9444,22 @@ snapshots: uuid@13.0.0: {} - v8-compile-cache-lib@3.0.1: - optional: true - v8-to-istanbul@9.2.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 - vite-plugin-image-optimizer@2.0.3(sharp@0.34.5)(svgo@4.0.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3)): + vite-plugin-image-optimizer@2.0.3(sharp@0.34.5)(svgo@4.0.1)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3)): dependencies: ansi-colors: 4.1.3 pathe: 2.0.3 - vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) optionalDependencies: sharp: 0.34.5 svgo: 4.0.1 - vite-plugin-mock@3.0.2(esbuild@0.27.4)(mockjs@1.1.0)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3)): + vite-plugin-mock@3.0.2(esbuild@0.27.4)(mockjs@1.1.0)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3)): dependencies: bundle-require: 4.2.1(esbuild@0.27.4) chokidar: 3.6.0 @@ -9296,7 +9470,7 @@ snapshots: mockjs: 1.1.0 path-to-regexp: 6.3.0 picocolors: 1.1.1 - vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3) + vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -9317,29 +9491,40 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.4.2)(yaml@2.8.3): - dependencies: - lightningcss: 1.32.0 + vitest@4.1.8(@types/node@25.5.0)(@vitest/coverage-v8@4.1.8)(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.2 + pathe: 2.0.3 picomatch: 4.0.4 - postcss: 8.5.8 - rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.5(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@1.21.7)(yaml@2.8.3) + why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.5.0 - esbuild: 0.27.4 - fsevents: 2.3.3 - jiti: 2.4.2 - yaml: 2.8.3 + '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + - msw vscode-uri@3.1.0: {} - vue-eslint-parser@10.4.0(eslint@10.1.0(jiti@2.4.2)): + vue-eslint-parser@10.4.0(eslint@10.1.0(jiti@1.21.7)): dependencies: debug: 4.4.3 - eslint: 10.1.0(jiti@2.4.2) + eslint: 10.1.0(jiti@1.21.7) eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 espree: 11.2.0 @@ -9423,6 +9608,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wordwrap@1.0.0: {} @@ -9470,9 +9660,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yn@3.1.1: - optional: true - yocto-queue@0.1.0: {} zod@4.3.6: {} diff --git a/wasmegg/artifact-explorer/package.json b/wasmegg/artifact-explorer/package.json index 5fcc20fe2..23d88978f 100644 --- a/wasmegg/artifact-explorer/package.json +++ b/wasmegg/artifact-explorer/package.json @@ -19,11 +19,14 @@ "@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue-jsx": "^5.1.5", "@vue/compiler-sfc": "^3.5.31", + "@vitest/coverage-v8": "^4.1.8", "autoprefixer": "^10.4.27", + "pinia": "^3.0.4", "postcss": "^8.5.8", "tailwindcss": "^3.4.4", "typescript": "^6.0.2", "vite": "^8.0.5", + "vitest": "^4.0.0", "vue-tsc": "^3.2.6" }, "license": "MIT", @@ -33,6 +36,9 @@ "fastbuild": "vite build", "lint": "eslint --ext .js,.ts,.vue src", "serve": "vite preview", - "format": "prettier --write \"src/**/*.{js,ts,vue}\"" + "format": "prettier --write \"src/**/*.{js,ts,vue}\"", + "test": "vitest run", + "coverage": "vitest run --coverage", + "test:watch": "vitest" } } diff --git a/wasmegg/artifact-explorer/src/App.vue b/wasmegg/artifact-explorer/src/App.vue index 8c63e30fe..ac178ae29 100644 --- a/wasmegg/artifact-explorer/src/App.vue +++ b/wasmegg/artifact-explorer/src/App.vue @@ -11,6 +11,7 @@ + diff --git a/wasmegg/artifact-explorer/src/components/ArtifactDropStats.vue b/wasmegg/artifact-explorer/src/components/ArtifactDropStats.vue index cbdd5ab60..99d03f28a 100644 --- a/wasmegg/artifact-explorer/src/components/ArtifactDropStats.vue +++ b/wasmegg/artifact-explorer/src/components/ArtifactDropStats.vue @@ -97,7 +97,7 @@ export default defineComponent({ ); const afxId = computed(() => getArtifactTierPropsFromId(artifactId.value).afx_id); const expand = ref(getLocalStorage(COLLAPSE_ARTIFACT_DROP_RATES_LOCALSTORAGE_KEY) !== 'true'); - const loot = computed(() => getTierLootData(artifactId.value)); + const loot = computed(() => getTierLootData(artifactId.value, config.value.targets)); const filteredMissions = computed(() => { let filtered: ItemMissionLootStore[] = []; if (config.value.onlyHenners) { diff --git a/wasmegg/artifact-explorer/src/components/ArtifactMissionOptimizer.vue b/wasmegg/artifact-explorer/src/components/ArtifactMissionOptimizer.vue new file mode 100644 index 000000000..c4b254e32 --- /dev/null +++ b/wasmegg/artifact-explorer/src/components/ArtifactMissionOptimizer.vue @@ -0,0 +1,181 @@ + + + diff --git a/wasmegg/artifact-explorer/src/components/PlayerOverridesModal.vue b/wasmegg/artifact-explorer/src/components/PlayerOverridesModal.vue new file mode 100644 index 000000000..bbb358694 --- /dev/null +++ b/wasmegg/artifact-explorer/src/components/PlayerOverridesModal.vue @@ -0,0 +1,578 @@ + + + + + diff --git a/wasmegg/artifact-explorer/src/components/ShipStars.ts b/wasmegg/artifact-explorer/src/components/ShipStars.ts new file mode 100644 index 000000000..71237984a --- /dev/null +++ b/wasmegg/artifact-explorer/src/components/ShipStars.ts @@ -0,0 +1,68 @@ +// Star rating for a ship's level: a ban icon (level 0) followed by `max` +// stars. When interactive, clicking emits set(0..max). Render function +// rather than an SFC since it's almost entirely inline SVG. + +import { defineComponent, h } from 'vue'; + +export default defineComponent({ + name: 'ShipStars', + props: { + level: { type: Number, required: true }, + max: { type: Number, required: true }, + interactive: { type: Boolean, default: false }, + }, + emits: ['set'], + setup(props, { emit }) { + return () => { + const stars = []; + stars.push( + h( + 'svg', + { + viewBox: '0 0 512 512', + class: [ + 'h-3 w-3 text-gray-400 relative top-px mr-0.5 select-none', + props.interactive ? 'cursor-pointer' : 'cursor-default opacity-50', + ], + onClick: () => props.interactive && emit('set', 0), + }, + [ + h('path', { + fill: 'currentColor', + d: 'M256 8C119.034 8 8 119.033 8 256s111.034 248 248 248 248-111.034 248-248S392.967 8 256 8zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676zM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676z', + }), + ] + ) + ); + for (let i = 1; i <= props.max; i++) { + const filled = i <= props.level; + stars.push( + h( + 'svg', + { + key: i, + viewBox: '0 0 576 512', + class: [ + 'h-3.5 w-3.5 text-yellow-400 select-none', + props.interactive ? 'cursor-pointer' : 'cursor-default', + ], + onClick: () => props.interactive && emit('set', i), + }, + [ + filled + ? h('path', { + fill: 'currentColor', + d: 'M259.3 17.8L194 150.2 47.9 171.5c-26.2 3.8-36.7 36.1-17.7 54.6l105.7 103-25 145.5c-4.5 26.3 23.2 46 46.4 33.7L288 439.6l130.7 68.7c23.2 12.2 50.9-7.4 46.4-33.7l-25-145.5 105.7-103c19-18.5 8.5-50.8-17.7-54.6L382 150.2 316.7 17.8c-11.7-23.6-45.6-23.9-57.4 0z', + }) + : h('path', { + fill: 'currentColor', + d: 'M528.1 171.5L382 150.2 316.7 17.8c-11.7-23.6-45.6-23.9-57.4 0L194 150.2 47.9 171.5c-26.2 3.8-36.7 36.1-17.7 54.6l105.7 103-25 145.5c-4.5 26.3 23.2 46 46.4 33.7L288 439.6l130.7 68.7c23.2 12.2 50.9-7.4 46.4-33.7l-25-145.5 105.7-103c19-18.5 8.5-50.8-17.7-54.6zM388.6 312.3l23.7 138.4L288 385.4l-124.3 65.3 23.7-138.4-100.6-98 139-20.2 62.2-126 62.2 126 139 20.2-100.6 98z', + }), + ] + ) + ); + } + return h('div', { class: 'flex items-center space-x-0.5' }, stars); + }; + }, +}); diff --git a/wasmegg/artifact-explorer/src/components/TankArtifactSelector.vue b/wasmegg/artifact-explorer/src/components/TankArtifactSelector.vue new file mode 100644 index 000000000..0d6576bea --- /dev/null +++ b/wasmegg/artifact-explorer/src/components/TankArtifactSelector.vue @@ -0,0 +1,39 @@ + + + diff --git a/wasmegg/artifact-explorer/src/components/optimizer/OptimizerChoiceList.vue b/wasmegg/artifact-explorer/src/components/optimizer/OptimizerChoiceList.vue new file mode 100644 index 000000000..858962626 --- /dev/null +++ b/wasmegg/artifact-explorer/src/components/optimizer/OptimizerChoiceList.vue @@ -0,0 +1,25 @@ + + + diff --git a/wasmegg/artifact-explorer/src/components/optimizer/OptimizerExpectedDrops.vue b/wasmegg/artifact-explorer/src/components/optimizer/OptimizerExpectedDrops.vue new file mode 100644 index 000000000..ff8d9ac29 --- /dev/null +++ b/wasmegg/artifact-explorer/src/components/optimizer/OptimizerExpectedDrops.vue @@ -0,0 +1,31 @@ + + + diff --git a/wasmegg/artifact-explorer/src/components/optimizer/OptimizerInventoryPanel.vue b/wasmegg/artifact-explorer/src/components/optimizer/OptimizerInventoryPanel.vue new file mode 100644 index 000000000..f155c8555 --- /dev/null +++ b/wasmegg/artifact-explorer/src/components/optimizer/OptimizerInventoryPanel.vue @@ -0,0 +1,36 @@ + + + diff --git a/wasmegg/artifact-explorer/src/components/optimizer/OptimizerProbabilityBreakdown.vue b/wasmegg/artifact-explorer/src/components/optimizer/OptimizerProbabilityBreakdown.vue new file mode 100644 index 000000000..5f69d78be --- /dev/null +++ b/wasmegg/artifact-explorer/src/components/optimizer/OptimizerProbabilityBreakdown.vue @@ -0,0 +1,101 @@ + + + diff --git a/wasmegg/artifact-explorer/src/components/optimizer/OptimizerSolutionCard.vue b/wasmegg/artifact-explorer/src/components/optimizer/OptimizerSolutionCard.vue new file mode 100644 index 000000000..1a595e405 --- /dev/null +++ b/wasmegg/artifact-explorer/src/components/optimizer/OptimizerSolutionCard.vue @@ -0,0 +1,78 @@ + + + diff --git a/wasmegg/artifact-explorer/src/components/optimizer/OptimizerToolbar.vue b/wasmegg/artifact-explorer/src/components/optimizer/OptimizerToolbar.vue new file mode 100644 index 000000000..4befa4d4c --- /dev/null +++ b/wasmegg/artifact-explorer/src/components/optimizer/OptimizerToolbar.vue @@ -0,0 +1,67 @@ + + + diff --git a/wasmegg/artifact-explorer/src/lib/filter.ts b/wasmegg/artifact-explorer/src/lib/filter.ts index 4aa0d31ad..bbc5a0132 100644 --- a/wasmegg/artifact-explorer/src/lib/filter.ts +++ b/wasmegg/artifact-explorer/src/lib/filter.ts @@ -1,4 +1,4 @@ -import { useSearch } from 'lib'; +import { ei, useSearch } from 'lib'; import { artifactTiers } from './artifacts'; import { missions } from './missions'; export { missions }; @@ -7,6 +7,21 @@ export const artifacts = artifactTiers.map(artifact => ({ ...artifact, display: `${artifact.name} (T${artifact.tier_number})`, })); +export const legendaryArtifacts = artifactTiers + .filter(artifact => { + if (!artifact.effects) return false; + + for (const rarity of artifact.effects) { + if (rarity.afx_rarity === ei.ArtifactSpec.Rarity.LEGENDARY) { + return true; + } + } + return false; + }) + .map(artifact => ({ + ...artifact, + display: `${artifact.name} (T${artifact.tier_number})`, + })); export const artifactIds = artifacts.map(artifact => artifact.id); export const artifactIdToArtifact = new Map(artifacts.map(artifact => [artifact.id, artifact])); export const missionIds = missions.map(mission => mission.missionTypeId); diff --git a/wasmegg/artifact-explorer/src/lib/index.ts b/wasmegg/artifact-explorer/src/lib/index.ts index a518c18f5..9b2e50a64 100644 --- a/wasmegg/artifact-explorer/src/lib/index.ts +++ b/wasmegg/artifact-explorer/src/lib/index.ts @@ -1,3 +1,155 @@ export * from './artifacts'; export * from './missions'; export * from './loot'; +export * from './optimizer-views'; + +import type { DAGNode, LaunchSolution, OptimizerConfig, OptimizerSolution, DropRow, RecipeDAG } from './types'; +import { enumerateLaunchOptions, generateRecipeDag } from './phases'; +import { optimizeFull } from './optimizer-core'; +import { ei, getArtifactTierPropsFromId, getCraftingLevelFromXp, Inventory, InventoryItem, ShipsConfig } from 'lib'; + +import { iconURL } from 'lib'; + +// Build the recipe DAG for the desired artifacts, plus the player's starting +// quantities for each ingredient node. Each root's legendaryCraftProbability +// comes from the player's crafting XP and prior craft count. +export function buildRecipeDag( + desiredArtifactNodeIds: string[], + playerInventory?: Inventory | null, + playerTotalCraftingXp?: number | null, + previousCraftsOverride?: number +): Map { + const recipeDag = new Map(); + // Default to full crafting XP because reasons + const xp = playerTotalCraftingXp ?? 10_000_000_000; + + for (const artifact of desiredArtifactNodeIds) { + generateRecipeDag(artifact, recipeDag); + const artifactProps = getArtifactTierPropsFromId(artifact); + const artifactItem = new InventoryItem(artifactProps.afx_id, artifactProps.afx_level); + const artifactDagNode = recipeDag.get(artifact)!; + const previousCrafts = + previousCraftsOverride !== undefined + ? previousCraftsOverride + : playerInventory + ? playerInventory.getItem({ name: artifactProps.afx_id, level: artifactProps.afx_level }).crafted + : 0; + + // craftChance returns a percentage value, not a raw probability + artifactDagNode.legendaryCraftProbability = + artifactItem.craftChance( + getCraftingLevelFromXp(xp).rarityMult, + ei.ArtifactSpec.Rarity.LEGENDARY, + previousCrafts + ) / 100.0; + } + + return recipeDag; +} + +export function computeBaseYield( + playerInventory: Inventory | null | undefined, + desiredArtifactNodeIds: string[], + recipeDag: Map +) { + const baseYield = new Map(); + + if (playerInventory) { + const rootIds = new Set(desiredArtifactNodeIds); + + for (const nodeId of recipeDag.keys()) { + if (rootIds.has(nodeId)) continue; + const props = getArtifactTierPropsFromId(nodeId); + const item = playerInventory.getItem({ name: props.afx_id, level: props.afx_level }); + const total = item.have; + if (total > 0) baseYield.set(nodeId, total); + } + } + + return baseYield; +} + +function computeExpectedDrops(solution: OptimizerSolution, dag: Map): DropRow[] { + const totals = new Map(); + + for (const choice of solution.choiceHistory) { + for (const [item, rate] of choice.supplyVector) { + totals.set(item, (totals.get(item) ?? 0) + (rate * choice.numShipsLaunched) / 3); + } + } + + const rows: DropRow[] = []; + for (const [itemId, expected] of totals) { + if (expected < 0.05) continue; + const props = getArtifactTierPropsFromId(itemId); + rows.push({ + itemId, + name: props.name, + iconUrl: iconURL('egginc/' + props.icon_filename, 64), + expected, + relevant: dag.has(itemId), + }); + } + rows.sort((a, b) => { + if (a.relevant !== b.relevant) return a.relevant ? -1 : 1; + return b.expected - a.expected; + }); + return rows; +} + +function computeFuelByEgg(solution: OptimizerSolution): Map { + const totals = new Map(); + + for (const choice of solution.choiceHistory) { + for (const [egg, rate] of choice.actualFuelByEgg) { + totals.set(egg, (totals.get(egg) ?? 0) + (rate * choice.numShipsLaunched) / 3.0); + } + } + + return totals; +} + +// Run the optimizer and fill in the presentation-only fields. Returns an +// array though today it's always one solution. May extend this to return +// top N solutions. +export function optimize( + config: OptimizerConfig, + playerConfig: ShipsConfig, + dag: RecipeDAG, + baseYield: Map, + minDurationSeconds?: number +) { + const { desiredArtifactNodeIds, fuelTankCapacity, timeBudgetSeconds } = config; + const options = enumerateLaunchOptions(playerConfig, dag, minDurationSeconds); + + const solutions: OptimizerSolution[] = [ + optimizeFull({ + options, + recipeDag: dag, + desiredArtifactNodeIds: desiredArtifactNodeIds, + fuelCapacity: fuelTankCapacity, + timeCapacity: timeBudgetSeconds, + baseYield: baseYield, + }), + ]; + + // Properties for presentation layer, easier to compute here + for (const solution of solutions) { + solution.choiceHistory.sort((a: LaunchSolution, b: LaunchSolution) => a.ship.shipType - b.ship.shipType); + solution.expectedDrops = computeExpectedDrops(solution, dag); + solution.fuelByEgg = computeFuelByEgg(solution); + } + + return solutions; +} + +export type { + OptimizerConfig, + OptimizerSolution, + LaunchOption, + LaunchSolution, + DropRow, + DAGNode, + DAGChildRef, + RecipeDAG, +} from './types'; diff --git a/wasmegg/artifact-explorer/src/lib/loot.spec.ts b/wasmegg/artifact-explorer/src/lib/loot.spec.ts new file mode 100644 index 000000000..a44e9e710 --- /dev/null +++ b/wasmegg/artifact-explorer/src/lib/loot.spec.ts @@ -0,0 +1,298 @@ +// Pins for the loot-data accessors. The real loot.json weighs ~80MB and is +// refreshed by update-loot-data.sh, so these specs swap in a hand-built +// three-mission dataset: assertions stay exact and don't churn when the live +// data is re-scraped. Mission and item ids are real ones so that +// getMissionTypeFromId / getArtifactTierPropsFromId resolve against lib. + +import { describe, expect, it, vi } from 'vitest'; +import { ei, getMissionTypeFromId, itemExpectedFullConsumption, targets, type ShipsConfig } from 'lib'; + +import type { lootjson, MissionLevelLootStore } from './loot-json'; +import lootFixture from './loot-json'; +import { + getMaxLegendaryCount, + getMissionLevelLootAverageConsumptionValue, + getMissionLootData, + getTierLootData, + hasMissionLootData, + legendaryDataIsSparse, + MIN_LEGENDARY_OBSERVATIONS, + missionDataNotEnough, +} from './loot'; + +const Name = ei.ArtifactSpec.Name; +const Level = ei.ArtifactSpec.Level; +const Rarity = ei.ArtifactSpec.Rarity; +const Ship = ei.MissionInfo.Spaceship; +const Duration = ei.MissionInfo.DurationType; + +// Mocking the actual loot data to ensure tests are minimally brittle against updated loot tables +vi.mock('./loot-json', async () => { + const { ei } = await import('lib'); + const Name = ei.ArtifactSpec.Name; + const Level = ei.ArtifactSpec.Level; + const item = ( + itemId: string, + afxId: ei.ArtifactSpec.Name, + afxLevel: ei.ArtifactSpec.Level, + counts: [number, number, number, number] + ) => ({ itemId, afxId, afxLevel, counts }); + + const data: lootjson = { + missions: [ + { + afxShip: ei.MissionInfo.Spaceship.CHICKEN_ONE, + afxDurationType: ei.MissionInfo.DurationType.SHORT, + missionId: 'chicken-one-short', + levels: [ + { + level: 0, + targets: [ + { + targetAfxId: Name.UNKNOWN, + totalDrops: 100, + items: [ + item('lunar-totem-1', Name.LUNAR_TOTEM, Level.INFERIOR, [90, 8, 2, 0]), + item('puzzle-cube-1', Name.PUZZLE_CUBE, Level.INFERIOR, [50, 0, 0, 0]), + ], + }, + ], + }, + { + level: 1, + targets: [ + { + targetAfxId: Name.UNKNOWN, + totalDrops: 200, + items: [item('lunar-totem-1', Name.LUNAR_TOTEM, Level.INFERIOR, [180, 0, 0, 0])], + }, + ], + }, + ], + }, + { + afxShip: ei.MissionInfo.Spaceship.HENERPRISE, + afxDurationType: ei.MissionInfo.DurationType.EPIC, + missionId: 'henerprise-extended', + levels: [ + { + level: 0, + targets: [ + { + targetAfxId: Name.LUNAR_TOTEM, + totalDrops: 1000, + items: [ + item('lunar-totem-3', Name.LUNAR_TOTEM, Level.NORMAL, [800, 150, 40, 10]), + item('lunar-totem-1', Name.LUNAR_TOTEM, Level.INFERIOR, [5, 0, 0, 0]), + ], + }, + { + targetAfxId: Name.PUZZLE_CUBE, + totalDrops: 500, + items: [ + item('lunar-totem-3', Name.LUNAR_TOTEM, Level.NORMAL, [10, 0, 0, 3]), + item('gold-meteorite-1', Name.GOLD_METEORITE, Level.INFERIOR, [0, 0, 0, 5]), + ], + }, + ], + }, + { + level: 1, + targets: [ + { + targetAfxId: Name.LUNAR_TOTEM, + totalDrops: 2000, + items: [ + item('lunar-totem-3', Name.LUNAR_TOTEM, Level.NORMAL, [1600, 300, 80, 20]), + item('gold-meteorite-2', Name.GOLD_METEORITE, Level.LESSER, [0, 0, 0, 4]), + ], + }, + ], + }, + ], + }, + { + afxShip: ei.MissionInfo.Spaceship.ATREGGIES, + afxDurationType: ei.MissionInfo.DurationType.EPIC, + missionId: 'atreggies-extended', + levels: [ + { + level: 0, + targets: [ + { + targetAfxId: Name.UNKNOWN, + totalDrops: 50, + items: [item('lunar-totem-3', Name.LUNAR_TOTEM, Level.NORMAL, [40, 0, 0, 0])], + }, + ], + }, + ], + }, + ], + }; + return { default: data }; +}); + +function enabledTargets(...enabled: ei.ArtifactSpec.Name[]): ShipsConfig['targets'] { + const config = Object.fromEntries(targets.map(t => [t, false])) as ShipsConfig['targets']; + for (const t of enabled) { + config[t] = true; + } + return config; +} + +describe('hasMissionLootData', () => { + it('checks atreggies missions against the dataset', () => { + expect(hasMissionLootData('atreggies-extended')).toBe(true); + expect(hasMissionLootData('atreggies-short')).toBe(false); + }); + + it('assumes every non-atreggies mission has data, even when absent', () => { + expect(hasMissionLootData('chicken-one-short')).toBe(true); + expect(hasMissionLootData('henerprise-short')).toBe(true); + }); +}); + +describe('getMissionLootData', () => { + it('returns the store for a known mission', () => { + expect(getMissionLootData('henerprise-extended')).toBe(lootFixture.missions[1]); + }); + + it('throws for an unknown mission', () => { + expect(() => getMissionLootData('voyegger-short')).toThrow("there's no mission with id voyegger-short"); + }); +}); + +describe('getMissionLevelLootAverageConsumptionValue', () => { + const levelStore = ( + targetAfxId: ei.ArtifactSpec.Name, + totalDrops: number, + items: MissionLevelLootStore['targets'][number]['items'] + ): MissionLevelLootStore => ({ level: 0, targets: [{ targetAfxId, totalDrops, items }] }); + + it('returns [0, 0] when the target has no loot entry', () => { + const store = levelStore(Name.UNKNOWN, 10, []); + expect(getMissionLevelLootAverageConsumptionValue(store, Name.LUNAR_TOTEM)).toEqual([0, 0]); + }); + + it('returns [0, 0] when the target has no recorded drops', () => { + const store = levelStore(Name.LUNAR_TOTEM, 0, []); + expect(getMissionLevelLootAverageConsumptionValue(store, Name.LUNAR_TOTEM)).toEqual([0, 0]); + }); + + it('averages consumption value over total drops', () => { + // Current lib consumption values: common lunar-totem-1 -> [2 gold, 0 fill], + // common gold-meteorite-1 -> [0 gold, 5 fill]. + const store = levelStore(Name.UNKNOWN, 10, [ + { itemId: 'lunar-totem-1', afxId: Name.LUNAR_TOTEM, afxLevel: Level.INFERIOR, counts: [3, 0, 0, 0] }, + { itemId: 'gold-meteorite-1', afxId: Name.GOLD_METEORITE, afxLevel: Level.INFERIOR, counts: [2, 0, 0, 0] }, + ]); + expect(getMissionLevelLootAverageConsumptionValue(store, Name.UNKNOWN)).toEqual([0.6, 1]); + }); + + it('weights each rarity bucket by its observed count', () => { + const store = levelStore(Name.LUNAR_TOTEM, 4, [ + { itemId: 'lunar-totem-3', afxId: Name.LUNAR_TOTEM, afxLevel: Level.NORMAL, counts: [2, 1, 0, 0] }, + ]); + const [gold, fill] = getMissionLevelLootAverageConsumptionValue(store, Name.LUNAR_TOTEM); + const common = itemExpectedFullConsumption(Name.LUNAR_TOTEM, Level.NORMAL, Rarity.COMMON); + const rare = itemExpectedFullConsumption(Name.LUNAR_TOTEM, Level.NORMAL, Rarity.RARE); + expect(gold).toBeCloseTo((2 * common[0] + rare[0]) / 4, 12); + expect(fill).toBeCloseTo((2 * common[1] + rare[1]) / 4, 12); + }); +}); + +describe('getTierLootData', () => { + it('collects per-level counts, zero-filling levels where the item never dropped', () => { + // lunar-totem-1 (quality 0.7) is within chicken-one-short's window and + // below henerprise-extended's, but it drops there in the fixture, so the + // henerprise store is kept anyway (withinRange || dropped). + const result = getTierLootData('lunar-totem-1', enabledTargets(Name.UNKNOWN)); + expect(result.missions).toEqual([ + { + targetAfxId: Name.UNKNOWN, + afxShip: Ship.CHICKEN_ONE, + afxDurationType: Duration.SHORT, + missionId: 'chicken-one-short', + levels: [ + { level: 0, totalDrops: 100, counts: [90, 8, 2, 0] }, + { level: 1, totalDrops: 200, counts: [180, 0, 0, 0] }, + ], + }, + { + targetAfxId: Name.LUNAR_TOTEM, + afxShip: Ship.HENERPRISE, + afxDurationType: Duration.EPIC, + missionId: 'henerprise-extended', + levels: [ + { level: 0, totalDrops: 1000, counts: [5, 0, 0, 0] }, + { level: 1, totalDrops: 2000, counts: [0, 0, 0, 0] }, + ], + }, + ]); + }); + + it("always includes the item's own family target, even when disabled", () => { + // With every target disabled (UNKNOWN included), untargeted missions are + // filtered out entirely and FTL ships only report the item's own family. + // The atreggies store survives on quality range alone, with no levels. + const result = getTierLootData('lunar-totem-3', enabledTargets()); + expect(result.missions.map(m => [m.missionId, m.targetAfxId, m.levels])).toEqual([ + [ + 'henerprise-extended', + Name.LUNAR_TOTEM, + [ + { level: 0, totalDrops: 1000, counts: [800, 150, 40, 10] }, + { level: 1, totalDrops: 2000, counts: [1600, 300, 80, 20] }, + ], + ], + ['atreggies-extended', Name.LUNAR_TOTEM, []], + ]); + }); + + it('omits levels with no data for an enabled target', () => { + // henerprise level 1 has no puzzle-cube-targeted records, so the + // puzzle-cube store only lists level 0. + const result = getTierLootData('lunar-totem-3', enabledTargets(Name.PUZZLE_CUBE)); + expect(result.missions.map(m => [m.missionId, m.targetAfxId, m.levels.length])).toEqual([ + ['henerprise-extended', Name.PUZZLE_CUBE, 1], + ['henerprise-extended', Name.LUNAR_TOTEM, 2], + ['atreggies-extended', Name.PUZZLE_CUBE, 0], + ['atreggies-extended', Name.LUNAR_TOTEM, 0], + ]); + expect(result.missions[0].levels[0]).toEqual({ level: 0, totalDrops: 500, counts: [10, 0, 0, 3] }); + }); +}); + +describe('missionDataNotEnough', () => { + it('requires 20 full ships worth of drops', () => { + const chickenOne = getMissionTypeFromId('chicken-one-short'); // capacity 4 + expect(missionDataNotEnough(chickenOne, 79)).toBe(true); + expect(missionDataNotEnough(chickenOne, 80)).toBe(false); + + const henerprise = getMissionTypeFromId('henerprise-extended'); // capacity 56 + expect(missionDataNotEnough(henerprise, 1119)).toBe(true); + expect(missionDataNotEnough(henerprise, 1120)).toBe(false); + }); +}); + +describe('legendary observation counts', () => { + it('finds the max legendary count across all missions, levels and targets', () => { + // lunar-totem-3 legendary counts in the fixture: 10, 3, 20, 0. + expect(getMaxLegendaryCount('lunar-totem-3')).toBe(20); + expect(getMaxLegendaryCount('lunar-totem-1')).toBe(0); + }); + + it('returns 0 for an item absent from the dataset', () => { + expect(getMaxLegendaryCount('book-of-basan-4')).toBe(0); + }); + + it('flags items below MIN_LEGENDARY_OBSERVATIONS as sparse', () => { + expect(MIN_LEGENDARY_OBSERVATIONS).toBe(5); + expect(legendaryDataIsSparse('gold-meteorite-1')).toBe(false); // exactly 5 + expect(legendaryDataIsSparse('gold-meteorite-2')).toBe(true); // 4 + expect(legendaryDataIsSparse('lunar-totem-3')).toBe(false); // 20 + expect(legendaryDataIsSparse('puzzle-cube-1')).toBe(true); // 0 + expect(legendaryDataIsSparse('book-of-basan-4')).toBe(true); // absent + }); +}); diff --git a/wasmegg/artifact-explorer/src/lib/loot.ts b/wasmegg/artifact-explorer/src/lib/loot.ts index 17e80d4e1..0b8c7cfe5 100644 --- a/wasmegg/artifact-explorer/src/lib/loot.ts +++ b/wasmegg/artifact-explorer/src/lib/loot.ts @@ -5,9 +5,9 @@ import { itemExpectedFullConsumption, MissionType, targets, + type ShipsConfig, } from 'lib'; -import { config } from '@/store'; import type { lootjson, MissionLevelLootStore, MissionLootStore } from './loot-json'; import data from './loot-json'; @@ -70,7 +70,7 @@ type ItemMissionLevelLootStore = { counts: [number, number, number, number]; }; -export function getTierLootData(itemId: string): ItemLootStore { +export function getTierLootData(itemId: string, enabledTargets: ShipsConfig['targets']): ItemLootStore { const item = getArtifactTierPropsFromId(itemId); const result: ItemLootStore = { missions: [], @@ -80,7 +80,7 @@ export function getTierLootData(itemId: string): ItemLootStore { const withinRange = mission.params.minQuality <= item.quality && item.quality <= mission.maxBoostedMaxQuality(); const validTargets = mission.isFTL ? targets : [ei.ArtifactSpec.Name.UNKNOWN]; for (const target of validTargets) { - if (!config.value.targets[target] && item.afx_id != target) { + if (!enabledTargets[target] && item.afx_id != target) { continue; } const store: ItemMissionLootStore = { @@ -121,3 +121,35 @@ export function getTierLootData(itemId: string): ItemLootStore { export function missionDataNotEnough(mission: MissionType, totalDrops: number) { return totalDrops / mission.defaultCapacity < 20; } + +// Legendary drop rates come from counts[3] / totalDrops, and counts[3] is +// often a single observation across tens of thousands of drops — far too +// noisy to trust. Buckets below this minimum get zeroed by the optimizer. +export const MIN_LEGENDARY_OBSERVATIONS = 5; + +let _maxLegendaryByItemId: Map | null = null; + +function maxLegendaryByItemId(): Map { + if (_maxLegendaryByItemId) return _maxLegendaryByItemId; + const result = new Map(); + for (const m of lootdata.missions) { + for (const level of m.levels) { + for (const tgt of level.targets) { + for (const item of tgt.items) { + const cur = result.get(item.itemId) ?? 0; + if (item.counts[3] > cur) result.set(item.itemId, item.counts[3]); + } + } + } + } + _maxLegendaryByItemId = result; + return result; +} + +export function getMaxLegendaryCount(itemId: string): number { + return maxLegendaryByItemId().get(itemId) ?? 0; +} + +export function legendaryDataIsSparse(itemId: string): boolean { + return getMaxLegendaryCount(itemId) < MIN_LEGENDARY_OBSERVATIONS; +} diff --git a/wasmegg/artifact-explorer/src/lib/lp.spec.ts b/wasmegg/artifact-explorer/src/lib/lp.spec.ts new file mode 100644 index 000000000..16552c043 --- /dev/null +++ b/wasmegg/artifact-explorer/src/lib/lp.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { solveLp } from './lp'; + +const PREC = 9; + +describe('solveLp', () => { + it('solves a trivial one-variable problem', () => { + // max x s.t. x <= 5 + const r = solveLp(new Float64Array([1]), [new Float64Array([1])], new Float64Array([5])); + expect(r.status).toBe('optimal'); + expect(r.objective).toBeCloseTo(5, PREC); + expect(r.primal[0]).toBeCloseTo(5, PREC); + expect(r.duals[0]).toBeCloseTo(1, PREC); + }); + + it('solves two independent constraints', () => { + // max x+y s.t. x <= 3, y <= 4 + const r = solveLp( + new Float64Array([1, 1]), + [new Float64Array([1, 0]), new Float64Array([0, 1])], + new Float64Array([3, 4]) + ); + expect(r.status).toBe('optimal'); + expect(r.objective).toBeCloseTo(7, PREC); + expect(r.primal[0]).toBeCloseTo(3, PREC); + expect(r.primal[1]).toBeCloseTo(4, PREC); + expect(r.duals[0]).toBeCloseTo(1, PREC); + expect(r.duals[1]).toBeCloseTo(1, PREC); + }); + + it('finds a vertex that needs multiple pivots', () => { + // max 5x+4y s.t. 6x+4y <= 24, x+2y <= 6 + // optimum is x=3, y=1.5, obj=21, duals=[0.75, 0.5] + const r = solveLp( + new Float64Array([5, 4]), + [new Float64Array([6, 4]), new Float64Array([1, 2])], + new Float64Array([24, 6]) + ); + expect(r.status).toBe('optimal'); + expect(r.objective).toBeCloseTo(21, PREC); + expect(r.primal[0]).toBeCloseTo(3, PREC); + expect(r.primal[1]).toBeCloseTo(1.5, PREC); + expect(r.duals[0]).toBeCloseTo(0.75, PREC); + expect(r.duals[1]).toBeCloseTo(0.5, PREC); + }); + + it('gives slack constraints a zero dual', () => { + // max x+2y s.t. x+y <= 4, x <= 2, y <= 3 + // optimum x=1, y=3; the x <= 2 constraint is slack so its dual must be 0 + const r = solveLp( + new Float64Array([1, 2]), + [new Float64Array([1, 1]), new Float64Array([1, 0]), new Float64Array([0, 1])], + new Float64Array([4, 2, 3]) + ); + expect(r.status).toBe('optimal'); + expect(r.objective).toBeCloseTo(7, PREC); + expect(r.primal[0]).toBeCloseTo(1, PREC); + expect(r.primal[1]).toBeCloseTo(3, PREC); + expect(r.duals[0]).toBeCloseTo(1, PREC); + expect(r.duals[1]).toBeCloseTo(0, PREC); + expect(r.duals[2]).toBeCloseTo(1, PREC); + }); + + it('gives a redundant upper bound a zero dual', () => { + // x <= 10 is redundant when x <= 5 binds + const r = solveLp(new Float64Array([1]), [new Float64Array([1]), new Float64Array([1])], new Float64Array([5, 10])); + expect(r.status).toBe('optimal'); + expect(r.objective).toBeCloseTo(5, PREC); + expect(r.duals[0]).toBeCloseTo(1, PREC); + expect(r.duals[1]).toBeCloseTo(0, PREC); + }); + + it('handles a zero RHS', () => { + // max x s.t. x <= 0 + const r = solveLp(new Float64Array([1]), [new Float64Array([1])], new Float64Array([0])); + expect(r.status).toBe('optimal'); + expect(r.objective).toBeCloseTo(0, PREC); + expect(r.primal[0]).toBeCloseTo(0, PREC); + expect(r.duals[0]).toBeCloseTo(1, PREC); + }); + + it('handles a zero objective', () => { + const r = solveLp(new Float64Array([0]), [new Float64Array([1])], new Float64Array([5])); + expect(r.status).toBe('optimal'); + expect(r.objective).toBeCloseTo(0, PREC); + }); + + it('reports infeasible on a negative RHS', () => { + const r = solveLp(new Float64Array([1, 1]), [new Float64Array([1, 0])], new Float64Array([-1])); + expect(r.status).toBe('infeasible'); + expect(r.objective).toBe(0); + }); + + it('reports unbounded when a variable has no upper bound', () => { + // max x s.t. y <= 1; x is unconstrained + const r = solveLp(new Float64Array([1, 0]), [new Float64Array([0, 1])], new Float64Array([1])); + expect(r.status).toBe('unbounded'); + expect(r.objective).toBe(Infinity); + }); +}); diff --git a/wasmegg/artifact-explorer/src/lib/lp.ts b/wasmegg/artifact-explorer/src/lib/lp.ts new file mode 100644 index 000000000..44c5ac27b --- /dev/null +++ b/wasmegg/artifact-explorer/src/lib/lp.ts @@ -0,0 +1,193 @@ +// Small tableau simplex: max c·x s.t. Ax <= b, x >= 0, with b >= 0 so the +// slack basis is feasible from the start. Bland's rule to avoid cycling. +// https://www.youtube.com/watch?v=9YKLXFqCy6E gives a decent baseline overview + +// Sorry for all the dense math. :( +// Standard libraries had unaccetpable runtime characteristics where +// this can make some simplifying assumptions + +export type LpStatus = 'optimal' | 'infeasible' | 'unbounded'; + +export interface LpResult { + status: LpStatus; + objective: number; + primal: Float64Array; + duals: Float64Array; // one per constraint row +} + +const EPS = 1e-9; + +// This sits at the bottom of the optimizer's search loops and gets called +// many times per run, so everything below is reused across calls and +// a solve allocates nothing in steady state. +// Size is expanded on-demand so first runs pay a slight overhead +let scratchT = new Float64Array(0); +let scratchBasis = new Int32Array(0); +let scratchPivIdx = new Int32Array(0); + +// The result is shared: primal/duals are views into one buffer and only +// valid until the next solveLp call. Updates need to be careful about +// the shared memory. +let scratchOut = new Float64Array(0); +const reusedResult: LpResult = { + status: 'optimal', + objective: 0, + primal: new Float64Array(0), + duals: new Float64Array(0), +}; + +function makeResult(status: LpStatus, objective: number, n: number, m: number): LpResult { + if (scratchOut.length < n + m) scratchOut = new Float64Array(n + m); + reusedResult.status = status; + reusedResult.objective = objective; + reusedResult.primal = scratchOut.subarray(0, n); + reusedResult.duals = scratchOut.subarray(n, n + m); + return reusedResult; +} + +// Prototype tableaus [-c | A I | 0] keyed by A. Callers solve the same +// (c, A) many times with only b changing, so the tableau init reduces to a +// copy plus the RHS column. Assumes c and A are never mutated after the +// first solve. +interface ProtoEntry { + c: Float64Array; + n: number; + m: number; + proto: Float64Array; +} +const protoCache = new WeakMap(); + +export function solveLp(c: Float64Array, A: Float64Array[], b: Float64Array): LpResult { + const n = c.length; // # decision variables + const m = A.length; // # constraints + const W = n + m + 1; // width: decision vars | slacks | RHS + + for (let i = 0; i < m; i++) { + if (b[i] < -EPS) { + // would need a phase-1 solve; we never build such problems + const r = makeResult('infeasible', 0, n, m); + r.primal.fill(0); + r.duals.fill(0); + return r; + } + } + + // row-major (m+1) x W tableau, row 0 = objective + const cells = (m + 1) * W; + let entry = protoCache.get(A); + if (!entry || entry.c !== c || entry.n !== n || entry.m !== m) { + const proto = new Float64Array(cells); + for (let j = 0; j < n; j++) { + proto[j] = -c[j]; + } + for (let i = 0; i < m; i++) { + const off = (i + 1) * W; + const Ai = A[i]; + for (let j = 0; j < n; j++) { + proto[off + j] = Ai[j]; + } + proto[off + n + i] = 1; + } + entry = { c, n, m, proto }; + protoCache.set(A, entry); + } + + if (scratchT.length < cells) { + scratchT = new Float64Array(cells); + } + const T = scratchT; + T.set(entry.proto); + for (let i = 0; i < m; i++) { + T[(i + 1) * W + W - 1] = Math.max(0, b[i]); + } + + if (scratchBasis.length < m) { + scratchBasis = new Int32Array(m); + } + const basis = scratchBasis; + for (let i = 0; i < m; i++) { + basis[i] = n + i; + } + + // Determined by trial-and-error. Further refinement may lead to better performance + // Within a few per-mil to HiGHS solver + const MAX_ITER = 50 * (n + m + 1); + + for (let iter = 0; iter < MAX_ITER; iter++) { + // entering variable: lowest-index column with negative reduced cost (Bland) + let pivCol = -1; + for (let j = 0; j < n + m; j++) { + if (T[j] < -EPS) { + pivCol = j; + break; + } + } + if (pivCol === -1) break; // optimal + + // min-ratio test, ties broken by lowest basis index + let pivRow = -1; + let bestRatio = Infinity; + for (let i = 0; i < m; i++) { + const off = (i + 1) * W; + const a = T[off + pivCol]; + if (a > EPS) { + const ratio = T[off + W - 1] / a; + if (ratio < bestRatio - EPS) { + bestRatio = ratio; + pivRow = i; + } else if (ratio < bestRatio + EPS && pivRow >= 0 && basis[i] < basis[pivRow]) { + pivRow = i; + } + } + } + if (pivRow === -1) { + const r = makeResult('unbounded', Infinity, n, m); + r.primal.fill(0); + r.duals.fill(0); + return r; + } + + // normalize the pivot row and record its nonzero columns; the + // elimination below only needs to touch those (constraint rows are + // sparse, and stay so over our handful of pivots) + const pOff = (pivRow + 1) * W; + const pivVal = T[pOff + pivCol]; + if (scratchPivIdx.length < W) { + scratchPivIdx = new Int32Array(W); + } + const pivIdx = scratchPivIdx; + let nnz = 0; + for (let j = 0; j < W; j++) { + T[pOff + j] /= pivVal; + if (T[pOff + j] !== 0) { + pivIdx[nnz++] = j; + } + } + for (let i = 0; i <= m; i++) { + if (i === pivRow + 1) continue; + const off = i * W; + const factor = T[off + pivCol]; + if (Math.abs(factor) < EPS) continue; + for (let k = 0; k < nnz; k++) { + const j = pivIdx[k]; + T[off + j] -= factor * T[pOff + j]; + } + } + basis[pivRow] = pivCol; + } + + const res = makeResult('optimal', T[W - 1], n, m); + const primal = res.primal; + primal.fill(0); + for (let i = 0; i < m; i++) { + const v = basis[i]; + if (v < n) { + primal[v] = T[(i + 1) * W + W - 1]; + } + } + const duals = res.duals; + for (let i = 0; i < m; i++) { + duals[i] = T[n + i]; + } + return res; +} diff --git a/wasmegg/artifact-explorer/src/lib/multi-target.spec.ts b/wasmegg/artifact-explorer/src/lib/multi-target.spec.ts new file mode 100644 index 000000000..4aef125a3 --- /dev/null +++ b/wasmegg/artifact-explorer/src/lib/multi-target.spec.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { ei } from 'lib'; +import { compileInnerLp } from './value-function'; +import { optimizeFull } from './optimizer-core'; +import { makeNode, makeOpt } from './spec-helpers'; +import type { RecipeDAG } from './types'; + +const Name = ei.ArtifactSpec.Name; + +describe('multi-sink weighted objective LP', () => { + it('routes a shared ingredient to the higher-weight target', () => { + const dag: RecipeDAG = new Map([ + ['A1', makeNode('A1', false, [['Z', 1]])], + ['A2', makeNode('A2', false, [['Z', 1]])], + ['Z', makeNode('Z', true)], + ]); + const hiA1 = compileInnerLp( + dag, + ['A1', 'A2'], + new Map([ + ['A1', 2], + ['A2', 1], + ]) + ).solve(new Map([['Z', 10]])); + expect(hiA1.score).toBeCloseTo(20, 9); + expect(hiA1.craftByTarget.get('A1')).toBeCloseTo(10, 9); + expect(hiA1.craftByTarget.get('A2')).toBeCloseTo(0, 9); + + const hiA2 = compileInnerLp( + dag, + ['A1', 'A2'], + new Map([ + ['A1', 1], + ['A2', 3], + ]) + ).solve(new Map([['Z', 10]])); + expect(hiA2.score).toBeCloseTo(30, 9); + expect(hiA2.craftByTarget.get('A2')).toBeCloseTo(10, 9); + }); + + it('handles a target that is also an ingredient of another target', () => { + // B is both a target and an ingredient of A, so it must keep its + // conservation row and still be valued in its own right. + const dag: RecipeDAG = new Map([ + ['A', makeNode('A', false, [['B', 1]])], + ['B', makeNode('B', false, [['C', 1]])], + ['C', makeNode('C', true)], + ]); + const w = new Map([ + ['A', 2], + ['B', 1], + ]); + // raw ingredient C: craft B from C and A from B, both targets credited + const r1 = compileInnerLp(dag, ['A', 'B'], w).solve(new Map([['C', 10]])); + expect(r1.score).toBeCloseTo(30, 9); + expect(r1.craftByTarget.get('A')).toBeCloseTo(10, 9); + expect(r1.craftByTarget.get('B')).toBeCloseTo(10, 9); + + // dropped B feeds A's crafting through B's conservation row + const r2 = compileInnerLp(dag, ['A', 'B'], w).solve(new Map([['B', 5]])); + expect(r2.craftByTarget.get('A')).toBeCloseTo(5, 9); + expect(r2.score).toBeCloseTo(10, 9); + }); + + it('does not count direct drops of a final target as crafts', () => { + const dag: RecipeDAG = new Map([ + ['A', makeNode('A', false, [['B', 1]])], + ['B', makeNode('B', true)], + ]); + const lp = compileInnerLp(dag, ['A']); + expect( + lp.solve( + new Map([ + ['B', 3], + ['A', 2], + ]) + ).alpha + ).toBeCloseTo(3, 9); + expect(lp.solve(new Map([['A', 4]])).alpha).toBeCloseTo(0, 9); + }); + + it('is order-independent when an option drops the root directly', () => { + // A mission dropping the root (without legendaries) must not be + // over-valued; the result should not depend on option order. + const dag: RecipeDAG = new Map([ + ['A', makeNode('A', false, [['B', 1]], 0.5)], + ['B', makeNode('B', true)], + ]); + const optRoot = makeOpt(1, 10, [['A', 1]], [], Name.LUNAR_TOTEM); + const optB = makeOpt(1, 10, [['B', 1]], [], Name.TUNGSTEN_ANKH); + const args = { + recipeDag: dag, + desiredArtifactNodeIds: ['A'], + fuelCapacity: 1000, + timeCapacity: 100, + baseYield: new Map(), + }; + const rootFirst = optimizeFull({ options: [optRoot, optB], ...args }); + const bFirst = optimizeFull({ options: [optB, optRoot], ...args }); + expect(rootFirst.bestProbability).toBeCloseTo(bFirst.bestProbability, 9); + expect(rootFirst.bestProbability).toBeGreaterThan(0.99); + expect(rootFirst.choiceHistory.some(c => c.targetAfxId === optB.targetAfxId)).toBe(true); + }); +}); diff --git a/wasmegg/artifact-explorer/src/lib/optimizer-core.spec.ts b/wasmegg/artifact-explorer/src/lib/optimizer-core.spec.ts new file mode 100644 index 000000000..539c3624d --- /dev/null +++ b/wasmegg/artifact-explorer/src/lib/optimizer-core.spec.ts @@ -0,0 +1,336 @@ +import { describe, it, expect } from 'vitest'; +import { ei } from 'lib'; +import { optimizeFull } from './optimizer-core'; +import { computeCraftChainRows } from './optimizer-views'; +import { makeNode, makeOpt } from './spec-helpers'; +import type { RecipeDAG } from './types'; + +const Name = ei.ArtifactSpec.Name; + +// Root 'A' (craftable) needing one leaf ingredient 'B'. With pCraft > 0, +// missions yielding B produce positive score, so the optimizer has a reason +// to launch. +function craftDag(pCraft = 0.1): RecipeDAG { + return new Map([ + ['A', makeNode('A', false, [['B', 1]], pCraft)], + ['B', makeNode('B', true)], + ]); +} + +describe('optimizeFull', () => { + it('handles an empty option list', () => { + const sol = optimizeFull({ + options: [], + recipeDag: craftDag(), + desiredArtifactNodeIds: ['A'], + fuelCapacity: 1000, + timeCapacity: 100, + baseYield: new Map(), + }); + expect(sol.bestProbability).toBeCloseTo(0, 9); + expect(sol.choiceHistory).toHaveLength(0); + expect(sol.fuelUsed).toBeCloseTo(0, 9); + }); + + it('uses the full time budget for a zero-fuel option', () => { + // 10s per launch, 100s budget: 10 launches, 10 B + const sol = optimizeFull({ + options: [makeOpt(0, 10, [['B', 1]])], + recipeDag: craftDag(0.1), + desiredArtifactNodeIds: ['A'], + fuelCapacity: 1_000_000, + timeCapacity: 100, + baseYield: new Map(), + }); + expect(sol.timeUnitsUsed).toBeLessThanOrEqual(100); + const yieldB = sol.finalYieldVector.get('B') ?? 0; + expect(yieldB).toBeGreaterThanOrEqual(10); + }); + + it('respects a tighter time budget exactly', () => { + // 10s per launch, 50s budget: exactly 5 launches + const sol = optimizeFull({ + options: [makeOpt(0, 10, [['B', 1]])], + recipeDag: craftDag(0.1), + desiredArtifactNodeIds: ['A'], + fuelCapacity: 1_000_000, + timeCapacity: 50, + baseYield: new Map(), + }); + expect(sol.timeUnitsUsed).toBe(50); + expect(sol.finalYieldVector.get('B')).toBeCloseTo(5, 9); + }); + + it('respects the fuel budget', () => { + // 100 fuel per launch, 300 budget: 3 launches + const sol = optimizeFull({ + options: [makeOpt(100, 1, [['B', 1]])], + recipeDag: craftDag(0.1), + desiredArtifactNodeIds: ['A'], + fuelCapacity: 300, + timeCapacity: 10_000, + baseYield: new Map(), + }); + expect(sol.fuelUsed).toBeLessThanOrEqual(300); + expect(sol.fuelUsed).toBeCloseTo(300, 6); + }); + + it('prunes an option dominated on yield', () => { + const opt0 = makeOpt(10, 10, [['B', 1]], [], Name.LUNAR_TOTEM); // same cost, half the yield + const opt1 = makeOpt(10, 10, [['B', 2]], [], Name.TUNGSTEN_ANKH); + const sol = optimizeFull({ + options: [opt0, opt1], + recipeDag: craftDag(0.1), + desiredArtifactNodeIds: ['A'], + fuelCapacity: 100, + timeCapacity: 100, + baseYield: new Map(), + }); + // 10 launches of opt1, 20 B + expect(sol.finalYieldVector.get('B')).toBeCloseTo(20, 6); + expect(sol.choiceHistory.find(c => c.targetAfxId === opt0.targetAfxId)).toBeUndefined(); + expect(sol.choiceHistory.find(c => c.targetAfxId === opt1.targetAfxId)).toBeDefined(); + }); + + it('allocates complementary options together', () => { + // A needs both B and C; one option yields each, neither dominates. + // The budget should be split between them. + const dag: RecipeDAG = new Map([ + [ + 'A', + makeNode( + 'A', + false, + [ + ['B', 1], + ['C', 1], + ], + 0.5 + ), + ], + ['B', makeNode('B', true)], + ['C', makeNode('C', true)], + ]); + const optB = makeOpt(10, 10, [['B', 1]], [], Name.LUNAR_TOTEM); + const optC = makeOpt(10, 10, [['C', 1]], [], Name.TUNGSTEN_ANKH); + const sol = optimizeFull({ + options: [optB, optC], + recipeDag: dag, + desiredArtifactNodeIds: ['A'], + fuelCapacity: 200, + timeCapacity: 200, + baseYield: new Map(), + }); + expect(sol.choiceHistory.length).toBe(2); + expect(sol.finalYieldVector.get('B') ?? 0).toBeGreaterThanOrEqual(9); + expect(sol.finalYieldVector.get('C') ?? 0).toBeGreaterThanOrEqual(9); + }); + + it('falls back to triples when pairs are not enough', () => { + // A needs B, C and D, with one option per ingredient. Any pair leaves + // the third ingredient at zero, so only the triple scan can find the + // (10, 10, 10) allocation. + const dag: RecipeDAG = new Map([ + [ + 'A', + makeNode( + 'A', + false, + [ + ['B', 1], + ['C', 1], + ['D', 1], + ], + 0.1 + ), + ], + ['B', makeNode('B', true)], + ['C', makeNode('C', true)], + ['D', makeNode('D', true)], + ]); + const optB = makeOpt(10, 10, [['B', 1]], [], Name.LUNAR_TOTEM); + const optC = makeOpt(10, 10, [['C', 1]], [], Name.TUNGSTEN_ANKH); + const optD = makeOpt(10, 10, [['D', 1]], [], Name.DEMETERS_NECKLACE); + const sol = optimizeFull({ + options: [optB, optC, optD], + recipeDag: dag, + desiredArtifactNodeIds: ['A'], + fuelCapacity: 300, + timeCapacity: 300, + baseYield: new Map(), + }); + expect(sol.choiceHistory.length).toBe(3); + expect(sol.finalYieldVector.get('B')).toBeCloseTo(10, 6); + expect(sol.finalYieldVector.get('C')).toBeCloseTo(10, 6); + expect(sol.finalYieldVector.get('D')).toBeCloseTo(10, 6); + expect(sol.expectedCrafts).toBeCloseTo(10, 6); + }); + + it('prunes an option dominated on cost alone', () => { + const optExpensive = makeOpt(20, 10, [['B', 1]], [], Name.LUNAR_TOTEM); + const optCheap = makeOpt(10, 10, [['B', 1]], [], Name.TUNGSTEN_ANKH); // same yield, half the fuel + const sol = optimizeFull({ + options: [optExpensive, optCheap], + recipeDag: craftDag(0.1), + desiredArtifactNodeIds: ['A'], + fuelCapacity: 100, + timeCapacity: 100, + baseYield: new Map(), + }); + expect(sol.choiceHistory.find(c => c.targetAfxId === optExpensive.targetAfxId)).toBeUndefined(); + expect(sol.choiceHistory.find(c => c.targetAfxId === optCheap.targetAfxId)).toBeDefined(); + expect(sol.finalYieldVector.get('B')).toBeCloseTo(10, 6); + }); + + it('values direct legendary drops when crafting is impossible', () => { + // pCraft=0, so the only value is the 0.1 legendary drop rate. 10 launches + // give lambda=1 and drop probability 1 - e^-1. + const dag: RecipeDAG = new Map([ + ['A', makeNode('A', false, [['B', 1]], 0)], + ['B', makeNode('B', true)], + ]); + const optLeg = makeOpt(10, 10, [['B', 1]], [['A', 0.1]]); + const sol = optimizeFull({ + options: [optLeg], + recipeDag: dag, + desiredArtifactNodeIds: ['A'], + fuelCapacity: 100, + timeCapacity: 100, + baseYield: new Map(), + }); + expect(sol.craftProbability).toBeCloseTo(0, 9); + expect(sol.dropProbability).toBeCloseTo(1 - Math.exp(-1), 6); + expect(sol.bestProbability).toBeCloseTo(1 - Math.exp(-1), 6); + }); + + it('pairs a zero-fuel option with a fueled one', () => { + const dag: RecipeDAG = new Map([ + [ + 'A', + makeNode( + 'A', + false, + [ + ['B', 1], + ['C', 1], + ], + 0.1 + ), + ], + ['B', makeNode('B', true)], + ['C', makeNode('C', true)], + ]); + const optZ = makeOpt(0, 10, [['B', 1]], [], Name.LUNAR_TOTEM); + const optP = makeOpt(10, 10, [['C', 1]], [], Name.TUNGSTEN_ANKH); + const sol = optimizeFull({ + options: [optZ, optP], + recipeDag: dag, + desiredArtifactNodeIds: ['A'], + fuelCapacity: 100, + timeCapacity: 200, + baseYield: new Map(), + }); + expect(sol.choiceHistory.find(c => c.targetAfxId === optZ.targetAfxId)).toBeDefined(); + expect(sol.choiceHistory.find(c => c.targetAfxId === optP.targetAfxId)).toBeDefined(); + expect(sol.finalYieldVector.get('B')).toBeCloseTo(10, 6); + expect(sol.finalYieldVector.get('C')).toBeCloseTo(10, 6); + expect(sol.timeUnitsUsed).toBe(200); + expect(sol.fuelUsed).toBeCloseTo(100, 6); + }); + + it('keeps an option the dual filter would wrongly prune', () => { + const dag: RecipeDAG = new Map([ + [ + 'A', + makeNode( + 'A', + false, + [ + ['B', 1], + ['C', 1], + ], + 0.1 + ), + ], + ['B', makeNode('B', true)], + ['C', makeNode('C', true)], + ]); + const opt0 = makeOpt( + 0, + 3, + [ + ['B', 0.8], + ['C', 1.5], + ], + [], + Name.LUNAR_TOTEM + ); + const opt1 = makeOpt( + 1, + 3, + [ + ['B', 2.43], + ['C', 2.03], + ], + [], + Name.TUNGSTEN_ANKH + ); + const opt2 = makeOpt( + 2, + 2, + [ + ['B', 1.36], + ['C', 0.61], + ], + [], + Name.DEMETERS_NECKLACE + ); + const sol = optimizeFull({ + options: [opt0, opt1, opt2], + recipeDag: dag, + desiredArtifactNodeIds: ['A'], + fuelCapacity: 6, + timeCapacity: 8, + baseYield: new Map(), + }); + expect(sol.expectedCrafts).toBeGreaterThan(4.5); + expect(sol.choiceHistory.some(c => c.targetAfxId === opt2.targetAfxId)).toBe(true); + }); + + it('snapshots base_yield and keeps it out of the dropped column', () => { + const root = 'puzzle-cube-2'; + const leaf = 'puzzle-cube-1'; + const dag: RecipeDAG = new Map([ + [root, makeNode(root, false, [[leaf, 1]], 0.1)], + [leaf, makeNode(leaf, true)], + ]); + const sol = optimizeFull({ + options: [makeOpt(0, 10, [[leaf, 1]])], + recipeDag: dag, + desiredArtifactNodeIds: [root], + fuelCapacity: 1_000_000, + timeCapacity: 50, + baseYield: new Map([[leaf, 5]]), + }); + expect(sol.baseYield.get(leaf)).toBe(5); + expect(sol.finalYieldVector.get(leaf)).toBeCloseTo(10, 9); // 5 owned + 5 dropped + const leafRow = computeCraftChainRows(sol, root, null).find(r => r.nodeId === leaf); + expect(leafRow).toBeDefined(); + expect(leafRow!.dropped).toBeCloseTo(5, 9); + }); + + it('never exceeds either budget', () => { + const opts = [makeOpt(40, 5, [['B', 1]]), makeOpt(60, 8, [['B', 2]]), makeOpt(0, 3, [['B', 1]])]; + const sol = optimizeFull({ + options: opts, + recipeDag: craftDag(0.1), + desiredArtifactNodeIds: ['A'], + fuelCapacity: 100, + timeCapacity: 50, + baseYield: new Map(), + }); + expect(sol.fuelUsed).toBeLessThanOrEqual(100 + 1e-6); + expect(sol.timeUnitsUsed).toBeLessThanOrEqual(51); // +1 for integer rounding + expect(sol.choiceHistory.length).toBeGreaterThan(0); + }); +}); diff --git a/wasmegg/artifact-explorer/src/lib/optimizer-core.ts b/wasmegg/artifact-explorer/src/lib/optimizer-core.ts new file mode 100644 index 000000000..65ccd4eea --- /dev/null +++ b/wasmegg/artifact-explorer/src/lib/optimizer-core.ts @@ -0,0 +1,724 @@ +// Outer search for the Path of Virtue optimizer: pick integer batch counts +// k_i for each launch option to maximize the chance of the desired legendary, +// under a fuel budget R and a time budget S. +// +// We don't maximize the probability directly. Instead the objective is +// score = sum_T Q_T * (crafts of T) + direct legendary drops, +// with Q_T = -log(1 - pCraftLegendary_T). The probability is monotone in +// score, and score is concave in inventory (the inner LP value is concave, +// the legendary term is linear), which is what makes the ternary searches, +// dominance pruning, and dual filter below work. + +import type { LaunchOption, LaunchSolution, OptimizerSolution, RecipeDAG } from './types'; +import { ei } from 'lib'; +import { compileInnerLp, alphaToProb, InnerLp } from './value-function'; +import { solveLp } from './lp'; + +const TRIPLE_TOP_K = 20; +const DEFAULT_EPSILON = 1e-3; +const ZERO_TOL = 1e-9; + +interface OptimizeArgs { + options: LaunchOption[]; + recipeDag: RecipeDAG; + desiredArtifactNodeIds: string[]; + fuelCapacity: number; + timeCapacity: number; + baseYield: Map; + epsilon?: number; +} + +export function optimizeFull(args: OptimizeArgs): OptimizerSolution { + const { + options, + recipeDag, + desiredArtifactNodeIds, + fuelCapacity: R, + timeCapacity: S, + baseYield, + epsilon = DEFAULT_EPSILON, + } = args; + + // Q_T weights the inner LP's craft objective so a craft of a target with + // better legendary odds counts for more. + const targets = desiredArtifactNodeIds; + const QByTarget = new Map(); + for (const t of targets) { + const pCraft = recipeDag.get(t)?.legendaryCraftProbability ?? 0; + QByTarget.set(t, pCraft <= 0 ? 0 : pCraft >= 1 ? 1e6 : -Math.log(1 - pCraft)); + } + + const innerLp = compileInnerLp(recipeDag, desiredArtifactNodeIds, QByTarget); + + // score(alloc) = inner weighted craft value + direct legendary drops. + // Note non-legendary drops of a final target are inert (it has no + // conservation row), so they never inflate the craft value. + // + // The inner LP only sees inventory through its b vector, so the base yield + // and each option's yield vector are preindexed down to constraint rows + // here; yields to nodes without a conservation row can't affect the score. + const nRows = innerLp.constraintNodes.length; + const rowIdxByNode = new Map(); + + for (let i = 0; i < nRows; i++) { + rowIdxByNode.set(innerLp.constraintNodes[i], i); + } + const bBase = new Float64Array(nRows); + for (const [k, v] of baseYield) { + const row = rowIdxByNode.get(k); + if (row !== undefined && v > 0) { + bBase[row] = v; + } + } + + const optYieldRows: Int32Array[] = new Array(options.length); + const optYieldRates: Float64Array[] = new Array(options.length); + + for (let i = 0; i < options.length; i++) { + const rows: number[] = []; + const rates: number[] = []; + for (const [n, r] of options[i].yieldVector) { + const row = rowIdxByNode.get(n); + if (row !== undefined) { + rows.push(row); + rates.push(r); + } + } + optYieldRows[i] = new Int32Array(rows); + optYieldRates[i] = new Float64Array(rates); + } + + const bEval = new Float64Array(nRows); + + const evalScoreAt = (multipliers: ReadonlyArray): number => { + bEval.set(bBase); + let directLegendary = 0; + for (const [idx, k] of multipliers) { + if (k <= 0) continue; + const rows = optYieldRows[idx]; + const rates = optYieldRates[idx]; + for (let j = 0; j < rows.length; j++) { + bEval[rows[j]] += k * rates[j]; + } + const opt = options[idx]; + for (const t of targets) { + directLegendary += k * (opt.legendaryYieldVector.get(t) ?? 0); + } + } + return innerLp.solveScore(bEval) + directLegendary; + }; + + const baseScore = innerLp.solveScore(bBase); + + // Single-option sweep. Also records each option's solo score, which the + // triple fallback uses for its top-K ranking. + const scoreAlone = new Float64Array(options.length).fill(-Infinity); + const kAlone = new Int32Array(options.length); + + let bestScore = baseScore; + let bestAlloc: Map = new Map(); + + const tryUpdateAllocations = (score: number, alloc: Map) => { + if (score > bestScore + ZERO_TOL) { + bestScore = score; + bestAlloc = alloc; + } + }; + + for (let idx = 0; idx < options.length; idx++) { + const o = options[idx]; + const r_i = o.actualFuel; + const s_i = o.actualTime; + if (s_i <= 0) continue; + const k_i_R = r_i > ZERO_TOL ? Math.floor(R / r_i) : Infinity; + const k_i_S = Math.floor(S / s_i); + const k_i = Math.min(k_i_R, k_i_S); + if (!isFinite(k_i) || k_i < 0) continue; + const a = evalScoreAt([[idx, k_i]]); + scoreAlone[idx] = a; + kAlone[idx] = k_i; + if (a > bestScore + ZERO_TOL) { + tryUpdateAllocations(a, new Map([[idx, k_i]])); + } + } + + // Dominance pruning: j dominates i when it costs no more on either budget + // and yields at least as much of every ingredient, strictly better + // somewhere. Comparing yields pointwise (rather than by solo score) keeps + // complementary options alive — the only good source of some ingredient + // can't be pruned just because its standalone score is poor. + const survives = new Uint8Array(options.length); + for (let i = 0; i < options.length; i++) survives[i] = 1; + + for (let i = 0; i < options.length; i++) { + if (!survives[i]) continue; + for (let j = 0; j < options.length; j++) { + if (i === j || !survives[j]) continue; + if (dominates(options[j], options[i])) { + survives[i] = 0; + break; + } + } + } + + const allSurvivors: number[] = []; + for (let i = 0; i < options.length; i++) { + if (survives[i]) { + allSurvivors.push(i); + } + } + + // Joint LP relaxation: upper bound on the score, plus the support set. + const jointLp = solveJointLp(allSurvivors, options, innerLp, R, S, baseYield, targets, recipeDag, QByTarget); + const scoreLP = jointLp.score; + const lpSupport = new Set(jointLp.support); + + // Dual filter: an option's reduced cost at the LP optimum bounds how much + // score the LP would lose if forced to include it. Drop options where even + // the solo-max multiplicity would cost more than half the epsilon gap + // budget. This is deliberately aggressive (it tends to cut the survivor set + // down to the LP support, which keeps the pair/triple scans fast) and it + // does discard cheap budget-filler options — the greedy repair at the end + // re-admits those from the full list. + if (scoreLP > ZERO_TOL) { + const lossBudget = 0.5 * epsilon * scoreLP; + const yR = jointLp.dualR; + const yS = jointLp.dualS; + const nodeDuals = jointLp.nodeDuals; + for (let i = 0; i < options.length; i++) { + if (!survives[i]) continue; + if (lpSupport.has(i)) continue; + const opt = options[i]; + let rc = 0; + for (const t of targets) rc += opt.legendaryYieldVector.get(t) ?? 0; + rc -= opt.actualFuel * yR; + rc -= opt.actualTime * yS; + for (const [n, dn] of nodeDuals) { + if (dn === 0) continue; + const v = opt.yieldVector.get(n); + if (v) rc += v * dn; + } + const k = Math.max(1, kAlone[i]); + const maxLoss = -rc * k; + if (maxLoss > lossBudget) survives[i] = 0; + } + } + + const survivorsAfter = allSurvivors.filter(i => survives[i]); + + // Pairwise scans: P x P, then Z x P and Z x Z. + for (let a = 0; a < survivorsAfter.length; a++) { + for (let b = a + 1; b < survivorsAfter.length; b++) { + pairwiseScan(survivorsAfter[a], survivorsAfter[b], options, R, S, evalScoreAt, tryUpdateAllocations); + } + } + + // If the LP gap is still large, try triples over the LP support plus the + // top-K options by solo score. The support goes first: complementary + // options with poor standalone scores live there, and with many + // near-duplicate missions the solo ranking would otherwise fill up with + // clones of the best standalone option and crowd them out. + const gap = scoreLP > ZERO_TOL ? (scoreLP - bestScore) / scoreLP : 0; + if (gap > epsilon) { + const bySingle = survivorsAfter + .filter(i => isFinite(scoreAlone[i]) && scoreAlone[i] > -Infinity) + .sort((x, y) => scoreAlone[y] - scoreAlone[x]) + .slice(0, TRIPLE_TOP_K); + const ranked = [...lpSupport].filter(i => survives[i]); + const seen = new Set(ranked); + for (const i of bySingle) { + if (!seen.has(i)) { + seen.add(i); + ranked.push(i); + } + } + ranked.length = Math.min(ranked.length, TRIPLE_TOP_K + lpSupport.size); + for (let a = 0; a < ranked.length; a++) { + for (let b = a + 1; b < ranked.length; b++) { + for (let c = b + 1; c < ranked.length; c++) { + tripleScan(ranked[a], ranked[b], ranked[c], options, R, S, evalScoreAt, tryUpdateAllocations); + } + } + } + } + + bestScore = repairAlloc(bestAlloc, bestScore, options, R, S, evalScoreAt); + + // Repair again from the floor-rounded LP solution (still feasible, and its + // neighborhood is where the integer optimum usually lives). Keep whichever + // start ends up better. + const lpRounded = new Map(); + for (let s = 0; s < allSurvivors.length; s++) { + const k = Math.floor(jointLp.x[s]); + if (k > 0) lpRounded.set(allSurvivors[s], k); + } + let lpRoundedScore = evalScoreAt([...lpRounded]); + lpRoundedScore = repairAlloc(lpRounded, lpRoundedScore, options, R, S, evalScoreAt); + if (lpRoundedScore > bestScore + ZERO_TOL) { + bestScore = lpRoundedScore; + bestAlloc = lpRounded; + } + + // Assemble the solution. + const { finalYieldVector, totalLegendary, fuelUsed, fuelByEgg, timeSecs, choiceHistory } = assembleSolution( + baseYield, + bestAlloc, + options + ); + + // One extra inner-LP solve at the chosen allocation to recover the + // per-target craftable counts. + const finalSolve = innerLp.solve(finalYieldVector); + const perTarget = desiredArtifactNodeIds.map(t => { + const craftCount = + finalSolve.craftByTarget.get(t) ?? (recipeDag.get(t)?.isLeaf ? (finalYieldVector.get(t) ?? 0) : 0); + const p = alphaToProb(craftCount, totalLegendary, [t], recipeDag); + return { nodeId: t, expectedCrafts: craftCount, ...p }; + }); + const primary = perTarget[0] ?? { + bestProbability: 0, + craftProbability: 0, + dropProbability: 0, + expectedCrafts: 0, + }; + + return { + bestProbability: primary.bestProbability, + craftProbability: primary.craftProbability, + dropProbability: primary.dropProbability, + expectedCrafts: primary.expectedCrafts, + fuelUsed: fuelUsed, + fuelByEgg: fuelByEgg, + timeUnitsUsed: Math.round(timeSecs), + choiceHistory: choiceHistory, + expectedDrops: [], // populated by index.ts + finalYieldVector: finalYieldVector, + baseYield: new Map(baseYield), + recipeDag: recipeDag, + craftPrimal: finalSolve.primalByNode, + perTarget: perTarget, + }; +} + +type EvalFn = (multipliers: ReadonlyArray) => number; + +function assembleSolution(baseYield: Map, bestAlloc: Map, options: LaunchOption[]) { + const choiceHistory: LaunchSolution[] = []; + let fuelUsed = 0; + let timeSecs = 0; + const finalYieldVector = new Map(baseYield); + const totalLegendary = new Map(); + const fuelByEgg = new Map(); + for (const [idx, k] of bestAlloc) { + if (k <= 0) continue; + const opt = options[idx]; + fuelUsed += k * opt.actualFuel; + timeSecs += k * opt.actualTime; + for (const [n, r] of opt.yieldVector) { + finalYieldVector.set(n, (finalYieldVector.get(n) ?? 0) + k * r); + } + for (const [n, r] of opt.legendaryYieldVector) { + totalLegendary.set(n, (totalLegendary.get(n) ?? 0) + k * r); + } + for (const [egg, rate] of opt.fuelByEgg) { + fuelByEgg.set(egg, (fuelByEgg.get(egg) ?? 0) + k * rate); + } + choiceHistory.push({ + ship: opt.ship, + actualFuel: opt.actualFuel, + actualFuelByEgg: opt.fuelByEgg, + actualTime: opt.actualTime, + target: opt.target ?? '', + targetAfxId: opt.targetAfxId, + numShipsLaunched: k * 3, + supplyVector: opt.supplyVector, + legendarySupplyVector: opt.legendaryYieldVector, + }); + } + return { finalYieldVector, totalLegendary, fuelUsed, fuelByEgg, timeSecs, choiceHistory }; +} + +function dominates(j: LaunchOption, i: LaunchOption): boolean { + const oi = i; + const oj = j; + if (oj.actualFuel > oi.actualFuel + ZERO_TOL) return false; + if (oj.actualTime > oi.actualTime + ZERO_TOL) return false; + let strictYield = false; + for (const [n, vi] of oi.yieldVector) { + const vj = oj.yieldVector.get(n) ?? 0; + if (vj < vi - ZERO_TOL) return false; + if (vj > vi + ZERO_TOL) strictYield = true; + } + // j producing an ingredient i lacks entirely also counts as strict + if (!strictYield) { + for (const [n, vj] of oj.yieldVector) { + if (vj > ZERO_TOL && !oi.yieldVector.has(n)) { + strictYield = true; + break; + } + } + } + const strictCost = oj.actualFuel < oi.actualFuel - ZERO_TOL || oj.actualTime < oi.actualTime - ZERO_TOL; + return strictCost || strictYield; +} + +// Greedy repair: starting from an allocation, keep adding the best-scoring +// max-fitting batch from the full option list (including pruned options) +// until nothing improves. Score is non-decreasing in inventory, so the best +// add of an option is always its max fitting multiplicity, and each +// accepted add leaves less budget than one batch of that option costs — +// the loop terminates after a handful of rounds. Mutates alloc in place. +function repairAlloc( + alloc: Map, + score: number, + options: LaunchOption[], + R: number, + S: number, + evalScoreAt: EvalFn +): number { + let usedR = 0; + let usedS = 0; + for (const [idx, k] of alloc) { + usedR += k * options[idx].actualFuel; + usedS += k * options[idx].actualTime; + } + for (;;) { + let bestAddScore = score; + let bestAdd: [number, number] | null = null; + for (let i = 0; i < options.length; i++) { + const o = options[i]; + if (o.actualTime <= 0) continue; + const remR = R - usedR; + const remS = S - usedS; + const kR = o.actualFuel > ZERO_TOL ? Math.floor(remR / o.actualFuel) : Infinity; + const kS = Math.floor(remS / o.actualTime); + const kFit = Math.min(kR, kS); + if (!isFinite(kFit) || kFit <= 0) continue; + const trial: [number, number][] = []; + let merged = false; + for (const [idx, k] of alloc) { + if (idx === i) { + trial.push([idx, k + kFit]); + merged = true; + } else trial.push([idx, k]); + } + if (!merged) trial.push([i, kFit]); + const s = evalScoreAt(trial); + if (s > bestAddScore + ZERO_TOL) { + bestAddScore = s; + bestAdd = [i, kFit]; + } + } + if (!bestAdd) break; + const [i, kAdd] = bestAdd; + alloc.set(i, (alloc.get(i) ?? 0) + kAdd); + usedR += kAdd * options[i].actualFuel; + usedS += kAdd * options[i].actualTime; + score = bestAddScore; + } + return score; +} + +// Scan the (k_i, k_j) lattice with ternary search on k_j (score is concave in +// k_j for a fixed pair). Works for any mix of zero- and positive-fuel options: +// a zero fuel cost just makes the fuel bound infinite, leaving the time bound. +function pairwiseScan( + iIdx: number, + jIdx: number, + options: LaunchOption[], + R: number, + S: number, + evalScore: EvalFn, + tryUpdate: (score: number, alloc: Map) => void +) { + const oi = options[iIdx]; + const oj = options[jIdx]; + const r_i = oi.actualFuel, + s_i = oi.actualTime; + const r_j = oj.actualFuel, + s_j = oj.actualTime; + if (s_i <= 0 || s_j <= 0) return; + + const k_j_max_R = r_j > ZERO_TOL ? Math.floor(R / r_j) : Infinity; + const k_j_max_S = Math.floor(S / s_j); + const k_j_max = Math.min(k_j_max_R, k_j_max_S); + if (k_j_max < 0) return; + + // largest k_i that still fits once k_j batches of j are committed + const kIatJ = (k_j: number): number => { + const remR = R - k_j * r_j; + const remS = S - k_j * s_j; + if (remR < -ZERO_TOL || remS < -ZERO_TOL) return -1; + const k_i_R = r_i > ZERO_TOL ? Math.floor(remR / r_i) : Infinity; + const k_i_S = s_i > 0 ? Math.floor(remS / s_i) : 0; + const k_i = Math.min(k_i_R, k_i_S); + return k_i < 0 ? 0 : isFinite(k_i) ? k_i : 0; + }; + + const scoreAtKj = (k_j: number): { score: number; k_i: number } => { + const k_i = kIatJ(k_j); + if (k_i < 0) return { score: -Infinity, k_i: -1 }; + const score = evalScore([ + [iIdx, k_i], + [jIdx, k_j], + ]); + return { score, k_i }; + }; + + const best = ternaryMaxOver(0, k_j_max, scoreAtKj); + if (best.k_i >= 0 && best.score > -Infinity) { + const alloc = new Map(); + if (best.k_i > 0) alloc.set(iIdx, best.k_i); + if (best.k > 0) alloc.set(jIdx, best.k); + tryUpdate(best.score, alloc); + } +} + +// Triple scan: nested ternary search on k_i and k_j, with k_k determined by +// the leftover budget. +function tripleScan( + iIdx: number, + jIdx: number, + kIdx: number, + options: LaunchOption[], + R: number, + S: number, + evalScore: EvalFn, + tryUpdate: (score: number, alloc: Map) => void +) { + const oi = options[iIdx]; + const oj = options[jIdx]; + const ok = options[kIdx]; + const r_i = oi.actualFuel, + s_i = oi.actualTime; + const r_j = oj.actualFuel, + s_j = oj.actualTime; + const r_k = ok.actualFuel, + s_k = ok.actualTime; + if (s_i <= 0 || s_j <= 0 || s_k <= 0) return; + + const k_i_max = Math.min(r_i > ZERO_TOL ? Math.floor(R / r_i) : Infinity, Math.floor(S / s_i)); + if (!isFinite(k_i_max) || k_i_max < 0) return; + + const scoreGivenIJ = (k_i: number, k_j: number) => { + const remR = R - k_i * r_i - k_j * r_j; + const remS = S - k_i * s_i - k_j * s_j; + if (remR < -ZERO_TOL || remS < -ZERO_TOL) return { score: -Infinity, k_k: -1 }; + const k_k_R = r_k > ZERO_TOL ? Math.floor(remR / r_k) : Infinity; + const k_k_S = Math.floor(remS / s_k); + const k_k = Math.min(k_k_R, k_k_S); + if (!isFinite(k_k) || k_k < 0) return { score: -Infinity, k_k: -1 }; + const score = evalScore([ + [iIdx, k_i], + [jIdx, k_j], + [kIdx, k_k], + ]); + return { score, k_k }; + }; + + const outerEval = (k_i: number) => { + const k_j_max = Math.min( + r_j > ZERO_TOL ? Math.floor((R - k_i * r_i) / r_j) : Infinity, + Math.floor((S - k_i * s_i) / s_j) + ); + if (!isFinite(k_j_max) || k_j_max < 0) return { score: -Infinity, k_j: -1, k_k: -1 }; + const inner = ternaryMaxOver(0, k_j_max, k_j => { + const r = scoreGivenIJ(k_i, k_j); + return { score: r.score, k_k: r.k_k }; + }); + return { score: inner.score, k_j: inner.k, k_k: inner.k_k }; + }; + + const outer = ternaryMaxOver(0, k_i_max, k_i => { + const o = outerEval(k_i); + return { score: o.score, k_j: o.k_j, k_k: o.k_k }; + }); + + if (outer.score > -Infinity) { + const alloc = new Map(); + if (outer.k > 0) alloc.set(iIdx, outer.k); + if (outer.k_j > 0) alloc.set(jIdx, outer.k_j); + if (outer.k_k > 0) alloc.set(kIdx, outer.k_k); + tryUpdate(outer.score, alloc); + } +} + +// Ternary search over an integer interval for the max of an approximately +// concave function. Extra fields returned by the probe ride along with the +// winning result. +function ternaryMaxOver>( + lo: number, + hi: number, + probe: (k: number) => { score: number } & E +): { score: number; k: number } & E { + if (hi < lo) { + return { score: -Infinity, k: lo, ...({} as E) }; + } + let bestK = lo; + let bestProbe = probe(lo); + let bestScore = bestProbe.score; + if (hi !== lo) { + const ph = probe(hi); + if (ph.score > bestScore) { + bestScore = ph.score; + bestK = hi; + bestProbe = ph; + } + } + while (hi - lo > 2) { + const m1 = lo + Math.floor((hi - lo) / 3); + const m2 = hi - Math.floor((hi - lo) / 3); + const p1 = probe(m1); + const p2 = probe(m2); + if (p1.score > bestScore) { + bestScore = p1.score; + bestK = m1; + bestProbe = p1; + } + if (p2.score > bestScore) { + bestScore = p2.score; + bestK = m2; + bestProbe = p2; + } + if (p1.score < p2.score) lo = m1 + 1; + else hi = m2 - 1; + } + for (let k = lo; k <= hi; k++) { + const p = probe(k); + if (p.score > bestScore) { + bestScore = p.score; + bestK = k; + bestProbe = p; + } + } + return { ...(bestProbe as E), score: bestScore, k: bestK }; +} + +interface JointLpResult { + score: number; + x: Float64Array; // multiplicity per surviving option + support: number[]; // indices into `options` with x > 0 + dualR: number; // fuel budget shadow price + dualS: number; // time budget shadow price + nodeDuals: Map; // per conservation row, keyed by node id +} + +function solveJointLp( + survivors: number[], + options: LaunchOption[], + innerLp: InnerLp, + R: number, + S: number, + baseYield: Map, + targets: string[], + recipeDag: RecipeDAG, + QByTarget: Map +): JointLpResult { + const nx = survivors.length; + const np = innerLp.nonLeafNodes.length; + const totalVars = nx + np; + if (totalVars === 0) { + return { + score: 0, + x: new Float64Array(0), + support: [], + dualR: 0, + dualS: 0, + nodeDuals: new Map(), + }; + } + + // Objective: weighted crafts plus each option's direct legendary yield. + // Ordinary target drops aren't rewarded here — their value flows through + // the conservation rows. + const c = new Float64Array(totalVars); + for (const [t, q] of QByTarget) { + const tIdx = innerLp.varIndex.get(t); + if (tIdx !== undefined) c[nx + tIdx] = q; + } + for (let s = 0; s < nx; s++) { + const opt = options[survivors[s]]; + let ci = 0; + for (const t of targets) ci += opt.legendaryYieldVector.get(t) ?? 0; + if (ci) c[s] = ci; + } + + const A: Float64Array[] = []; + const bArr: number[] = []; + + // budget rows + const rRow = new Float64Array(totalVars); + const sRow = new Float64Array(totalVars); + for (let s = 0; s < nx; s++) { + rRow[s] = options[survivors[s]].actualFuel; + sRow[s] = options[survivors[s]].actualTime; + } + A.push(rRow); + bArr.push(R); + A.push(sRow); + bArr.push(S); + + // Conservation rows, one per consumed node n: + // sum_parents q * p_parent - (p_n if non-leaf) - sum_i x_i * yield_i[n] <= base_yield[n] + const parentsOf = new Map(); + for (const [pid, pnode] of recipeDag) { + if (pnode.isLeaf) continue; + for (const child of pnode.children) { + let arr = parentsOf.get(child.nodeId); + if (!arr) { + arr = []; + parentsOf.set(child.nodeId, arr); + } + arr.push({ parent: pid, q: child.quantity }); + } + } + + // remember which row belongs to which node so duals can be mapped back + const constraintRowNode: string[] = []; + for (const nodeId of recipeDag.keys()) { + const parents = parentsOf.get(nodeId); + if (!parents || parents.length === 0) continue; + const row = new Float64Array(totalVars); + for (const { parent, q } of parents) { + const pIdx = innerLp.varIndex.get(parent); + if (pIdx !== undefined) row[nx + pIdx] += q; + } + const myIdx = innerLp.varIndex.get(nodeId); + if (myIdx !== undefined) row[nx + myIdx] -= 1; + for (let s = 0; s < nx; s++) { + const v = options[survivors[s]].yieldVector.get(nodeId) ?? 0; + if (v) row[s] -= v; + } + A.push(row); + bArr.push(baseYield.get(nodeId) ?? 0); + constraintRowNode.push(nodeId); + } + + const b = new Float64Array(bArr); + const result = solveLp(c, A, b); + if (result.status !== 'optimal') { + return { + score: 0, + x: new Float64Array(nx), + support: [], + dualR: 0, + dualS: 0, + nodeDuals: new Map(), + }; + } + const x = new Float64Array(nx); + const support: number[] = []; + for (let s = 0; s < nx; s++) { + x[s] = result.primal[s]; + if (x[s] > ZERO_TOL) support.push(survivors[s]); + } + let score = result.objective; + for (const [t, q] of QByTarget) score += q * (baseYield.get(t) ?? 0); + const dualR = result.duals[0]; + const dualS = result.duals[1]; + const nodeDuals = new Map(); + for (let r = 0; r < constraintRowNode.length; r++) { + nodeDuals.set(constraintRowNode[r], result.duals[2 + r]); + } + return { score, x, support, dualR, dualS, nodeDuals }; +} diff --git a/wasmegg/artifact-explorer/src/lib/optimizer-views.spec.ts b/wasmegg/artifact-explorer/src/lib/optimizer-views.spec.ts new file mode 100644 index 000000000..1ecd0fe89 --- /dev/null +++ b/wasmegg/artifact-explorer/src/lib/optimizer-views.spec.ts @@ -0,0 +1,297 @@ +// Pins for the display-row builders. DAGs and solutions are hand-built over +// the real lunar totem tier chain, since the builders resolve names and icons +// through getArtifactTierPropsFromId. + +import { describe, expect, it } from 'vitest'; +import { ei, getMissionTypeFromId, iconURL, Inventory } from 'lib'; + +import { + computeCraftChainRows, + computeInventoryRows, + computeMissionLegendaryRows, + lambdaFromDropProbability, + legendaryCraftProbabilityOf, +} from './optimizer-views'; +import { makeNode } from './spec-helpers'; +import type { LaunchSolution, OptimizerSolution, RecipeDAG } from './types'; + +const Name = ei.ArtifactSpec.Name; +const Level = ei.ArtifactSpec.Level; +const Rarity = ei.ArtifactSpec.Rarity; + +const lt1 = 'lunar-totem-1'; +const lt2 = 'lunar-totem-2'; +const lt3 = 'lunar-totem-3'; +const lt4 = 'lunar-totem-4'; + +// lt4 needs 2x lt3 + 1x lt2; both intermediates share the lt1 leaf. +function totemDag(): RecipeDAG { + return new Map([ + [ + lt4, + makeNode(lt4, false, [ + [lt3, 2], + [lt2, 1], + ]), + ], + [lt3, makeNode(lt3, false, [[lt1, 3]])], + [lt2, makeNode(lt2, false, [[lt1, 2]])], + [lt1, makeNode(lt1, true)], + ]); +} + +// 4 common + 1 legendary T1 totems, 2 rare T2 totems. +function totemInventory(): Inventory { + return new Inventory({ + inventoryItems: [ + { artifact: { spec: { name: Name.LUNAR_TOTEM, level: Level.INFERIOR, rarity: Rarity.COMMON } }, quantity: 4 }, + { artifact: { spec: { name: Name.LUNAR_TOTEM, level: Level.INFERIOR, rarity: Rarity.LEGENDARY } }, quantity: 1 }, + { artifact: { spec: { name: Name.LUNAR_TOTEM, level: Level.LESSER, rarity: Rarity.RARE } }, quantity: 2 }, + ], + }); +} + +function makeSolution(overrides: Partial): OptimizerSolution { + return { + bestProbability: 0, + craftProbability: 0, + dropProbability: 0, + expectedCrafts: 0, + fuelUsed: 0, + fuelByEgg: new Map(), + timeUnitsUsed: 0, + choiceHistory: [], + expectedDrops: [], + finalYieldVector: new Map(), + baseYield: new Map(), + recipeDag: new Map(), + craftPrimal: new Map(), + perTarget: [], + ...overrides, + }; +} + +describe('computeInventoryRows', () => { + it('returns nothing without a player inventory', () => { + expect(computeInventoryRows(lt4, totemDag(), null)).toEqual([]); + }); + + it('walks the DAG breadth-first, summing owned counts across rarities', () => { + const rows = computeInventoryRows(lt4, totemDag(), totemInventory()); + expect(rows).toEqual([ + { + nodeId: lt4, + name: 'Eggceptional lunar totem', + iconUrl: iconURL('egginc/afx_lunar_totem_4.png', 64), + depth: 0, + have: 0, + needed: 1, + }, + { + nodeId: lt3, + name: 'Powerful lunar totem', + iconUrl: iconURL('egginc/afx_lunar_totem_3.png', 64), + depth: 1, + have: 0, + needed: 2, + }, + { + nodeId: lt2, + name: 'Lunar totem', + iconUrl: iconURL('egginc/afx_lunar_totem_2.png', 64), + depth: 1, + have: 2, + needed: 1, + }, + // shared leaf appears once; `needed` comes from the first parent + // reached (lt3's 3 per craft), not lt2's + { + nodeId: lt1, + name: 'Basic lunar totem', + iconUrl: iconURL('egginc/afx_lunar_totem_1.png', 64), + depth: 2, + have: 5, + needed: 3, + }, + ]); + }); + + it('skips child references that are missing from the DAG', () => { + const dag: RecipeDAG = new Map([ + [ + lt2, + makeNode(lt2, false, [ + [lt1, 2], + ['puzzle-cube-1', 1], + ]), + ], + [lt1, makeNode(lt1, true)], + ]); + const rows = computeInventoryRows(lt2, dag, totemInventory()); + expect(rows.map(r => r.nodeId)).toEqual([lt2, lt1]); + }); + + it('returns nothing when the root is not in the DAG', () => { + expect(computeInventoryRows('puzzle-cube-1', totemDag(), totemInventory())).toEqual([]); + }); +}); + +describe('computeCraftChainRows', () => { + it('returns nothing when the root is not in the DAG', () => { + expect(computeCraftChainRows(makeSolution({}), lt4, null)).toEqual([]); + }); + + it('breaks down owned/dropped/crafted/consumed per node, excluding the root', () => { + const solution = makeSolution({ + recipeDag: totemDag(), + // 2 root crafts eat 4x lt3 + 2x lt2; 4 lt3 crafts eat 12x lt1 + craftPrimal: new Map([ + [lt4, 2], + [lt3, 4], + ]), + finalYieldVector: new Map([ + [lt3, 10], + [lt2, 1], + [lt1, 12], + ]), + baseYield: new Map([ + [lt3, 3], + [lt2, 5], + ]), + }); + const rows = computeCraftChainRows(solution, lt4, totemInventory()); + expect(rows).toEqual([ + { + nodeId: lt3, + name: 'Powerful lunar totem', + iconUrl: iconURL('egginc/afx_lunar_totem_3.png', 64), + depth: 1, + qtyPerParentCraft: 2, + owned: 0, + dropped: 7, + crafted: 4, + consumed: 4, + }, + // baseYield exceeds finalYield here; dropped clamps to 0 + { + nodeId: lt2, + name: 'Lunar totem', + iconUrl: iconURL('egginc/afx_lunar_totem_2.png', 64), + depth: 1, + qtyPerParentCraft: 1, + owned: 2, + dropped: 0, + crafted: 0, + consumed: 2, + }, + { + nodeId: lt1, + name: 'Basic lunar totem', + iconUrl: iconURL('egginc/afx_lunar_totem_1.png', 64), + depth: 2, + qtyPerParentCraft: 3, + owned: 5, + dropped: 12, + crafted: 0, + consumed: 12, + }, + ]); + }); + + it('reports owned as 0 without a player inventory', () => { + const solution = makeSolution({ recipeDag: totemDag() }); + const rows = computeCraftChainRows(solution, lt4, null); + expect(rows.map(r => r.owned)).toEqual([0, 0, 0]); + }); + + it('skips child references that are missing from the DAG', () => { + const dag: RecipeDAG = new Map([ + [ + lt4, + makeNode(lt4, false, [ + [lt3, 2], + ['puzzle-cube-1', 1], + ]), + ], + [lt3, makeNode(lt3, true)], + ]); + const rows = computeCraftChainRows(makeSolution({ recipeDag: dag }), lt4, null); + expect(rows.map(r => r.nodeId)).toEqual([lt3]); + }); + + it('tolerates cycles between ingredients without looping', () => { + const dag: RecipeDAG = new Map([ + [ + lt4, + makeNode(lt4, false, [ + [lt3, 1], + [lt2, 1], + ]), + ], + [lt3, makeNode(lt3, false, [[lt2, 1]])], + [lt2, makeNode(lt2, false, [[lt3, 1]])], + ]); + const rows = computeCraftChainRows(makeSolution({ recipeDag: dag }), lt4, null); + expect(rows.map(r => r.nodeId)).toEqual([lt3, lt2]); + }); +}); + +describe('computeMissionLegendaryRows', () => { + const ship = getMissionTypeFromId('henerprise-extended'); + + function makeChoice(numShipsLaunched: number, legendary: [string, number][]): LaunchSolution { + return { + ship, + actualFuel: 0, + actualFuelByEgg: new Map(), + actualTime: 0, + target: '', + targetAfxId: Name.LUNAR_TOTEM, + numShipsLaunched, + supplyVector: new Map(), + legendarySupplyVector: new Map(legendary), + }; + } + + it('scales per-batch legendary supply by batches of 3 ships', () => { + const solution = makeSolution({ + choiceHistory: [ + makeChoice(6, [[lt4, 0.5]]), // 2 batches * 0.5 = 1 expected drop + makeChoice(9, [[lt3, 0.5]]), // supplies a different node only + makeChoice(3, [[lt4, 0.0001]]), // exactly at the noise threshold + ], + }); + const rows = computeMissionLegendaryRows(solution, lt4); + expect(rows).toHaveLength(1); + expect(rows[0].ship).toBe(ship); + expect(rows[0].targetAfxId).toBe(Name.LUNAR_TOTEM); + expect(rows[0].numShipsLaunched).toBe(6); + expect(rows[0].legendaryDrops).toBeCloseTo(1, 12); + }); + + it('returns nothing for an empty history', () => { + expect(computeMissionLegendaryRows(makeSolution({}), lt4)).toEqual([]); + }); +}); + +describe('lambdaFromDropProbability', () => { + it('inverts P = 1 - e^-lambda', () => { + expect(lambdaFromDropProbability(0.5)).toBeCloseTo(Math.LN2, 12); + expect(lambdaFromDropProbability(1 - Math.exp(-2))).toBeCloseTo(2, 12); + }); + + it('returns 0 outside (0, 1)', () => { + expect(lambdaFromDropProbability(0)).toBe(0); + expect(lambdaFromDropProbability(1)).toBe(0); + expect(lambdaFromDropProbability(-0.2)).toBe(0); + expect(lambdaFromDropProbability(1.5)).toBe(0); + }); +}); + +describe('legendaryCraftProbabilityOf', () => { + it('reads the root craft probability off the DAG, defaulting to 0', () => { + const dag: RecipeDAG = new Map([[lt4, makeNode(lt4, false, [], 0.25)]]); + expect(legendaryCraftProbabilityOf(makeSolution({ recipeDag: dag }), lt4)).toBe(0.25); + expect(legendaryCraftProbabilityOf(makeSolution({ recipeDag: dag }), lt1)).toBe(0); + }); +}); diff --git a/wasmegg/artifact-explorer/src/lib/optimizer-views.ts b/wasmegg/artifact-explorer/src/lib/optimizer-views.ts new file mode 100644 index 000000000..6ad78a74e --- /dev/null +++ b/wasmegg/artifact-explorer/src/lib/optimizer-views.ts @@ -0,0 +1,164 @@ +// Display-ready row builders derived from an OptimizerSolution. + +import type { ei, Inventory, MissionType } from 'lib'; +import { getArtifactTierPropsFromId, iconURL } from 'lib'; +import type { DAGNode, OptimizerSolution } from './types'; + +export interface InventoryRow { + nodeId: string; + name: string; + iconUrl: string; + depth: number; + have: number; + needed: number; +} + +// Walk the recipe DAG from the targeted artifact, collecting the player's +// owned counts (all rarities) for each ingredient. Feeds the inventory panel. +export function computeInventoryRows( + rootId: string, + recipeDag: Map, + playerInventory: Inventory | null +): InventoryRow[] { + if (!playerInventory) return []; + + const rows: InventoryRow[] = []; + const visited = new Set(); + const queue: { nodeId: string; depth: number; needed: number }[] = [{ nodeId: rootId, depth: 0, needed: 1 }]; + + while (queue.length > 0) { + const { nodeId, depth, needed } = queue.shift()!; + if (visited.has(nodeId)) continue; + visited.add(nodeId); + + const node = recipeDag.get(nodeId); + if (!node) continue; + + const props = getArtifactTierPropsFromId(nodeId); + const item = playerInventory.getItem({ name: props.afx_id, level: props.afx_level }); + const have = item.haveRarity[0] + item.haveRarity[1] + item.haveRarity[2] + item.haveRarity[3]; + + rows.push({ + nodeId, + name: props.name, + iconUrl: iconURL('egginc/' + props.icon_filename, 64), + depth, + have, + needed, + }); + + for (const child of node.children) { + queue.push({ nodeId: child.nodeId, depth: depth + 1, needed: child.quantity }); + } + } + + return rows; +} + +export interface CraftChainRow { + nodeId: string; + name: string; + iconUrl: string; + depth: number; + qtyPerParentCraft: number; + owned: number; + dropped: number; + crafted: number; + consumed: number; +} + +export interface MissionLegendaryRow { + ship: MissionType; + targetAfxId: ei.ArtifactSpec.Name; + numShipsLaunched: number; + legendaryDrops: number; +} + +// Invert P(drop) = 1 - e^(-lambda); 0 outside (0, 1). +export function lambdaFromDropProbability(p: number): number { + return p > 0 && p < 1 ? -Math.log(1 - p) : 0; +} + +// Craft-chain breakdown rows for the probability display. consumed[B] is the +// LP-implied number of B eaten by the recipes the optimizer chose to run, +// shown alongside the per-node owned / dropped / crafted quantities. +export function computeCraftChainRows( + solution: OptimizerSolution, + rootId: string, + playerInventory: Inventory | null +): CraftChainRow[] { + const dag = solution.recipeDag; + const rootNode = dag.get(rootId); + if (!rootNode) return []; + + const consumed = new Map(); + for (const [nodeId, node] of dag) { + if (node.isLeaf) continue; + const crafted = solution.craftPrimal.get(nodeId) ?? 0; + if (crafted <= 0) continue; + for (const child of node.children) { + consumed.set(child.nodeId, (consumed.get(child.nodeId) ?? 0) + crafted * child.quantity); + } + } + + const rows: CraftChainRow[] = []; + const visited = new Set([rootId]); + const queue: { nodeId: string; depth: number; qty: number }[] = rootNode.children.map(c => ({ + nodeId: c.nodeId, + depth: 1, + qty: c.quantity, + })); + + while (queue.length > 0) { + const item = queue.shift()!; + if (visited.has(item.nodeId)) continue; + visited.add(item.nodeId); + const node = dag.get(item.nodeId); + if (!node) continue; + const props = getArtifactTierPropsFromId(item.nodeId); + let ownedCount = 0; + if (playerInventory) { + const it = playerInventory.getItem({ name: props.afx_id, level: props.afx_level }); + ownedCount = it.haveRarity[0] + it.haveRarity[1] + it.haveRarity[2] + it.haveRarity[3]; + } + rows.push({ + nodeId: item.nodeId, + name: props.name, + iconUrl: iconURL('egginc/' + props.icon_filename, 64), + depth: item.depth, + qtyPerParentCraft: item.qty, + owned: ownedCount, + dropped: Math.max( + 0, + (solution.finalYieldVector.get(item.nodeId) ?? 0) - (solution.baseYield.get(item.nodeId) ?? 0) + ), + crafted: solution.craftPrimal.get(item.nodeId) ?? 0, + consumed: consumed.get(item.nodeId) ?? 0, + }); + for (const child of node.children) { + if (!visited.has(child.nodeId)) { + queue.push({ nodeId: child.nodeId, depth: item.depth + 1, qty: child.quantity }); + } + } + } + + return rows; +} + +// Per-mission expected direct legendary drops of the targeted root. +// legendary_supply_vector is per batch of 3 ships, hence the /3. Missions +// contributing essentially nothing are dropped from the breakdown. +export function computeMissionLegendaryRows(solution: OptimizerSolution, rootId: string): MissionLegendaryRow[] { + return solution.choiceHistory + .map(choice => ({ + ship: choice.ship, + targetAfxId: choice.targetAfxId, + numShipsLaunched: choice.numShipsLaunched, + legendaryDrops: (choice.numShipsLaunched / 3) * (choice.legendarySupplyVector.get(rootId) ?? 0), + })) + .filter(row => row.legendaryDrops > 0.0001); +} + +export function legendaryCraftProbabilityOf(solution: OptimizerSolution, rootId: string): number { + return solution.recipeDag.get(rootId)?.legendaryCraftProbability ?? 0; +} diff --git a/wasmegg/artifact-explorer/src/lib/phases.ts b/wasmegg/artifact-explorer/src/lib/phases.ts new file mode 100644 index 000000000..3dd27276a --- /dev/null +++ b/wasmegg/artifact-explorer/src/lib/phases.ts @@ -0,0 +1,149 @@ +// Recipe DAG construction and launch option enumeration for the optimizer. + +import { missions } from '@/lib/filter'; +import { + ei, + getArtifactTierPropsFromId, + getArtifactName, + getMissionTypeFromId, + ShipsConfig, + type MissionType, +} from 'lib'; + +import type { DAGChildRef, DAGNode, LaunchOption, RecipeDAG } from './types'; +import { getMissionLootData, MIN_LEGENDARY_OBSERVATIONS } from '@/lib'; +import { sum } from '@/utils'; +import { Ingredient } from 'lib/artifacts/data-json'; + +// Recursively add `id` and everything in its crafting tree to `recipeDag`. +// Non-craftable artifacts become leaves with no children. +export function generateRecipeDag(id: string, recipeDag: RecipeDAG) { + if (recipeDag.has(id)) return; + + const artifactData = getArtifactTierPropsFromId(id); + + const artifactIngredients = artifactData.recipe?.ingredients ?? []; + + const dagNode: DAGNode = { + id, + isLeaf: !artifactData.craftable, + children: artifactIngredients.map( + (ingredient: Ingredient): DAGChildRef => ({ + nodeId: ingredient.id, + quantity: ingredient.count, + }) + ), + legendaryCraftProbability: 0, // buildRecipeDag fills this in for the root + }; + + recipeDag.set(id, dagNode); + + for (const ingredient of artifactIngredients) { + generateRecipeDag(ingredient.id, recipeDag); + } +} + +// Enumerate launch options: every visible ship crossed with its applicable +// mission targets, each carrying fuel cost, duration, and per-launch yield +// vectors. Missions shorter than minDurationSeconds (if given) are skipped. +export function enumerateLaunchOptions( + playerConfig: ShipsConfig, + dag: RecipeDAG, + minDurationSeconds?: number +): LaunchOption[] { + const options: LaunchOption[] = []; + + // Artifact families represented anywhere in the DAG. Targeting boosts a + // whole family, so that's the granularity that matters here. + const dagAfxIds = new Set(); + for (const nodeId of dag.keys()) { + dagAfxIds.add(getArtifactTierPropsFromId(nodeId).afx_id); + } + + for (const mission of missions) { + if (!playerConfig.shipVisibility[mission.shipType]) continue; + + if (minDurationSeconds !== undefined) { + const missionDuration = mission.boostedDurationSeconds(playerConfig); + if (missionDuration < minDurationSeconds) continue; + } + + const missionData = getMissionLootData(mission.missionTypeId); + const levelLootData = missionData.levels[playerConfig.shipLevels[mission.shipType]]; + const missionType = getMissionTypeFromId(missionData.missionId); + const missionCapacity = missionType.boostedCapacity(playerConfig); + const maxMissionCapacity = missionType.maxBoostedCapacity(); + + const applicableTargets = mission.isFTL + ? levelLootData.targets + : levelLootData.targets.filter(target => target.targetAfxId === ei.ArtifactSpec.Name.UNKNOWN); + + // Keep every untargeted option and every option targeting a family in the + // DAG. Targets outside the DAG are interchangeable for our purposes, so + // keep just one representative: the one with the most recorded drops, + // since its sampled rates are the most trustworthy. + let bestNonDagTarget: (typeof applicableTargets)[number] | undefined; + for (const target of applicableTargets) { + if (target.targetAfxId === ei.ArtifactSpec.Name.UNKNOWN) continue; + if (dagAfxIds.has(target.targetAfxId)) continue; + if (bestNonDagTarget === undefined || target.totalDrops > bestNonDagTarget.totalDrops) { + bestNonDagTarget = target; + } + } + + for (const target of applicableTargets) { + const minTotalLaunches = target.totalDrops / maxMissionCapacity; + + // Cannot use the missionDataNotEnough function as it's too conservative + // It uses the base launch capacity which yields too high of an expected launch count + // Though, this effect will + if (minTotalLaunches < 20 && !playerConfig.showNodata) continue; + + if (target.targetAfxId !== ei.ArtifactSpec.Name.UNKNOWN && !dagAfxIds.has(target.targetAfxId)) { + if (target !== bestNonDagTarget) continue; + } + + const option = makeLaunchOption(mission, target.targetAfxId, playerConfig); + for (const item of target.items) { + const expectedDropsPerBatch = (sum(item.counts) / target.totalDrops) * missionCapacity * 3.0; + option.supplyVector.set(item.itemId, expectedDropsPerBatch); + + if (dag.has(item.itemId)) { + // Zero out legendary counts below the observation minimum — a single + // legendary across tens of thousands of drops gives a misleadingly + // precise rate. + const observed = item.counts[3]; + const legendaryCount = observed >= MIN_LEGENDARY_OBSERVATIONS || playerConfig.showNodata ? observed : 0; + const legendaryRate = (legendaryCount / target.totalDrops) * missionCapacity * 3.0; + + option.yieldVector.set(item.itemId, expectedDropsPerBatch); + option.legendaryYieldVector.set(item.itemId, legendaryRate); + } + } + + options.push(option); + } + } + + return options; +} + +function makeLaunchOption(mission: MissionType, target: ei.ArtifactSpec.Name, playerConfig: ShipsConfig): LaunchOption { + const id = `${mission.missionTypeId}::${target}`; + const fuelUse = mission.virtueFuels; + + const nonHumilityFuelUse = fuelUse.filter(x => x.egg !== ei.Egg.HUMILITY); + + return { + id, + ship: mission, + target: getArtifactName(target), + targetAfxId: target, + actualFuel: nonHumilityFuelUse.reduce((agg, current) => agg + current.amount, 0) * 3, + actualTime: mission.boostedDurationSeconds(playerConfig), + fuelByEgg: nonHumilityFuelUse.reduce((agg, current) => agg.set(current.egg, current.amount * 3), new Map()), + supplyVector: new Map(), + yieldVector: new Map(), + legendaryYieldVector: new Map(), + }; +} diff --git a/wasmegg/artifact-explorer/src/lib/pipeline.spec.ts b/wasmegg/artifact-explorer/src/lib/pipeline.spec.ts new file mode 100644 index 000000000..4aa4e663d --- /dev/null +++ b/wasmegg/artifact-explorer/src/lib/pipeline.spec.ts @@ -0,0 +1,107 @@ +// End-to-end coverage of the production pipeline on real game data: recipe +// DAG construction, launch option enumeration, and a full optimize() run. +// Assertions stick to structure and invariants so loot data refreshes don't +// break them; the one exact-recipe check (puzzle cube) is stable game design. + +import { describe, it, expect } from 'vitest'; +import { ei, perfectShipsConfig } from 'lib'; +import { buildRecipeDag, computeBaseYield, optimize } from '@/lib'; +import { enumerateLaunchOptions } from './phases'; + +const Name = ei.ArtifactSpec.Name; + +describe('buildRecipeDag', () => { + it('builds the real puzzle cube recipe chain', () => { + const dag = buildRecipeDag(['puzzle-cube-4']); + + for (const tier of ['puzzle-cube-1', 'puzzle-cube-2', 'puzzle-cube-3', 'puzzle-cube-4']) { + expect(dag.has(tier)).toBe(true); + } + expect(dag.get('puzzle-cube-4')!.isLeaf).toBe(false); + expect(dag.get('puzzle-cube-1')!.isLeaf).toBe(true); + expect(dag.get('puzzle-cube-1')!.children).toEqual([]); + expect(dag.get('puzzle-cube-2')!.children).toEqual([{ nodeId: 'puzzle-cube-1', quantity: 3 }]); + + // every child reference resolves within the DAG + for (const node of dag.values()) { + for (const child of node.children) { + expect(dag.has(child.nodeId)).toBe(true); + expect(child.quantity).toBeGreaterThanOrEqual(1); + } + } + }); + + it('puts the legendary craft probability on the root only', () => { + const dag = buildRecipeDag(['puzzle-cube-4']); + const root = dag.get('puzzle-cube-4')!; + expect(root.legendaryCraftProbability).toBeGreaterThan(0); + expect(root.legendaryCraftProbability).toBeLessThanOrEqual(1); + for (const node of dag.values()) { + if (node.id === root.id) continue; + expect(node.legendaryCraftProbability).toBe(0); + } + }); +}); + +describe('enumerateLaunchOptions', () => { + const dag = buildRecipeDag(['puzzle-cube-4']); + + it('produces well-formed options from real loot data', () => { + const options = enumerateLaunchOptions(perfectShipsConfig, dag); + expect(options.length).toBeGreaterThan(0); + for (const o of options) { + expect(o.actualTime).toBeGreaterThan(0); + expect(o.actualFuel).toBeGreaterThanOrEqual(0); + for (const itemId of o.yieldVector.keys()) { + expect(dag.has(itemId)).toBe(true); + } + for (const itemId of o.legendaryYieldVector.keys()) { + expect(dag.has(itemId)).toBe(true); + } + } + expect(options.some(o => o.targetAfxId === Name.UNKNOWN)).toBe(true); + expect(options.some(o => o.targetAfxId === Name.PUZZLE_CUBE)).toBe(true); + }); + + it('drops missions shorter than minDurationSeconds', () => { + const all = enumerateLaunchOptions(perfectShipsConfig, dag); + const minDuration = 4 * 3600; + const longOnly = enumerateLaunchOptions(perfectShipsConfig, dag, minDuration); + expect(longOnly.length).toBeGreaterThan(0); + expect(longOnly.length).toBeLessThan(all.length); + for (const o of longOnly) { + expect(o.actualTime).toBeGreaterThanOrEqual(minDuration); + } + }); +}); + +describe('optimize', () => { + it('runs the full pipeline within budgets', () => { + const config = { + desiredArtifactNodeIds: ['puzzle-cube-4'], + includeNotEnoughData: false, + fuelTankCapacity: 2_000_000_000, + timeBudgetSeconds: 3 * 24 * 3600, + }; + const dag = buildRecipeDag(config.desiredArtifactNodeIds); + const baseYield = computeBaseYield(null, config.desiredArtifactNodeIds, dag); + const [sol] = optimize(config, perfectShipsConfig, dag, baseYield); + + expect(sol.fuelUsed).toBeLessThanOrEqual(config.fuelTankCapacity + 1e-6); + expect(sol.timeUnitsUsed).toBeLessThanOrEqual(config.timeBudgetSeconds + 1); + expect(sol.bestProbability).toBeGreaterThan(0); + expect(sol.bestProbability).toBeLessThanOrEqual(1); + expect(sol.perTarget[0].bestProbability).toBeCloseTo(sol.bestProbability, 12); + expect(sol.choiceHistory.length).toBeGreaterThan(0); + + // presentation pass: sorted by ship, drop rows filled in + for (let i = 1; i < sol.choiceHistory.length; i++) { + expect(sol.choiceHistory[i - 1].ship.shipType).toBeLessThanOrEqual(sol.choiceHistory[i].ship.shipType); + } + expect(sol.expectedDrops.length).toBeGreaterThan(0); + for (const row of sol.expectedDrops) { + expect(row.expected).toBeGreaterThan(0); + expect(row.iconUrl).toMatch(/^https:/); + } + }); +}); diff --git a/wasmegg/artifact-explorer/src/lib/spec-helpers.ts b/wasmegg/artifact-explorer/src/lib/spec-helpers.ts new file mode 100644 index 000000000..f189dfde7 --- /dev/null +++ b/wasmegg/artifact-explorer/src/lib/spec-helpers.ts @@ -0,0 +1,41 @@ +// Shared fixtures for the optimizer specs: hand-built DAG nodes and launch +// options with small controlled numbers so tests can assert exact arithmetic. + +import { ei, MissionType } from 'lib'; +import type { DAGNode, LaunchOption } from './types'; + +export function makeNode(id: string, isLeaf: boolean, children: [string, number][] = [], pCraft = 0): DAGNode { + return { + id, + isLeaf, + children: children.map(([nodeId, quantity]) => ({ nodeId, quantity })), + legendaryCraftProbability: pCraft, + }; +} + +// The ship is irrelevant to the optimizer core (only the presentation layer +// reads it), so every fixture option flies the same one. +const fixtureShip = new MissionType(ei.MissionInfo.Spaceship.CHICKEN_ONE, ei.MissionInfo.DurationType.SHORT); + +let seq = 0; + +export function makeOpt( + actualFuel: number, + actualTime: number, + yieldEntries: [string, number][], + legendaryEntries: [string, number][] = [], + targetAfxId: ei.ArtifactSpec.Name = ei.ArtifactSpec.Name.UNKNOWN +): LaunchOption { + return { + id: `opt-${seq++}`, + ship: fixtureShip, + target: null, + targetAfxId, + actualFuel, + fuelByEgg: new Map(), + actualTime, + supplyVector: new Map(yieldEntries), + yieldVector: new Map(yieldEntries), + legendaryYieldVector: new Map(legendaryEntries), + }; +} diff --git a/wasmegg/artifact-explorer/src/lib/types.ts b/wasmegg/artifact-explorer/src/lib/types.ts new file mode 100644 index 000000000..b05a09eaa --- /dev/null +++ b/wasmegg/artifact-explorer/src/lib/types.ts @@ -0,0 +1,90 @@ +import { ei, MissionType } from 'lib'; + +// documents intent only, not enforced +type integer = number; +export type { integer }; + +export interface LaunchOption { + id: string; + ship: MissionType; + target: string | null; + targetAfxId: ei.ArtifactSpec.Name; // UNKNOWN when untargeted + actualFuel: number; + fuelByEgg: Map; + actualTime: number; + // everything this launch drops, per batch — display only + supplyVector: Map; + // subset of supplyVector restricted to recipe ingredients; this is what + // the optimizer feeds the inner LP + yieldVector: Map; + legendaryYieldVector: Map; +} + +export interface DAGChildRef { + nodeId: string; + quantity: integer; +} + +export interface DAGNode { + id: string; + isLeaf: boolean; // raw drop only, not craftable + children: DAGChildRef[]; + legendaryCraftProbability: number; // non-zero only on the targeted root +} + +export type RecipeDAG = Map; + +export interface LaunchSolution { + ship: MissionType; + actualFuel: number; + actualFuelByEgg: Map; + actualTime: number; + target: string; + targetAfxId: ei.ArtifactSpec.Name; + numShipsLaunched: integer; + supplyVector: Map; + legendarySupplyVector: Map; +} + +export interface DropRow { + itemId: string; + name: string; + iconUrl: string; + expected: number; + relevant: boolean; +} + +export interface TargetProbability { + nodeId: string; + bestProbability: number; + craftProbability: number; + dropProbability: number; + expectedCrafts: number; +} + +export interface OptimizerSolution { + // the scalar probability fields describe the primary target; multi-target + // consumers should read perTarget instead + bestProbability: number; + craftProbability: number; + dropProbability: number; + expectedCrafts: number; + fuelUsed: number; + fuelByEgg: Map; + timeUnitsUsed: integer; + choiceHistory: LaunchSolution[]; + expectedDrops: DropRow[]; + finalYieldVector: Map; + // owned-inventory head start already baked into finalYieldVector + baseYield: Map; + recipeDag: RecipeDAG; + craftPrimal: Map; + perTarget: TargetProbability[]; // perTarget[0] mirrors the scalar fields +} + +export interface OptimizerConfig { + desiredArtifactNodeIds: string[]; + includeNotEnoughData: boolean; + fuelTankCapacity: integer; + timeBudgetSeconds: number; +} diff --git a/wasmegg/artifact-explorer/src/lib/value-function.spec.ts b/wasmegg/artifact-explorer/src/lib/value-function.spec.ts new file mode 100644 index 000000000..0bb8a741a --- /dev/null +++ b/wasmegg/artifact-explorer/src/lib/value-function.spec.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from 'vitest'; +import { compileInnerLp, alphaToProb } from './value-function'; +import { makeNode } from './spec-helpers'; +import type { RecipeDAG, DAGNode } from './types'; + +const PREC = 9; + +function dag(...nodes: DAGNode[]): RecipeDAG { + return new Map(nodes.map(n => [n.id, n])); +} + +function diamondDag(): RecipeDAG { + // A needs B and C, which both need the same leaf D + return dag( + makeNode('A', false, [ + ['B', 1], + ['C', 1], + ]), + makeNode('B', false, [['D', 1]]), + makeNode('C', false, [['D', 1]]), + makeNode('D', true) + ); +} + +describe('compileInnerLp + solve: alpha computation', () => { + it('leaf root: alpha is just the inventory count', () => { + const d = dag(makeNode('A', true)); + const lp = compileInnerLp(d, ['A']); + expect(lp.solve(new Map([['A', 7]])).alpha).toBeCloseTo(7, PREC); + }); + + it('leaf root with empty inventory', () => { + const d = dag(makeNode('A', true)); + const lp = compileInnerLp(d, ['A']); + expect(lp.solve(new Map()).alpha).toBeCloseTo(0, PREC); + }); + + it('no desired artifacts', () => { + const d = dag(makeNode('A', true)); + const lp = compileInnerLp(d, []); + expect(lp.solve(new Map([['A', 10]])).alpha).toBeCloseTo(0, PREC); + }); + + it('linear chain: each B makes one A', () => { + const d = dag(makeNode('A', false, [['B', 1]]), makeNode('B', true)); + const lp = compileInnerLp(d, ['A']); + expect(lp.solve(new Map([['B', 10]])).alpha).toBeCloseTo(10, PREC); + }); + + it('linear chain with qty 2 gives a fractional LP relaxation', () => { + // 2 B per A, 5 B available, so alpha = 2.5 + const d = dag(makeNode('A', false, [['B', 2]]), makeNode('B', true)); + const lp = compileInnerLp(d, ['A']); + expect(lp.solve(new Map([['B', 5]])).alpha).toBeCloseTo(2.5, PREC); + }); + + it('two-level chain', () => { + const d = dag(makeNode('A', false, [['B', 1]]), makeNode('B', false, [['C', 1]]), makeNode('C', true)); + const lp = compileInnerLp(d, ['A']); + expect(lp.solve(new Map([['C', 8]])).alpha).toBeCloseTo(8, PREC); + }); + + it('diamond dependency splits the shared ingredient', () => { + // B and C both consume D, so 6 D only supports 3 A. A naive tree + // recursion would count the 6 D twice and report 6. + const lp = compileInnerLp(diamondDag(), ['A']); + expect(lp.solve(new Map([['D', 6]])).alpha).toBeCloseTo(3, PREC); + }); + + it('diamond dependency, smaller inventory', () => { + const lp = compileInnerLp(diamondDag(), ['A']); + expect(lp.solve(new Map([['D', 4]])).alpha).toBeCloseTo(2, PREC); + }); + + it('zero inventory of the only ingredient', () => { + const d = dag(makeNode('A', false, [['B', 1]]), makeNode('B', true)); + const lp = compileInnerLp(d, ['A']); + expect(lp.solve(new Map([['B', 0]])).alpha).toBeCloseTo(0, PREC); + }); + + it('shadow price of the ingredient in a linear chain is 1', () => { + const d = dag(makeNode('A', false, [['B', 1]]), makeNode('B', true)); + const lp = compileInnerLp(d, ['A']); + const result = lp.solve(new Map([['B', 10]])); + expect(result.duals.get('B')).toBeCloseTo(1, PREC); + }); + + it('shadow price of the shared diamond ingredient is 0.5', () => { + const lp = compileInnerLp(diamondDag(), ['A']); + const result = lp.solve(new Map([['D', 6]])); + expect(result.duals.get('D')).toBeCloseTo(0.5, PREC); + }); + + it('repeated solves on one compiled LP do not leak state', () => { + // the outer search reuses one compiled LP millions of times + const d = dag(makeNode('A', false, [['B', 1]]), makeNode('B', true)); + const lp = compileInnerLp(d, ['A']); + const r1 = lp.solve(new Map([['B', 3]])); + const r2 = lp.solve(new Map([['B', 7]])); + expect(r1.alpha).toBeCloseTo(3, PREC); + expect(r2.alpha).toBeCloseTo(7, PREC); + }); +}); + +describe('alphaToProb', () => { + function makedag(pCraft: number) { + return dag(makeNode('A', false, [], pCraft)); + } + + it('alpha=0 means no crafting regardless of pCraft', () => { + const r = alphaToProb(0, new Map(), ['A'], makedag(0.5)); + expect(r.craftProbability).toBeCloseTo(0, PREC); + expect(r.dropProbability).toBeCloseTo(0, PREC); + expect(r.bestProbability).toBeCloseTo(0, PREC); + }); + + it('craft probability is 1 - (1-p)^alpha', () => { + // p=0.5, alpha=4: 1 - 0.0625 + const r = alphaToProb(4, new Map(), ['A'], makedag(0.5)); + expect(r.craftProbability).toBeCloseTo(0.9375, PREC); + expect(r.dropProbability).toBeCloseTo(0, PREC); + expect(r.bestProbability).toBeCloseTo(0.9375, PREC); + }); + + it('craft probability with alpha=2', () => { + const r = alphaToProb(2, new Map(), ['A'], makedag(0.5)); + expect(r.craftProbability).toBeCloseTo(0.75, PREC); + }); + + it('drop-only path when pCraft is 0', () => { + const r = alphaToProb(1, new Map([['A', 1]]), ['A'], makedag(0)); + expect(r.craftProbability).toBeCloseTo(0, PREC); + expect(r.dropProbability).toBeCloseTo(1 - Math.exp(-1), PREC); + expect(r.bestProbability).toBeCloseTo(1 - Math.exp(-1), PREC); + }); + + it('pCraft=1 is a guaranteed craft', () => { + const r = alphaToProb(2, new Map(), ['A'], makedag(1.0)); + expect(r.craftProbability).toBeCloseTo(1, PREC); + expect(r.bestProbability).toBeCloseTo(1, PREC); + }); + + it('drop probability follows the Poisson rate', () => { + const r = alphaToProb(0, new Map([['A', 2]]), ['A'], makedag(0)); + expect(r.craftProbability).toBeCloseTo(0, PREC); + expect(r.dropProbability).toBeCloseTo(1 - Math.exp(-2), PREC); + }); + + it('combines craft and drop by inclusion-exclusion', () => { + const craft = 0.9375; // p=0.5, alpha=4 + const drop = 1 - Math.exp(-1); + const expectedBest = 1 - (1 - craft) * (1 - drop); + const r = alphaToProb(4, new Map([['A', 1]]), ['A'], makedag(0.5)); + expect(r.craftProbability).toBeCloseTo(craft, PREC); + expect(r.dropProbability).toBeCloseTo(drop, PREC); + expect(r.bestProbability).toBeCloseTo(expectedBest, PREC); + }); + + it('empty desired list gives all zeros', () => { + const r = alphaToProb(10, new Map([['A', 5]]), [], makedag(0.9)); + expect(r.craftProbability).toBe(0); + expect(r.dropProbability).toBe(0); + expect(r.bestProbability).toBe(0); + }); +}); diff --git a/wasmegg/artifact-explorer/src/lib/value-function.ts b/wasmegg/artifact-explorer/src/lib/value-function.ts new file mode 100644 index 000000000..285ee88f5 --- /dev/null +++ b/wasmegg/artifact-explorer/src/lib/value-function.ts @@ -0,0 +1,210 @@ +// Inner crafting LP: given an inventory, maximize sum over targets T of +// w_T * (crafts of T), subject to recipe conservation. Every node consumed by +// some parent gets a conservation row, targets included. A final target (no +// parents) has no row, so dropped copies of it don't count as crafts; a +// target that is also an ingredient keeps its row and its drops feed the +// parent recipe. compileInnerLp builds the matrix once; the outer search +// then scores millions of candidate inventories against it. + +import type { RecipeDAG } from './types'; +import { solveLp } from './lp'; + +export interface AlphaResult { + alpha: number; // craftable count of targets[0]; 0 when it's a leaf + score: number; // weighted objective at the optimum + craftByTarget: Map; + duals: Map; // shadow price per constraint node + primalByNode: Map; // crafted count per non-leaf node +} + +export interface InnerLp { + readonly nonLeafNodes: readonly string[]; // decision variable order + readonly constraintNodes: readonly string[]; // constraint row order + readonly varIndex: ReadonlyMap; + readonly root: string; + readonly targets: readonly string[]; + readonly weightByTarget: ReadonlyMap; + + solve(inventory: Map): AlphaResult; + // Hot-path variant: caller supplies b directly (one entry per + // constraintNodes row) and gets back only the weighted objective, skipping + // the per-call result Maps. 0 when the LP is not optimal. + solveScore(b: Float64Array): number; +} + +// `weights` is the per-target objective weight; targets without an entry get +// weight 1 (callers that just want craftable counts omit it entirely). +export function compileInnerLp( + recipeDag: RecipeDAG, + desiredArtifactNodeIds: string[], + weights?: Map +): InnerLp { + if (desiredArtifactNodeIds.length === 0) { + return makeTrivialLp('', [], new Map()); + } + const targets = desiredArtifactNodeIds; + const primary = targets[0]; + + // Non-leaf nodes become decision variables p_n. + const nonLeafNodes: string[] = []; + const varIndex = new Map(); + for (const [id, node] of recipeDag) { + if (!node.isLeaf) { + varIndex.set(id, nonLeafNodes.length); + nonLeafNodes.push(id); + } + } + + // Objective weight per craftable target. A leaf target can't be crafted, so it + // contributes no objective term (its legendary chance is drops-only). + const weightByTarget = new Map(); + for (const t of targets) { + if (varIndex.has(t)) weightByTarget.set(t, weights?.get(t) ?? 1); + } + if (weightByTarget.size === 0) { + // nothing craftable; fall back to holdings of the primary target + return makeTrivialLp(primary, targets, weightByTarget); + } + + // for each node, who consumes it and at what rate + const parentsOf = new Map(); + for (const [parentId, parentNode] of recipeDag) { + if (parentNode.isLeaf) continue; + for (const child of parentNode.children) { + let parents = parentsOf.get(child.nodeId); + if (!parents) { + parents = []; + parentsOf.set(child.nodeId, parents); + } + parents.push({ parent: parentId, q: child.quantity }); + } + } + + // One constraint per consumed node: + // sum_parents q * p_parent - (p_n if non-leaf) <= inventory[n] + const constraintNodes: string[] = []; + for (const id of recipeDag.keys()) { + const parents = parentsOf.get(id); + if (!parents || parents.length === 0) continue; + constraintNodes.push(id); + } + + const nVars = nonLeafNodes.length; + const nCons = constraintNodes.length; + + const c = new Float64Array(nVars); + for (const [t, w] of weightByTarget) c[varIndex.get(t)!] = w; + + const A: Float64Array[] = new Array(nCons); + for (let i = 0; i < nCons; i++) { + const id = constraintNodes[i]; + const row = new Float64Array(nVars); + const parents = parentsOf.get(id) ?? []; + for (const { parent, q } of parents) { + const idx = varIndex.get(parent); + if (idx !== undefined) row[idx] += q; + } + if (varIndex.has(id)) row[varIndex.get(id)!] -= 1; + A[i] = row; + } + + const bScratch = new Float64Array(nCons); + + return { + nonLeafNodes, + constraintNodes, + varIndex, + root: primary, + targets, + weightByTarget, + + solve(inventory: Map): AlphaResult { + for (let i = 0; i < nCons; i++) { + const v = inventory.get(constraintNodes[i]); + bScratch[i] = v !== undefined && v > 0 ? v : 0; + } + const r = solveLp(c, A, bScratch); + if (r.status !== 'optimal') { + return { alpha: 0, score: 0, craftByTarget: new Map(), duals: new Map(), primalByNode: new Map() }; + } + const craftByTarget = new Map(); + for (const t of weightByTarget.keys()) { + craftByTarget.set(t, r.primal[varIndex.get(t)!]); + } + const alpha = craftByTarget.get(primary) ?? 0; + const duals = new Map(); + for (let i = 0; i < nCons; i++) { + duals.set(constraintNodes[i], r.duals[i]); + } + const primalByNode = new Map(); + for (let i = 0; i < nonLeafNodes.length; i++) { + if (r.primal[i] > 1e-9) { + primalByNode.set(nonLeafNodes[i], r.primal[i]); + } + } + return { alpha, score: r.objective, craftByTarget, duals, primalByNode }; + }, + + solveScore(b: Float64Array): number { + const r = solveLp(c, A, b); + return r.status === 'optimal' ? r.objective : 0; + }, + }; +} + +function makeTrivialLp(primary: string, targets: readonly string[], weightByTarget: Map): InnerLp { + return { + nonLeafNodes: [], + constraintNodes: [], + varIndex: new Map(), + root: primary, + targets, + weightByTarget, + solve(inventory: Map): AlphaResult { + const v = inventory.get(primary) ?? 0; + return { alpha: v > 0 ? v : 0, score: 0, craftByTarget: new Map(), duals: new Map(), primalByNode: new Map() }; + }, + solveScore(): number { + return 0; + }, + }; +} + +export interface ProbabilityFields { + bestProbability: number; + craftProbability: number; + dropProbability: number; +} + +// Map a target's craftable count plus its legendary-drop rate into +// probabilities: +// craft = 1 - (1 - pCraft)^alpha +// drop = 1 - e^(-lambda) (Poisson on direct legendary drops) +// best = 1 - (1 - craft)(1 - drop) +export function alphaToProb( + alpha: number, + legendaryYield: Map, + desiredArtifactNodeIds: string[], + recipeDag: RecipeDAG +): ProbabilityFields { + if (desiredArtifactNodeIds.length === 0) { + return { bestProbability: 0, craftProbability: 0, dropProbability: 0 }; + } + const root = desiredArtifactNodeIds[0]; + const node = recipeDag.get(root); + const pCraft = node?.legendaryCraftProbability ?? 0; + + const a = alpha > 0 ? alpha : 0; + let craftProbability = 0; + if (pCraft > 0 && a > 0) { + if (pCraft >= 1) craftProbability = 1; + else craftProbability = 1 - Math.exp(a * Math.log(1 - pCraft)); + } + + const lambda = legendaryYield.get(root) ?? 0; + const dropProbability = lambda > 0 ? 1 - Math.exp(-lambda) : 0; + + const bestProbability = 1 - (1 - craftProbability) * (1 - dropProbability); + + return { bestProbability, craftProbability: craftProbability, dropProbability }; +} diff --git a/wasmegg/artifact-explorer/src/main.ts b/wasmegg/artifact-explorer/src/main.ts index d59ee9ca2..62ff6ab2c 100644 --- a/wasmegg/artifact-explorer/src/main.ts +++ b/wasmegg/artifact-explorer/src/main.ts @@ -1,4 +1,5 @@ import { createApp } from 'vue'; +import { createPinia } from 'pinia'; import router from './router'; import App from './App.vue'; import './index.css'; @@ -10,6 +11,8 @@ import 'tippy.js/themes/translucent.css'; const app = createApp(App); app.use(router); +const pinia = createPinia(); +app.use(pinia); app.use(VueTippy, { defaultProps: { theme: 'translucent', diff --git a/wasmegg/artifact-explorer/src/router.ts b/wasmegg/artifact-explorer/src/router.ts index 844b02a54..b4693eb99 100644 --- a/wasmegg/artifact-explorer/src/router.ts +++ b/wasmegg/artifact-explorer/src/router.ts @@ -3,6 +3,7 @@ import { createRouter, createWebHashHistory } from 'vue-router'; import Main from '@/views/Main.vue'; import Mission from '@/views/Mission.vue'; import Artifact from '@/views/Artifact.vue'; +import FuelTankPlanner from '@/views/FuelTankPlanner.vue'; const router = createRouter({ routes: [ @@ -28,6 +29,14 @@ const router = createRouter({ }, props: true, }, + { + name: 'tank', + path: 'tank/:tankPlannerArtifactId/', + components: { + tank: FuelTankPlanner, + }, + props: true, + }, ], }, { diff --git a/wasmegg/artifact-explorer/src/store/index.ts b/wasmegg/artifact-explorer/src/store/index.ts index 6379f2402..272d5a692 100644 --- a/wasmegg/artifact-explorer/src/store/index.ts +++ b/wasmegg/artifact-explorer/src/store/index.ts @@ -1,17 +1,44 @@ -import { ref } from 'vue'; +import { computed, ref, shallowRef } from 'vue'; import { ei, fixOldShipsConfig, + fuelTankSizes, + getArtifactTierPropsFromId, + getCraftingLevelFromXp, getLocalStorage, + getXPFromCraftingLevel, + Inventory, isOldShipsConfig, isShipsConfig, + newShipsConfig, perfectShipsConfig, setLocalStorage, + shipLevelLaunchPointThresholds, ShipsConfig, + spaceshipList, } from 'lib'; +import Spaceship = ei.MissionInfo.Spaceship; +import DurationType = ei.MissionInfo.DurationType; + +import { + ExtrasConfig, + isExtrasConfig, + isMissionFilters, + isOverrideFlags, + MissionFilters, + newExtras, + newMissionFilters, + newOverrides, + OverrideFlags, +} from './schema'; +export type { ExtrasConfig, MissionFilters, OverrideFlags } from './schema'; + export const CONFIG_LOCALSTORAGE_KEY = 'config'; +export const OVERRIDES_LOCALSTORAGE_KEY = 'overrides'; +export const EXTRAS_LOCALSTORAGE_KEY = 'extras'; +export const MISSION_FILTERS_LOCALSTORAGE_KEY = 'mission_filters'; // config is persisted through a watch in App.vue. export const config = ref(loadConfig()); @@ -33,6 +60,194 @@ export function setShipVisibility(ship: ei.MissionInfo.Spaceship, visible: boole config.value.shipVisibility[ship] = visible; } +export const overrides = ref(loadOverrides()); +export const playerOverridesModalOpen = ref(false); + +export function setOverrideCraftingLevel(b: boolean): void { + overrides.value.craftingLevel = b; +} + +export function setOverridePreviousCrafts(b: boolean): void { + overrides.value.previousCrafts = b; +} + +export function setOverrideFTL(b: boolean): void { + overrides.value.epicResearchFTLLevel = b; +} + +export function setOverrideZerog(b: boolean): void { + overrides.value.epicResearchZerogLevel = b; +} + +export function setOverrideShipLevel(ship: Spaceship, b: boolean): void { + overrides.value.shipLevels[ship] = b; +} + +export function setOverrideShipVisibility(ship: Spaceship, b: boolean): void { + overrides.value.shipVisibility[ship] = b; +} + +export function resetAllOverrides(): void { + overrides.value = newOverrides(); +} + +export function takeControlOfAllShips(): void { + for (const s of spaceshipList) { + overrides.value.shipLevels[s] = true; + overrides.value.shipVisibility[s] = true; + } +} + +export function setOverrideTankLevel(b: boolean): void { + overrides.value.tankLevel = b; +} + +export const extras = ref(loadExtras()); + +export function setCraftingLevel(level: number): void { + extras.value.craftingLevel = level; +} + +export function setPreviousCraftCount(count: number): void { + extras.value.previousCrafts = count; +} + +export function setTankLevel(level: number): void { + extras.value.tankLevel = level; +} + +// Player data loaded from a save. Never persisted. +export const playerShipsConfig = ref(null); +export const playerInventory = shallowRef(null); +export const playerTotalCraftingXp = ref(null); +export const playerTankLevel = ref(null); + +// Set by ArtifactMissionOptimizer so the override modal can show the prior +// craft count for the targeted artifact. +export const currentOptimizerArtifactId = ref(null); + +export const playerCraftingLevel = computed(() => { + const xp = playerTotalCraftingXp.value; + if (xp == null) return null; + return getCraftingLevelFromXp(xp).level; +}); + +export const playerPreviousCrafts = computed(() => { + const inv = playerInventory.value; + const id = currentOptimizerArtifactId.value; + if (!inv || !id) return null; + const props = getArtifactTierPropsFromId(id); + return inv.getItem({ name: props.afx_id, level: props.afx_level }).crafted; +}); + +// Effective values consumed by the optimizer. +export const effectiveCraftingLevel = computed(() => { + const player = playerCraftingLevel.value; + if (player == null) return extras.value.craftingLevel; + return overrides.value.craftingLevel ? extras.value.craftingLevel : player; +}); + +export const effectivePreviousCrafts = computed(() => { + const player = playerPreviousCrafts.value; + if (player == null) return extras.value.previousCrafts; + return overrides.value.previousCrafts ? extras.value.previousCrafts : player; +}); + +export const effectiveTankLevel = computed(() => { + const player = playerTankLevel.value; + if (player == null) return fuelTankSizes.length - 1; // largest + return overrides.value.tankLevel ? extras.value.tankLevel : player; +}); + +export const effectiveFuelTankCapacity = computed(() => fuelTankSizes[effectiveTankLevel.value]); + +// The optimizer takes XP, not a level, so convert back. +export const effectiveTotalCraftingXp = computed(() => getXPFromCraftingLevel(effectiveCraftingLevel.value)); + +// What the optimizer reads: the manual config when no player data is loaded, +// otherwise player data with overridden fields taken from the manual config. +export const effectiveConfig = computed(() => { + const player = playerShipsConfig.value; + if (!player) return config.value; + const o = overrides.value; + const shipLevels = { ...player.shipLevels }; + const shipVisibility = { ...player.shipVisibility }; + for (const s of spaceshipList) { + if (o.shipLevels[s]) shipLevels[s] = config.value.shipLevels[s]; + if (o.shipVisibility[s]) shipVisibility[s] = config.value.shipVisibility[s]; + } + return { + ...player, + epicResearchFTLLevel: o.epicResearchFTLLevel ? config.value.epicResearchFTLLevel : player.epicResearchFTLLevel, + epicResearchZerogLevel: o.epicResearchZerogLevel + ? config.value.epicResearchZerogLevel + : player.epicResearchZerogLevel, + shipLevels, + shipVisibility, + showNodata: config.value.showNodata, + targets: config.value.targets, + }; +}); + +function computeShipLevelFromPoints(shipType: Spaceship, points: number): number { + const thresholds = shipLevelLaunchPointThresholds(shipType); + let level = 0; + for (; level < thresholds.length; level++) { + if (points < thresholds[level]) return Math.max(0, level - 1); + } + return thresholds.length - 1; +} + +export function setPlayerData(backup: ei.IBackup): void { + if (!backup.game || !backup.artifactsDb) return; + + const base = newShipsConfig(backup.game); + + // Accumulate launch points per ship from completed missions. + const launchPoints: Partial> = {}; + const hasLaunched: Partial> = {}; + + const missions = (backup.artifactsDb.missionArchive ?? []) + .concat(backup.artifactsDb.missionInfos ?? []) + .filter(m => (m.status ?? 0) >= ei.MissionInfo.Status.EXPLORING); + + for (const mission of missions) { + const ship = mission.ship!; + let pts = 1.0; + if (mission.durationType === DurationType.LONG) pts = 1.4; + else if (mission.durationType === DurationType.EPIC) pts = 1.8; + launchPoints[ship] = (launchPoints[ship] ?? 0) + pts; + hasLaunched[ship] = true; + } + + for (const shipType of spaceshipList) { + base.shipLevels[shipType] = computeShipLevelFromPoints(shipType, launchPoints[shipType] ?? 0); + // Chicken One is always available; other ships require completed missions. + base.shipVisibility[shipType] = shipType === Spaceship.CHICKEN_ONE ? true : (hasLaunched[shipType] ?? false); + } + + // targets and showNodata aren't in the backup; keep the user's settings + base.targets = config.value.targets; + base.showNodata = config.value.showNodata; + + playerShipsConfig.value = base; + + const inv = new Inventory(backup.artifactsDb, { virtue: true }); + for (const item of backup.artifactsDb.virtueAfxDb?.artifactStatus || []) { + inv.getItem(item.spec!).crafted += item.count!; + } + playerInventory.value = inv; + playerTotalCraftingXp.value = Math.floor(backup.artifacts?.craftingXp ?? 0); + playerTankLevel.value = backup.artifacts?.tankLevel ?? null; +} + +export function clearPlayerData(): void { + playerShipsConfig.value = null; + playerInventory.value = null; + playerTotalCraftingXp.value = null; + playerTankLevel.value = null; +} + export function loadConfig(): ShipsConfig { const str = getLocalStorage(CONFIG_LOCALSTORAGE_KEY); if (!str) { @@ -74,3 +289,99 @@ export function openConfigModal(): void { export function closeConfigModal(): void { configModalOpen.value = false; } + +export function openPlayerOverridesModal(): void { + playerOverridesModalOpen.value = true; +} + +export function closePlayerOverridesModal(): void { + playerOverridesModalOpen.value = false; +} + +export function loadOverrides(): OverrideFlags { + const str = getLocalStorage(OVERRIDES_LOCALSTORAGE_KEY); + if (!str) return newOverrides(); + try { + const parsed: unknown = JSON.parse(str); + if (isOverrideFlags(parsed)) { + return { + ...parsed, + tankLevel: parsed.tankLevel ?? false, + }; + } + } catch (err) { + console.warn(`error parsing overrides: ${err}`); + } + return newOverrides(); +} + +export function persistOverrides(): void { + setLocalStorage(OVERRIDES_LOCALSTORAGE_KEY, JSON.stringify(overrides.value)); +} + +export function loadExtras(): ExtrasConfig { + const str = getLocalStorage(EXTRAS_LOCALSTORAGE_KEY); + if (!str) return newExtras(fuelTankSizes.length - 1); + try { + const parsed: unknown = JSON.parse(str); + if (isExtrasConfig(parsed)) { + return { + ...parsed, + tankLevel: parsed.tankLevel ?? fuelTankSizes.length - 1, + }; + } + } catch (err) { + console.warn(`error parsing extras: ${err}`); + } + return newExtras(fuelTankSizes.length - 1); +} + +export function persistExtras(): void { + setLocalStorage(EXTRAS_LOCALSTORAGE_KEY, JSON.stringify(extras.value)); +} + +export const missionFilters = ref(loadMissionFilters()); + +export function setMinDurationHoursEnabled(enabled: boolean): void { + missionFilters.value.minDurationHoursEnabled = enabled; +} + +export function setMinDurationHours(hours: number): void { + missionFilters.value.minDurationHours = Math.max(0, hours); +} + +export function loadMissionFilters(): MissionFilters { + const str = getLocalStorage(MISSION_FILTERS_LOCALSTORAGE_KEY); + if (!str) return newMissionFilters(); + try { + const parsed: unknown = JSON.parse(str); + if (isMissionFilters(parsed)) { + return parsed; + } + } catch (err) { + console.warn(`error parsing mission filters: ${err}`); + } + return newMissionFilters(); +} + +export function persistMissionFilters(): void { + setLocalStorage(MISSION_FILTERS_LOCALSTORAGE_KEY, JSON.stringify(missionFilters.value)); +} + +export const AUTO_COMPUTE_LOCALSTORAGE_KEY = 'auto_compute'; + +export const autoCompute = ref(loadAutoCompute()); + +export function setAutoCompute(b: boolean): void { + autoCompute.value = b; +} + +function loadAutoCompute(): boolean { + const str = getLocalStorage(AUTO_COMPUTE_LOCALSTORAGE_KEY); + if (str === 'false') return false; + return true; +} + +export function persistAutoCompute(): void { + setLocalStorage(AUTO_COMPUTE_LOCALSTORAGE_KEY, String(autoCompute.value)); +} diff --git a/wasmegg/artifact-explorer/src/store/schema.ts b/wasmegg/artifact-explorer/src/store/schema.ts new file mode 100644 index 000000000..342090819 --- /dev/null +++ b/wasmegg/artifact-explorer/src/store/schema.ts @@ -0,0 +1,84 @@ +// Persisted config shapes, defaults, and the type guards that validate +// localStorage values. Kept free of side effects (no localStorage or window +// access at import time) so it can be unit tested under node. + +import type { ei } from 'lib'; + +type Spaceship = ei.MissionInfo.Spaceship; + +// Per-field flags: true means use the manual config value instead of the +// value from player data. +export interface OverrideFlags { + craftingLevel: boolean; + previousCrafts: boolean; + epicResearchFTLLevel: boolean; + epicResearchZerogLevel: boolean; + shipLevels: Partial>; + shipVisibility: Partial>; + tankLevel: boolean; +} + +export function newOverrides(): OverrideFlags { + return { + craftingLevel: false, + previousCrafts: false, + epicResearchFTLLevel: false, + epicResearchZerogLevel: false, + shipLevels: {}, + shipVisibility: {}, + tankLevel: false, + }; +} + +export function isOverrideFlags(x: unknown): x is OverrideFlags { + if (!x || typeof x !== 'object') return false; + const o = x as OverrideFlags; + return ( + typeof o.previousCrafts === 'boolean' && + typeof o.craftingLevel === 'boolean' && + typeof o.epicResearchFTLLevel === 'boolean' && + typeof o.epicResearchZerogLevel === 'boolean' && + typeof o.shipLevels === 'object' && + o.shipLevels !== null && + typeof o.shipVisibility === 'object' && + o.shipVisibility !== null && + (o.tankLevel === undefined || typeof o.tankLevel === 'boolean') + ); +} + +// Manual values that aren't part of ShipsConfig, persisted alongside config +// and overrides. +export interface ExtrasConfig { + craftingLevel: number; + previousCrafts: number; + tankLevel: number; +} + +export function newExtras(maxTankLevel: number): ExtrasConfig { + return { craftingLevel: 30, previousCrafts: 0, tankLevel: maxTankLevel }; +} + +export function isExtrasConfig(x: unknown): x is ExtrasConfig { + if (!x || typeof x !== 'object') return false; + const e = x as ExtrasConfig; + return ( + typeof e.craftingLevel === 'number' && + typeof e.previousCrafts === 'number' && + (e.tankLevel === undefined || typeof e.tankLevel === 'number') + ); +} + +export interface MissionFilters { + minDurationHoursEnabled: boolean; + minDurationHours: number; +} + +export function newMissionFilters(): MissionFilters { + return { minDurationHoursEnabled: false, minDurationHours: 0 }; +} + +export function isMissionFilters(x: unknown): x is MissionFilters { + if (!x || typeof x !== 'object') return false; + const m = x as MissionFilters; + return typeof m.minDurationHoursEnabled === 'boolean' && typeof m.minDurationHours === 'number'; +} diff --git a/wasmegg/artifact-explorer/src/store/store.spec.ts b/wasmegg/artifact-explorer/src/store/store.spec.ts new file mode 100644 index 000000000..6fd7a5317 --- /dev/null +++ b/wasmegg/artifact-explorer/src/store/store.spec.ts @@ -0,0 +1,88 @@ +// These cover ./schema only. The reactive store in ./index.ts reads +// localStorage at module load and can't be imported under node, so its +// load/persist helpers are exercised indirectly through these validators. + +import { describe, it, expect } from 'vitest'; + +import { isExtrasConfig, isOverrideFlags, newExtras, newOverrides } from './schema'; + +describe('OverrideFlags', () => { + it('defaults everything to off', () => { + const flags = newOverrides(); + + expect(flags.craftingLevel).toBe(false); + expect(flags.previousCrafts).toBe(false); + expect(flags.epicResearchFTLLevel).toBe(false); + expect(flags.epicResearchZerogLevel).toBe(false); + expect(flags.shipLevels).toEqual({}); + expect(flags.shipVisibility).toEqual({}); + expect(flags.tankLevel).toBe(false); + }); + + it('validates a default object', () => { + expect(isOverrideFlags(newOverrides())).toBe(true); + }); + + it('rejects objects with missing fields', () => { + const invalid = { + craftingLevel: false, + previousCrafts: false, + }; + expect(isOverrideFlags(invalid)).toBe(false); + }); + + it('accepts persisted blobs without tankLevel', () => { + // tankLevel was added later; old localStorage blobs don't have it + const old = { + craftingLevel: false, + previousCrafts: false, + epicResearchFTLLevel: false, + epicResearchZerogLevel: false, + shipLevels: {}, + shipVisibility: {}, + }; + expect(isOverrideFlags(old)).toBe(true); + }); + + it('rejects non-objects', () => { + expect(isOverrideFlags(null)).toBe(false); + expect(isOverrideFlags(undefined)).toBe(false); + expect(isOverrideFlags('string')).toBe(false); + expect(isOverrideFlags(123)).toBe(false); + }); +}); + +describe('ExtrasConfig', () => { + const MAX_TANK_LEVEL = 7; + + it('defaults to max crafting level and the given tank level', () => { + const extras = newExtras(MAX_TANK_LEVEL); + + expect(extras.craftingLevel).toBe(30); + expect(extras.previousCrafts).toBe(0); + expect(extras.tankLevel).toBe(MAX_TANK_LEVEL); + }); + + it('validates a default object', () => { + expect(isExtrasConfig(newExtras(MAX_TANK_LEVEL))).toBe(true); + }); + + it('rejects objects with missing fields', () => { + expect(isExtrasConfig({ craftingLevel: 30 })).toBe(false); + }); + + it('accepts persisted blobs without tankLevel', () => { + const old = { + craftingLevel: 30, + previousCrafts: 0, + }; + expect(isExtrasConfig(old)).toBe(true); + }); + + it('rejects non-objects', () => { + expect(isExtrasConfig(null)).toBe(false); + expect(isExtrasConfig(undefined)).toBe(false); + expect(isExtrasConfig('string')).toBe(false); + expect(isExtrasConfig(123)).toBe(false); + }); +}); diff --git a/wasmegg/artifact-explorer/src/views/FuelTankPlanner.vue b/wasmegg/artifact-explorer/src/views/FuelTankPlanner.vue new file mode 100644 index 000000000..f0ad0b44a --- /dev/null +++ b/wasmegg/artifact-explorer/src/views/FuelTankPlanner.vue @@ -0,0 +1,183 @@ + + + diff --git a/wasmegg/artifact-explorer/src/views/Main.vue b/wasmegg/artifact-explorer/src/views/Main.vue index 43650b0ca..c77ddc4c0 100644 --- a/wasmegg/artifact-explorer/src/views/Main.vue +++ b/wasmegg/artifact-explorer/src/views/Main.vue @@ -1,8 +1,9 @@ @@ -22,6 +24,7 @@ import { useRoute, useRouter } from 'vue-router'; import SpoilerAlert from '@/components/SpoilerAlert.vue'; import ArtifactGrid from '@/components/ArtifactGrid.vue'; import ArtifactSelector from '@/components/ArtifactSelector.vue'; +import TankArtifactSelector from '@/components/TankArtifactSelector.vue'; import MissionSelector from '@/components/MissionSelector.vue'; export default defineComponent({ @@ -29,6 +32,7 @@ export default defineComponent({ SpoilerAlert, ArtifactGrid, ArtifactSelector, + TankArtifactSelector, MissionSelector, }, props: { @@ -40,11 +44,15 @@ export default defineComponent({ type: String as PropType, default: null, }, + tankPlannerArtifactId: { + type: String as PropType, + default: null, + }, }, setup(props) { const router = useRouter(); const route = useRoute(); - const { missionId, artifactId } = toRefs(props); + const { missionId, artifactId, tankPlannerArtifactId } = toRefs(props); const selectedMissionId = ref(missionId.value); watch(missionId, current => { @@ -72,10 +80,24 @@ export default defineComponent({ } }); + const selectedTankArtifactId = ref(tankPlannerArtifactId.value); + watch(tankPlannerArtifactId, current => { + selectedTankArtifactId.value = current; + }); + watch(selectedTankArtifactId, current => { + if (current !== null) { + router.push({ + name: 'tank', + params: { tankPlannerArtifactId: current }, + }); + } + }); + return { route, selectedMissionId, selectedArtifactId, + selectedTankArtifactId, }; }, }); diff --git a/wasmegg/artifact-explorer/tsconfig.json b/wasmegg/artifact-explorer/tsconfig.json index cea0d3357..799147d6d 100644 --- a/wasmegg/artifact-explorer/tsconfig.json +++ b/wasmegg/artifact-explorer/tsconfig.json @@ -3,6 +3,7 @@ "target": "esnext", "module": "esnext", "moduleResolution": "bundler", + "skipLibCheck": true, "strict": true, "jsx": "preserve", "jsxImportSource": "vue", diff --git a/wasmegg/artifact-explorer/vitest.config.ts b/wasmegg/artifact-explorer/vitest.config.ts new file mode 100644 index 000000000..4b9704781 --- /dev/null +++ b/wasmegg/artifact-explorer/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vitest/config'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': new URL('./src', import.meta.url).pathname, + }, + }, + test: { + environment: 'node', + include: ['src/**/*.{test,spec}.ts'], + coverage: { + provider: 'v8', + include: ["src/**/*.ts"], + exclude: ["src/**/*.spec.ts"] + }, + }, +});