diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a8f015..0266409 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,8 @@ jobs: test: name: test (linux) runs-on: ubuntu-latest + outputs: + release-version: ${{ steps.version.outputs.release-version }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: @@ -49,15 +51,17 @@ jobs: toolchain: 1.98.0 components: rustfmt, clippy - - name: Verify release tag matches package versions - if: github.event_name == 'push' + - name: Verify release versions + id: version shell: bash run: | - EXPECTED="${GITHUB_REF_NAME#v}" - DECK_VERSION=$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json,sys; print(json.load(sys.stdin)["packages"][0]["version"])') - WASM_VERSION=$(cargo metadata --manifest-path wasm/Cargo.toml --no-deps --format-version 1 | python3 -c 'import json,sys; print(json.load(sys.stdin)["packages"][0]["version"])') - test "$DECK_VERSION" = "$EXPECTED" - test "$WASM_VERSION" = "$EXPECTED" + DECK_VERSION=$(cargo metadata --no-deps --format-version 1 | python3 -c 'import json,sys; print(next(item["version"] for item in json.load(sys.stdin)["packages"] if item["name"] == "allium-deck"))') + WASM_VERSION=$(cargo metadata --manifest-path wasm/Cargo.toml --no-deps --format-version 1 | python3 -c 'import json,sys; print(next(item["version"] for item in json.load(sys.stdin)["packages"] if item["name"] == "allium-deck-wasm"))') + test "$DECK_VERSION" = "$WASM_VERSION" + if [ "$GITHUB_EVENT_NAME" = push ]; then + test "v$DECK_VERSION" = "$GITHUB_REF_NAME" + fi + echo "release-version=$DECK_VERSION" >> "$GITHUB_OUTPUT" - name: Cache cargo registry and build uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 @@ -69,13 +73,13 @@ jobs: - name: cargo clippy run: | - cargo clippy --all-targets - cargo clippy --manifest-path wasm/Cargo.toml --all-targets + cargo clippy --locked --all-features --all-targets -- -D warnings + cargo clippy --manifest-path wasm/Cargo.toml --all-targets --locked -- -D warnings - name: cargo test run: | - cargo test --all-targets --release - cargo test --manifest-path wasm/Cargo.toml --all-targets --release + cargo test --locked --all-features --all-targets --release + cargo test --manifest-path wasm/Cargo.toml --all-targets --release --locked - name: release packaging tests run: python3 tests/test_release_packaging.py @@ -110,7 +114,6 @@ jobs: name: build (${{ matrix.target }}) needs: test runs-on: ${{ matrix.os }} - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') strategy: fail-fast: false matrix: @@ -178,25 +181,19 @@ jobs: shell: bash run: | mkdir -p release - cp target/${{ matrix.target }}/release/${{ matrix.bin_name }} release/recommend_cli-${{ github.ref_name }}-${{ matrix.asset_name }} + cp target/${{ matrix.target }}/release/${{ matrix.bin_name }} release/recommend_cli-v${{ needs.test.outputs.release-version }}-${{ matrix.asset_name }} - name: Upload artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: recommend_cli-${{ matrix.target }} + name: release-build-${{ matrix.target }} path: release/* + if-no-files-found: error build-wasm-release: name: build WASM npm package needs: test runs-on: ubuntu-latest - outputs: - wasm-version: ${{ steps.inputs.outputs.wasm-version }} - # npm trusted publishing 需要 OIDC token,必须在能发出它的 job 层声明; - # 顶层 `permissions: contents: read` 会被这里的 job 级设置覆盖。 - permissions: - contents: read - id-token: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -204,19 +201,18 @@ jobs: run: echo "SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD)" >> "$GITHUB_ENV" - name: Resolve WASM release version - id: inputs + env: + VERSION: ${{ needs.test.outputs.release-version }} run: | # wasm 与 masterdata 解耦,产物版本即 wasm crate 版本。 - VERSION=$(cargo metadata --manifest-path wasm/Cargo.toml --no-deps --format-version 1 | python3 -c 'import json,sys; print(json.load(sys.stdin)["packages"][0]["version"])') - WASM_ZIP="allium-deck-wasm-${GITHUB_REF_NAME}-cn.zip" - NPM_TGZ="allium-deck-wasm-${GITHUB_REF_NAME}.tgz" + WASM_ZIP="allium-deck-wasm-v${VERSION}-cn.zip" + NPM_TGZ="allium-deck-wasm-v${VERSION}.tgz" { echo "DIST_DIR=dist/deck-wasm" echo "WASM_VERSION=${VERSION}" echo "WASM_ZIP=${WASM_ZIP}" echo "NPM_TGZ=${NPM_TGZ}" } >> "$GITHUB_ENV" - echo "wasm-version=${VERSION}" >> "$GITHUB_OUTPUT" - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable @@ -240,6 +236,11 @@ jobs: CARGO_PROFILE_RELEASE_PANIC: "abort" run: wasm-pack build wasm --target web --scope empty-sekai + - name: Set up Node for packaging and smoke tests + uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Package release files run: | python3 scripts/package_wasm.py \ @@ -266,12 +267,6 @@ jobs: (cd wasm/pkg && npm pack --pack-destination ../../release) mv release/empty-sekai-allium-deck-wasm-*.tgz "release/$NPM_TGZ" - - name: Set up Node for npm trusted publishing - uses: actions/setup-node@v4 - with: - node-version: '24' - registry-url: 'https://registry.npmjs.org' - - name: Smoke-test npm package run: | INSTALL_ROOT=$(mktemp -d) @@ -280,29 +275,103 @@ jobs: cp scripts/smoke_wasm_package.mjs "$INSTALL_ROOT/smoke.mjs" node "$INSTALL_ROOT/smoke.mjs" - - name: Publish to npm (trusted publishing) - run: | - # trusted publishing 硬门槛:npm CLI >= 11.5.1 / Node >= 22.14。 - # runner 自带版本未必满足,强制升到 latest。 - npm install -g npm@latest - # trusted publishing 校验 package.json 的 repository.url 与 GitHub 仓库一致, - # 缺了会报 "Repository URL mismatch"。wasm-pack 生成的 package.json 不带该字段, - # 此处补上并显式设为 public(scoped 包默认 restricted)。 - node -e "const fs=require('fs');const p='wasm/pkg/package.json';const j=JSON.parse(fs.readFileSync(p));j.repository={type:'git',url:'git+https://github.com/empty-sekai/allium-deck.git'};j.publishConfig={access:'public'};fs.writeFileSync(p,JSON.stringify(j,null,2)+'\n')" - cd wasm/pkg - npm publish --provenance - - name: Upload WASM release artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: allium-deck-wasm-${{ github.ref_name }}-cn + name: release-build-wasm path: | release/*.zip release/*.tgz + if-no-files-found: error + + release-assets: + name: verify complete release assets + needs: [test, build-binaries, build-wasm-release] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Download artifacts from this run + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: release-build-* + path: release + merge-multiple: true + + - name: Create and verify SHA256SUMS + env: + VERSION: ${{ needs.test.outputs.release-version }} + run: | + python3 scripts/create_release_checksums.py --directory release --version "$VERSION" + (cd release && sha256sum --check SHA256SUMS) + + - name: Upload verified release assets + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-assets + path: release/* + if-no-files-found: error + + publish-npm: + name: publish npm package + needs: [test, release-assets] + runs-on: ubuntu-latest + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Download verified release assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-assets + path: release + + - name: Set up Node for npm trusted publishing + uses: actions/setup-node@v4 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + + - name: Publish the tested npm tarball (trusted publishing) + env: + VERSION: ${{ needs.test.outputs.release-version }} + run: | + (cd release && sha256sum --check SHA256SUMS) + set +e + node scripts/verify_npm_package.mjs \ + --package @empty-sekai/allium-deck-wasm --version "$VERSION" \ + --tarball "release/allium-deck-wasm-v${VERSION}.tgz" \ + --attempts 3 --delay-ms 2000 --timeout-ms 5000 + LOOKUP_STATUS=$? + set -e + case "$LOOKUP_STATUS" in + 0) exit 0 ;; + 3) ;; + *) exit "$LOOKUP_STATUS" ;; + esac + # Trusted publishing requires npm >= 11.5.1 and Node >= 22.14. + npm install -g npm@latest + set +e + npm publish --provenance --access public "release/allium-deck-wasm-v${VERSION}.tgz" + PUBLISH_STATUS=$? + set -e + if node scripts/verify_npm_package.mjs \ + --package @empty-sekai/allium-deck-wasm --version "$VERSION" \ + --tarball "release/allium-deck-wasm-v${VERSION}.tgz" \ + --attempts 6 --delay-ms 3000 --timeout-ms 5000; then + if [ "$PUBLISH_STATUS" -ne 0 ]; then + echo "npm publish returned ${PUBLISH_STATUS}, but the registry has the tested tarball" + fi + exit 0 + fi + exit 1 publish-crate: name: publish crate - needs: [test, build-binaries, build-wasm-release] + needs: [test, release-assets] runs-on: ubuntu-latest if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') steps: @@ -358,7 +427,7 @@ jobs: release: name: release - needs: [publish-crate, build-wasm-release] + needs: [publish-crate, publish-npm, release-assets] runs-on: ubuntu-latest if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') permissions: @@ -388,6 +457,10 @@ jobs: RANGE="${GITHUB_REF_NAME}" fi git-cliff --tag "${GITHUB_REF_NAME}" --output CHANGELOG.md "$RANGE" + if [ -f "docs/releases/${GITHUB_REF_NAME}.md" ]; then + printf '\n' >> CHANGELOG.md + cat "docs/releases/${GITHUB_REF_NAME}.md" >> CHANGELOG.md + fi { echo "" echo "### Assets" @@ -396,13 +469,18 @@ jobs: echo "| \`recommend_cli-${GITHUB_REF_NAME}-*\` | Prebuilt CLI binaries (Linux x86_64 / musl / aarch64, Windows x86_64, macOS Apple Silicon) |" echo "| \`allium-deck-wasm-${GITHUB_REF_NAME}.tgz\` | Installable npm package \`@empty-sekai/allium-deck-wasm\` |" echo "| \`allium-deck-wasm-${GITHUB_REF_NAME}-cn.zip\` | Browser WASM bundle (ZIP) |" + echo "| \`SHA256SUMS\` | SHA-256 checksums for all five native binaries and both WASM packages |" } >> CHANGELOG.md - - name: Download all artifacts + - name: Download verified release assets uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: + name: release-assets path: artifacts + - name: Verify downloaded release assets + run: (cd artifacts && sha256sum --check SHA256SUMS) + - name: Create GitHub Release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: @@ -412,6 +490,8 @@ jobs: draft: false prerelease: ${{ contains(github.ref_name, '-') }} files: | - artifacts/**/recommend_cli-* - artifacts/**/allium-deck-wasm-*.zip - artifacts/**/allium-deck-wasm-*.tgz + artifacts/recommend_cli-* + artifacts/allium-deck-wasm-*.zip + artifacts/allium-deck-wasm-*.tgz + artifacts/SHA256SUMS + fail_on_unmatched_files: true diff --git a/Cargo.lock b/Cargo.lock index 42707d1..241256c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "allium-deck" -version = "0.0.15" +version = "0.1.0" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 63cd065..f4e673f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "allium-deck" -version = "0.0.15" +version = "0.1.0" edition = "2024" rust-version = "1.89" description = "Project Sekai deck recommendation engine: DFS / branch-and-bound exact search" @@ -13,6 +13,7 @@ include = [ "/README.en.md", "/LICENSE-MIT", "/LICENSE-APACHE", + "/docs/game-rule-sources.md", "/src/**", "/data/**", ] diff --git a/README.en.md b/README.en.md index 9cbdc77..6fd99b9 100644 --- a/README.en.md +++ b/README.en.md @@ -13,7 +13,7 @@ Some in-game values and logic (power, skill bonuses, event points, support decks - https://github.com/Team-Haruki/sekai-deck-recommend-cpp - https://github.com/StarMoe-org/sekai-deck-recommend-cpp -See the individual commit messages for exactly what was ported and corrected. +The direct source of the JP7 business-rule port is **[Team-Haruki/sekai-deck-recommend-cpp](https://github.com/Team-Haruki/sekai-deck-recommend-cpp)** at [`496caed78a07ffe1f2d1f1553047dbf6b2ed0314`](https://github.com/Team-Haruki/sekai-deck-recommend-cpp/commit/496caed78a07ffe1f2d1f1553047dbf6b2ed0314). Area-item, MySEKAI gate, World Link finale, and event-limit rules are reimplemented in the existing Rust engine. See [Business-rule sources](docs/game-rule-sources.md) for source files, upstream attribution, and behavior differences. On top of that, this implementation is not a line-by-line translation: the **low-level hot paths and search pruning have been thoroughly reworked in Rust**, with all core data structures aligned to cache lines: @@ -170,6 +170,7 @@ Flags: | `--world-bloom-character-id` / `--world-bloom-event-turn` / `--challenge-live-character-id` | value | World Bloom / Challenge Live special parameters. | | `--skill-reference-strategy` / `--live-skill-order` / `--specific-skill-order` | value | Skill reference and activation order; a specific order is given as `0,1,2,3,4`. | | `--multi-teammate-power` / `--multi-teammate-score-up` / `--multi-live-score-up-lower-bound` | value | Teammate power, effective skill score-up, and total skill lower bound for multi / Cheerful lives. | +| `--multi-unit-bonus-evaluation` | enum | Owned multi-unit area effects: `by_deck` (default), `force_on`, `force_off`. Shared by deck and area-upgrade recommendations; see the [parameter reference](docs/parameters.md#area-items-and-mysekai-gates). | | `--other-score` / `--life` | value | Cheerful opponent score and life. | | `--rarity4-config` / `--single-card-config` | value | Card training configs, e.g. `level_max,skill_max,master_max,episode_read,canvas` and `123:level_max,skill_max`. | diff --git a/README.md b/README.md index 8e83ad7..64c2397 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Project Sekai 组卡推荐引擎的 Rust 实现,专攻 **DFS / 分支限界( - https://github.com/Team-Haruki/sekai-deck-recommend-cpp - https://github.com/StarMoe-org/sekai-deck-recommend-cpp -具体移植与修正内容见各 commit 说明。 +本次 JP7 业务规则移植的直接来源是 **[Team-Haruki/sekai-deck-recommend-cpp](https://github.com/Team-Haruki/sekai-deck-recommend-cpp)**,对照提交为 [`496caed78a07ffe1f2d1f1553047dbf6b2ed0314`](https://github.com/Team-Haruki/sekai-deck-recommend-cpp/commit/496caed78a07ffe1f2d1f1553047dbf6b2ed0314)。区域道具、MySEKAI 大门、World Link 终章与活动上限等业务规则在现有 Rust 引擎中重新实现。来源文件、上游作者与行为差异见[业务规则来源](docs/game-rule-sources.md)。 在此基础上,本实现并非逐行翻译,而是对**底层热路径与搜索剪枝做了彻底的 Rust 重构**,核心数据结构全部按 cache line 对齐: @@ -189,6 +189,7 @@ recommend_cli \ | `--mode area-items` / `--mode music` / `--mode exact-live` | 模式 | 辅助计算(不组卡):`area-items` 需 `--card-ids`;`music` 需 `--deck`;`exact-live` 需 `--power/--skills/--music-score`。 | | `--skill-reference-strategy` / `--live-skill-order` / `--specific-skill-order` | 值 | 技能参考与发动顺序;指定顺序使用 `0,1,2,3,4`。 | | `--multi-teammate-power` / `--multi-teammate-score-up` / `--multi-live-score-up-lower-bound` | 值 | 协力和 Cheerful 队友综合力、技能实效、技能总下限。 | +| `--multi-unit-bonus-evaluation` | 枚举 | 混编区域道具:`by_deck`(默认)/`force_on`/`force_off`;组卡与区域道具升级共用,规则见 [参数文档](docs/parameters.md#area-items-and-mysekai-gates)。 | | `--other-score` / `--life` | 值 | Cheerful 对手分数和体力。 | | `--rarity4-config` / `--single-card-config` | 值 | 养成配置,如 `level_max,skill_max,master_max,episode_read,canvas` 和 `123:level_max,skill_max`。 | diff --git a/docs/game-rule-sources.md b/docs/game-rule-sources.md new file mode 100644 index 0000000..b0b3744 --- /dev/null +++ b/docs/game-rule-sources.md @@ -0,0 +1,46 @@ +# Business-rule sources + +## C++ reference + +The JP7 business rules were ported from +**[Team-Haruki/sekai-deck-recommend-cpp](https://github.com/Team-Haruki/sekai-deck-recommend-cpp)**, +using commit +[`496caed78a07ffe1f2d1f1553047dbf6b2ed0314`](https://github.com/Team-Haruki/sekai-deck-recommend-cpp/commit/496caed78a07ffe1f2d1f1553047dbf6b2ed0314) +as the reference. The rules are implemented in allium-deck's Rust data model and +exact-search architecture. + +| Business rule | Reference implementation | +| --- | --- | +| Three-dimensional area effects, ALL_MATCH and multi-unit selection | [card-power-calculator.cpp](https://github.com/Team-Haruki/sekai-deck-recommend-cpp/blob/496caed78a07ffe1f2d1f1553047dbf6b2ed0314/src/card-information/card-power-calculator.cpp), [area-item-service.cpp](https://github.com/Team-Haruki/sekai-deck-recommend-cpp/blob/496caed78a07ffe1f2d1f1553047dbf6b2ed0314/src/area-item-information/area-item-service.cpp) | +| Multi-unit activation and original-unit shuffle counting | [deck-calculator.cpp](https://github.com/Team-Haruki/sekai-deck-recommend-cpp/blob/496caed78a07ffe1f2d1f1553047dbf6b2ed0314/src/deck-information/deck-calculator.cpp) | +| MySEKAI gate selection and missing gate-level rows | [card-power-calculator.cpp](https://github.com/Team-Haruki/sekai-deck-recommend-cpp/blob/496caed78a07ffe1f2d1f1553047dbf6b2ed0314/src/card-information/card-power-calculator.cpp), [PR #9](https://github.com/Team-Haruki/sekai-deck-recommend-cpp/pull/9) | +| Finale identification from master data, event limits and default shuffle rates | [master-data.cpp](https://github.com/Team-Haruki/sekai-deck-recommend-cpp/blob/496caed78a07ffe1f2d1f1553047dbf6b2ed0314/src/data-provider/master-data.cpp) | +| Area-item upgrade evaluation | [area-item-recommend.cpp](https://github.com/Team-Haruki/sekai-deck-recommend-cpp/blob/496caed78a07ffe1f2d1f1553047dbf6b2ed0314/src/area-item-recommend/area-item-recommend.cpp) | + +Credit also belongs to the earlier +[StarMoe-org/sekai-deck-recommend-cpp](https://github.com/StarMoe-org/sekai-deck-recommend-cpp) +and [NeuraXmy/sekai-deck-recommend-cpp](https://github.com/NeuraXmy/sekai-deck-recommend-cpp) +projects acknowledged by the Haruki fork. The C++ repository publishes its source +under [LGPL-2.1](https://github.com/Team-Haruki/sekai-deck-recommend-cpp/blob/496caed78a07ffe1f2d1f1553047dbf6b2ed0314/LICENSE). + +This source map identifies the provenance of the business rules. It does not +claim that the two engines have identical searches or outputs. The Rust engine +retains its independent fixes for other-unit skill counting, static reference +skill values, fractional reference effects, and order-independent averaging. +Its completion and canonical Top-K contracts are documented in +[parameters.md](parameters.md) and [search-validation.md](search-validation.md). + +The reference uses fixed 10/30/50 percent shuffle rates for third-round finales. +allium-deck uses those defaults when the selected event has no explicit shuffle +rows. Matching `eventShuffleUnitBonuses` rows replace the defaults as a whole; +an omitted unit count then contributes zero. A real finale requires its skill +cap row; only legacy and simulated finales use the legacy fallback cap. + +## Browser-consumer behavior + +[Moesekai's published WASM patch series](https://github.com/StarMoe-org/Moesekai/tree/d0e7cd4cd5cdea25756945806f001dfdfdb3111e/web/vendor/allium-deck-wasm/patches) +also provided concrete integration cases: real finale events, raw skill caps, +master-table shuffle bonuses, mandatory cards and characters, and explicit versus automatic +leader selection. Its [vendor documentation](https://github.com/StarMoe-org/Moesekai/blob/d0e7cd4cd5cdea25756945806f001dfdfdb3111e/web/vendor/allium-deck-wasm/README.md) +records those fixes. Consumer membership requirements are distinct from the +engine's default ordered-slot constraints. diff --git a/docs/parameters.md b/docs/parameters.md index 7e4f47e..c188c1e 100644 --- a/docs/parameters.md +++ b/docs/parameters.md @@ -13,6 +13,20 @@ Build parameters are the fourth argument of `engine::recommend_json` (a JSON obj | `member` | int | absent | Compatibility field; only 5 (or absent) is supported. | | `timeoutMs` / `timeout_ms` | int | 300000 | Public JSON search deadline in milliseconds; valid range `1..=300000`. On expiry the legal incumbents found so far are returned (anytime behavior); exactness is certified only when completion is `Complete`. The lower-level `SearchParams` API additionally reserves `0` for an unlimited internal search. | | `minimize` | bool | false | Weakest-deck search. Only meaningful with `target=power`; ignored otherwise. | +| `multiUnitBonusEvaluation` / `multi_unit_bonus_evaluation` | string | `"by_deck"` | `by_deck`, `force_on`, `force_off`. Applies to owned multi-unit area effects; `force_off` retains other effects of the same item. The typed field is `BuildParams::multi_unit_bonus_mode`. | + +## Area items and MySekai gates + +Area-item rates preserve all three power dimensions and the original master-row order. All three ALL_MATCH rates must be present; otherwise that row uses its normal rates. A target character of zero is unrestricted. Owned levels above an item's master maximum are clamped to that maximum; missing rows below the maximum are not substituted. + +For multi-unit activation, first collect non-Virtual-Singer original units. Then visit Virtual Singers in deck order: add a new support unit, or mark `piapro` as needed when support is absent or already present. Add `piapro` once at the end. More than one resulting unit activates `by_deck`. This is separate from skill unit counting and finale shuffle. A same-unit human team plus a Virtual Singer supporting that unit can satisfy both multi-unit activation and unit ALL_MATCH. + +Multi-unit area rows use character, multi-unit, original-unit, support-unit, attribute, and unrestricted buckets, in that priority. Original and support buckets compete by their f32 sum of three rates, with ties retaining the original bucket. Its all-match increment then competes with the multi bucket, with ties retaining the increment. If multi wins, the retained unit bucket uses normal rates. Retained rows accumulate in f64 in original order; each power dimension is floored separately. Without owned multi rows, every evaluation mode uses the legacy table without allocating a multi sidecar. + +Gate selection prefers the card's support unit, otherwise its original unit. A bare Virtual Singer selects the highest owned gate level, preserving user order on ties. Selection happens before looking up the level rate: a selected gate without a master level row contributes zero rather than falling back to another gate. Gate IDs do not imply a linear level-to-rate formula. + +Area-upgrade recommendations use the same evaluation mode before and after the upgrade and replace every effect row of the item together. The CLI flag is `--multi-unit-bonus-evaluation`; HTTP and WASM auxiliary requests accept the JSON spellings above. Typed callers use `AuxiliaryData::recommend_area_items_with_evaluation`; `recommend_area_items` uses `by_deck`. Levels 16–20 and item 56 use their master shop numbering. + ## Event context @@ -41,11 +55,20 @@ Build parameters are the fourth argument of `engine::recommend_json` (a JSON obj Final chapter leader honor: a deck equips one main honor, so the leader-only honor bonus is a single event-honor row matched by event, honor and leader character; owned honors do not stack. For each leader character the builder assumes the owned honor with the largest leader bonus, breaking ties by the smallest honor ID. Results report it as `main_honor_id` (`mainHonorId` in the HTTP service); the key is omitted outside the final chapter or when the leader character has no matching owned honor. The honor power bonus added to total power is computed separately and still covers every owned honor. +Finales are identified by `worldBlooms.json` rows with `worldBloomChapterType: "finale"`, as well as the supported legacy and simulated finale IDs. Explicit master rows take priority over fallback limits. Finale fallbacks count four limited cards for WL2 and five for WL3; fixture caps are respectively 20 and 60 in 0.1% units. Skill caps are the raw master value, without subtracting 100. WL3 chapters and finale retain the 336,000 total-power cap. + +`eventShuffleUnitBonuses.json` is optional. Its rows contain `eventId`, `unitCount`, and integer `bonusRate` in percentage points. A WL3 finale with no matching rows defaults to 10/30/50% for 3/4/5 distinct **original** units; other events default to zero. Any matching rows replace the whole fallback table, with zero for counts without a row. This applies to both real and simulated finales. Every Virtual Singer contributes `piapro`, independently of support units. Search bounds use the largest supplied rate, including nonmonotone tables. Results expose this component as `shuffle_bonus_rate` (`shuffleBonusRate` over HTTP). The typed masterdata view exposes `event_shuffle_unit_bonuses`, and `SearchContext.shuffle_unit_bonus` carries its six count-indexed rates. + +A real finale must supply its `eventSkillScoreUpLimits` row; a missing row produces a build error. The legacy finale and simulated finales retain their 140% fallback, and an explicit row takes priority. Ordinary events may omit the skill-cap table. + +Simulated finale support rows copy positive rates for supported characters from the source events and deduplicate `(character, card)` among those eligible rows in master order; absent source rows are not synthesized from old event-card bonuses. A real finale with a zero `leaderBonusRate` keeps that zero; the 20% missing-leader-bonus fallback applies only to the supported legacy and simulated finales. + ## Deck constraints | Key | Type | Default | Notes | | --- | --- | --- | --- | | `fixedCards` | int[] | `[]` | Card IDs locked into the deck. Combined with `fixedCharacters`, at most 5 slots; slot 0 carries leader semantics. | +| `fixedConstraintMode` / `fixed_constraint_mode` | string | `"slots"` | `slots` preserves the ordered-slot contract. `members` is an opt-in for finale recommendations: fixed cards and characters must participate, and `forcedLeaderCharacterId` independently selects the leader. With no forced leader, every legal leader is considered. Membership mode with mandatory members and exact `targetBonusList` tiers is not supported. | | `fixedCharacters` | int[] | `[]` | Character IDs locked into slots after the fixed cards. | | `excludedCards` | int[] | `[]` | Card IDs removed from the candidate pool. | | `challengeLiveCharacterId` / `challenge_live_character_id` | int | absent | Character for `challenge` / `challenge_auto` (character uniqueness is disabled there). | @@ -125,7 +148,7 @@ The end-to-end exactness argument and counterexample regressions are recorded in Exactness is conditional on `SearchCompletion::Complete`. On timeout, every returned incumbent is still legal and exactly evaluated, but canonical Top-K completeness is unproven; `SearchStats::deadline_hit` is set and completion is `TimedOut`. Pools above 512 retain their full candidate set in SoA columns; the fixed metadata-mask accessors explicitly return `None`. A pool larger than 65,535 cards returns `TooManyCards` rather than silently producing an approximate deck. -Compact representation limits are checked before pool packing. The current model uses 16-bit dense indexes and public card IDs, 18-bit per-card power profiles, 8-bit skill values, 12-bit main-card bonus totals in tenths, 15 distinct nonzero limited-bonus values, and 255 distinct entries in each special-skill table. A real event skill cap is applied before the width check. Identical special-skill content is interned, so duplicate entries do not consume distinct capacity. Unrepresentable values return a typed `BuildError::CapacityExceeded`; they are not saturated, truncated, silently removed, or allowed to panic in the arena builder. +Compact representation limits are checked before pool packing. The current model uses 16-bit dense indexes and public card IDs, 18-bit legacy per-card power profiles plus an optional pool-wide eight-state u32 multi-unit sidecar, 8-bit skill values, 12-bit main-card bonus totals in tenths, 15 distinct nonzero limited-bonus values, and 255 distinct entries in each special-skill table. A real event skill cap is applied before the width check. Identical special-skill content is interned, so duplicate entries do not consume distinct capacity. Unrepresentable values return a typed `BuildError::CapacityExceeded`; they are not saturated, truncated, silently removed, or allowed to panic in the arena builder. Result ordering uses one total order for every solver: objective descending (only minimizing `power` reverses this field), then actual resolved and capped total power descending for `mysekai`, then the ascending sorted public card-ID set, then the ascending concrete legal input-slot card IDs, and finally the prepared-pool variant ordinals. The last field selects a deterministic cultivation representative within the same immutable pool; rebuilding a different pool does not promise the same dense ordinals. Distinct means a public card-ID set, not a slot permutation or cultivation variant. A fixed or forced leader remains a role constraint; the display order materialized by the evaluator is not fed back as a new Specific input order. Exact bonus requests deduplicate separately in each tier. diff --git a/docs/pruning-proof.md b/docs/pruning-proof.md index dfd0f48..428bd50 100644 --- a/docs/pruning-proof.md +++ b/docs/pruning-proof.md @@ -740,18 +740,35 @@ Implementation: src/search/composition.rs (`search_regimes`, `RegimePlan`) and `CardPool::restrict`. For a deck $D$ let $A(D)$ be the attribute shared by all five cards, if any, -and $U(D)$ the set of units contained in all five cards. The evaluator resolves -card $c$ of $D$ to +and $U(D)$ the set of original-or-support units contained in all five cards. The legacy +power of card $c$ is $$ -p_D(c)=\max_{w\in\mathrm{units}(c)} v_c\bigl(\pi_c(w),\,[w\in U(D)],\,[A(D)=\mathrm{attr}(c)]\bigr), +L_D(c)=\max_{w\in\mathrm{units}(c)} v_c\bigl(\pi_c(w),\,[w\in U(D)],\,[A(D)=\mathrm{attr}(c)]\bigr), $$ where $v_c(\pi,u,a)$ is the stored power of profile $\pi$ under member key $2u+a$. Since $A(D)=\mathrm{attr}(c)$ holds for every card exactly when $A(D)$ exists, the key of each unit depends only on $(w\in U(D),\ A(D)\text{ exists})$. -**Regimes.** The feasible decks are covered by 49 regimes: +The optional multi table stores $M_c[o,s,a]$ for the three independent +ALL_MATCH bits: the card's original unit in $U(D)$, its support unit in +$U(D)$, and shared attribute. `power::DeckComposition` computes $U(D)$ and +multi activation separately; shared membership is not evidence against multi +activation. `power::effective_power` returns $M$ exactly when the owned table +exists and the requested mode activates it; otherwise it returns $L$. The +legacy two-profile LUT and its 18-bit values are unchanged. The eight-state +u32 sidecar is copied in lockstep by gather, compaction, and restriction. + +The multi values are prepared by f32 bucket comparisons, followed by ordered +f64 per-row dimension sums and individual floors. Pruning uses the resulting +integers, not a rearranged rate formula. No monotonicity between stored states +is required. Dominance compares every enabled multi state as well as every +legacy state, under identical memberships; it therefore preserves the other +members' activation and ALL_MATCH states. Numeric admission bounds the maxima +including the sidecar. Minimization takes the minimum over every enabled state. + +**Regimes.** The legacy membership/attribute cover has 49 regimes: | regime | condition on $D$ | admitted cards | member keys $K_R$ | | --- | --- | --- | --- | @@ -772,7 +789,16 @@ $$ p_D(c)\le b_R(c)=\max_{w\in\mathrm{units}(c)}\ \max_{k\in K_R} v_c(\pi_c(w),k). $$ -`RegimePlan::new` computes $b_R$ and `CardPool::restrict` builds the pool of +With a multi table, `search_regimes` pairs this cover with legacy/multi +activation, omitting the inactive side for force modes. For multi power the +allowed state set contains exactly the attribute bit of $K_R$, with both unit +bits false when $U(D)$ must be empty and either bit allowed otherwise. Every +actual state belongs to that set, so its maximum is an admissible $b_R$. +`power_over_keys` uses the union when a caller does not split activation. +All valid decks belong to at least one paired regime, including a deck that +has both shared membership and active multi furniture. + +`RegimePlan::new` and `new_for_activation` compute $b_R$; `CardPool::restrict` builds the pool of admitted cards with $b_R$ as their `power_max`; every other column, including the eight exact power contexts, is copied unchanged. Every bound of Sections 4–12 and 14–18 uses `power_max` only as a per-card upper bound on resolved @@ -791,7 +817,7 @@ apply verbatim to the feasible decks of $R$, with $b_R$ in place of $b_R$, skill maximum and card-bonus ceiling among admitted cards, and adds an admissible extra-bonus term: for World Bloom the best diversity bonus over the attribute counts the admitted cards can reach (one when the regime fixes the -attribute) plus, over every support profile, the rounded-up sum of its first +attribute), the WL3 finale shuffle ceiling, plus, over every support profile, the rounded-up sum of its first `count` entries, which is the largest sum any exclusion can leave; otherwise the context's extra-bonus bound; and for Final Chapter the largest leader bonus. A deck uses five distinct characters, so each sum of five per-character @@ -956,6 +982,22 @@ layer's and the bound is at most the layer's. The exclusion-aware ceiling with it is therefore at most the one with the layer's bound, and only the former is tested. +### 16.1 Original-unit shuffle + +Only a third World Bloom finale adds original-unit diversity: 0/10/30/50 +for fewer than three / three / four / five original units. All Virtual Singers +contribute piapro regardless of support. This predicate is independent of +both skill counting and multi furniture. Its maximum 50 is added to every +World Bloom extra-bonus bound, including the composition plan, dense suffix, +Final Chapter attribute DP, selected-card bound, and log-linear feature box. +The final evaluator and summary use the actual original-unit count. + +Exact bonus tiers enumerate each attribute-bonus class together with all +four shuffle values. The support-refill certificate uses that same set of +extra ticks. It may admit infeasible combinations of attribute and original +unit counts, but never excludes a real one; exact leaves filter the requested +tier. Hence the existing reachability and Top-K arguments remain valid. + ## 17. Exact bonus tiers Implementation: src/search/solver/bonus_tiers.rs. @@ -1804,8 +1846,16 @@ $$ This bound even permits reuse of the same global maximum card and ignores character/fixed-slot restrictions, so it is a relaxation. -For minimizing Power, let power_min(c) be the minimum of card $c$'s eight -precomputed power contexts and let +For minimizing Power, define the legacy lower bound of card $c$ as zero when +its six-bit unit membership is empty, otherwise as the minimum of its eight +decoded legacy values. The empty membership case resolves to zero without +selecting a table entry. For nonempty membership, each selected profile value +is at least the table minimum, so their maximum is also at least this bound. + +Let power_min(c) be that legacy bound when the sidecar is absent or the mode is +`force_off`; otherwise take its minimum with all eight sidecar values. This +bounds both effective-power branches. Keeping the legacy bound in `force_on` +can only weaken the lower bound, not exclude a feasible completion. Let $$ m=\min_c power\_min(c). @@ -2010,14 +2060,15 @@ Implementation: src/search/solver/power.rs. For a deck $D$ let $U(D)$ be the set of unit bits that every member's unit mask contains (over the six bits the evaluator reads) and $A(D)$ the -attribute all five members share, if any. The evaluator resolves member $c$ to +attribute all five members share, if any. The legacy branch resolves member $c$ to $$ p(c,D)=\max_{w\in units(c)} V_c\bigl[prof_c(w)\bigr]\bigl[2\,[w\in U(D)]+[A(D)\text{ exists}]\bigr], $$ where $V_c[\cdot][k]$ are the card's stored powers by profile and member key, -and $v(D)=\operatorname{clamp}(\sum_{c\in D}p(c,D)+H)$ with honor power $H$. +and the active multi branch is defined in Section 13. In both cases +$v(D)=\operatorname{clamp}(\sum_{c\in D}p(c,D)+H)$ with honor power $H$. A scenario $S=(U,a)$ pairs a unit set $U$ with $a$, either "no shared attribute" or one attribute. It admits the cards whose mask contains $U$ and, @@ -2033,32 +2084,22 @@ fewer than five characters holds no deck and is dropped. ### Lemma 3 — scenario power ceiling -For a card $c$ admitted by $S=(U,a)$ let -$single(c;u,a)$ = `resolve_card_power_scenario` with all-member unit $u$ (or -none) and the attribute flag of $a$, and - -$$ -g_S(c)=\begin{cases} -single(c;\varnothing,a) & U=\varnothing,\ -\max_{u\in U} single(c;u,a) & \text{otherwise.} -\end{cases} -$$ - -Then $p(c,D)\le g_S(c)$ for every deck $D$ with $S(D)=S$ and every member -$c$, with equality when $|U|\le1$. - -**Proof.** $single(c;u,a)$ is the maximum over $w\in units(c)$ of -$V_c[prof_c(w)][2[w=u]+[a]]$. Take any term of the maximum defining -$p(c,D)$. If $w\in U$, it is the $w$-term of $single(c;w,a)$. If $w\notin U$, -it is the $w$-term of $single(c;u,a)$ for every $u\in U$, or of -$single(c;\varnothing,a)$ when $U=\varnothing$. Every term is therefore -bounded by $g_S(c)$. When $|U|\le1$ the single call uses exactly the member -keys of $D$. ∎ - -No order between member keys or between profiles is assumed. Two all-member -units occur when every member carries both, for example five Virtual Singer -cards with one support unit. Since clamp is non-decreasing, -$v(D)\le\operatorname{clamp}(\sum_{c\in D}g_{S(D)}(c)+H)$. +For a card $c$ admitted by $S=(U,a)$, `scenario_power` evaluates +`effective_power` with the exact common-unit set $U$ and attribute bit $a$ +for both possible multi-activation values and takes their maximum $g_S(c)$. +Force modes and the absence of a sidecar already collapse the irrelevant +activation through the selector. Thus the actual state of every deck $D$ +with $S(D)=S$ is among those considered, and $p(c,D)\le g_S(c)$. + +No order between member keys, profiles or activation states is assumed. +Both original and support ALL_MATCH can hold at once. Since clamp is +non-decreasing, $v(D)\le\operatorname{clamp}(\sum_{c\in D}g_{S(D)}(c)+H)$. + +The no-event suffix bound has a different input: its `allowed` mask is only +a superset of the eventual common units. `card_scenario_power` therefore +includes normal and all-match legacy keys and every multi state whose true +unit bits are still possible. Taking a maximum over that superset avoids any +assumption that all-match or multi rates increase power. ### Lemma 4 — best completion in a scenario diff --git a/docs/releases/v0.1.0.md b/docs/releases/v0.1.0.md new file mode 100644 index 0000000..ba0ee84 --- /dev/null +++ b/docs/releases/v0.1.0.md @@ -0,0 +1,24 @@ +## Business-rule provenance + +The JP7 business rules in this release were ported from +**[Team-Haruki/sekai-deck-recommend-cpp](https://github.com/Team-Haruki/sekai-deck-recommend-cpp)** +at commit [`496caed78a07ffe1f2d1f1553047dbf6b2ed0314`](https://github.com/Team-Haruki/sekai-deck-recommend-cpp/commit/496caed78a07ffe1f2d1f1553047dbf6b2ed0314). +This includes multi-unit area-item effects, MySEKAI gates, finale identification, +and World Link limits and bonuses. The business rules are implemented in the +Rust engine's own data model and exact-search routines. + +Master-table shuffle overrides and browser integration cases also draw on +[Moesekai's public patch series](https://github.com/StarMoe-org/Moesekai/tree/d0e7cd4cd5cdea25756945806f001dfdfdb3111e/web/vendor/allium-deck-wasm/patches). +See the [source map](https://github.com/empty-sekai/allium-deck/blob/v0.1.0/docs/game-rule-sources.md) +for file-level references, upstream attribution, and deliberate differences. + +## Compatibility + +This release includes the public API changes since 0.0.15. Search outcomes carry +an explicit completion status; a timed-out result does not establish complete +Top-K. Typed integrations must update their data structures, and browser +consumers must preserve the returned completion status. + +The default fixed-card and fixed-character contract remains ordered slots. +Explicit finale membership constraints let consumers require participation +without making the first fixed card the leader. diff --git a/scripts/create_release_checksums.py b/scripts/create_release_checksums.py new file mode 100644 index 0000000..9a54646 --- /dev/null +++ b/scripts/create_release_checksums.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Validate the complete release asset set and write portable SHA256SUMS.""" + +from __future__ import annotations + +import argparse +import hashlib +import re +from pathlib import Path + + +def expected_assets(version: str) -> set[str]: + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?", version): + raise ValueError("invalid release version") + return { + *(f"recommend_cli-v{version}-{platform}" for platform in ( + "linux-x86_64", + "linux-x86_64-musl", + "linux-aarch64", + "windows-x86_64.exe", + "macos-aarch64", + )), + f"allium-deck-wasm-v{version}-cn.zip", + f"allium-deck-wasm-v{version}.tgz", + } + + +def create_checksums(directory: Path, version: str) -> Path: + expected = expected_assets(version) + actual = {path.name for path in directory.iterdir() if path.name != "SHA256SUMS"} + if actual != expected: + raise ValueError( + f"incomplete release asset set: missing={sorted(expected - actual)}, " + f"unexpected={sorted(actual - expected)}" + ) + rows = [] + for name in sorted(expected): + path = directory / name + if path.is_symlink() or not path.is_file() or path.stat().st_size == 0: + raise ValueError(f"release asset must be a nonempty regular file: {name}") + with path.open("rb") as handle: + digest = hashlib.file_digest(handle, "sha256").hexdigest() + rows.append(f"{digest} {name}\n") + output = directory / "SHA256SUMS" + output.write_text("".join(rows), encoding="utf-8", newline="\n") + return output + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--directory", type=Path, required=True) + parser.add_argument("--version", required=True) + args = parser.parse_args() + print(create_checksums(args.directory, args.version)) + + +if __name__ == "__main__": + main() diff --git a/scripts/package_wasm.py b/scripts/package_wasm.py index d24f170..88f210c 100644 --- a/scripts/package_wasm.py +++ b/scripts/package_wasm.py @@ -45,6 +45,27 @@ def copy_if_exists(src: Path, dst: Path) -> None: shutil.copy2(src, dst) +def prepare_npm_metadata(pkg_dir: Path, version: str, repository: str) -> None: + """Finalize metadata before either browser or npm artifacts are packaged.""" + path = pkg_dir / "package.json" + package = json.loads(path.read_text(encoding="utf-8")) + if package.get("name") != "@empty-sekai/allium-deck-wasm": + raise ValueError("unexpected WASM npm package name") + if package.get("version") != version: + raise ValueError("WASM npm package version does not match the release") + if repository: + package["repository"] = { + "type": "git", + "url": f"git+https://github.com/{repository}.git", + } + package["publishConfig"] = {**package.get("publishConfig", {}), "access": "public"} + path.write_text( + json.dumps(package, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + newline="\n", + ) + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--pkg-dir", default="pkg") @@ -56,6 +77,7 @@ def main() -> int: pkg_dir = Path(args.pkg_dir) out_dir = Path(args.out_dir) + prepare_npm_metadata(pkg_dir, args.version, args.source_repository) if out_dir.exists(): shutil.rmtree(out_dir) out_dir.mkdir(parents=True) diff --git a/scripts/verify_npm_package.mjs b/scripts/verify_npm_package.mjs new file mode 100644 index 0000000..36fdee7 --- /dev/null +++ b/scripts/verify_npm_package.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +/** Verify that the public npm registry contains the exact tested tarball. */ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; + +export const NOT_FOUND = 3; + +class RegistryLookupError extends Error { + constructor(message, retryable = false) { + super(message); + this.retryable = retryable; + } +} + +async function queryVersion(packageName, version, fetchImpl, timeoutMs) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const url = `https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}`; + try { + let response; + try { + response = await fetchImpl(url, { + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + } catch (error) { + throw new RegistryLookupError(`npm registry request failed: ${error.message}`, true); + } + if (response.status === 404) return null; + if (!response.ok) { + throw new RegistryLookupError( + `npm registry lookup returned HTTP ${response.status}`, + response.status === 429 || response.status >= 500, + ); + } + try { + const metadata = await response.json(); + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + throw new Error("expected a package-version object"); + } + return metadata; + } catch (error) { + throw new RegistryLookupError(`npm registry returned invalid JSON: ${error.message}`); + } + } finally { + clearTimeout(timeout); + } +} + +/** Return 0 on an exact match, 3 only on confirmed absence; all other failures throw. */ +export async function verifyPackage({ + packageName, + version, + tarball, + attempts = 1, + delayMs = 0, + timeoutMs = 5_000, + fetchImpl = globalThis.fetch, + pause = (milliseconds) => new Promise((done) => setTimeout(done, milliseconds)), + log = console.log, +}) { + if (!packageName || !version || !tarball) throw new Error("package, version and tarball are required"); + if (!Number.isInteger(attempts) || attempts < 1) throw new Error("attempts must be a positive integer"); + if (!Number.isFinite(delayMs) || delayMs < 0) throw new Error("delay-ms must be nonnegative"); + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("timeout-ms must be positive"); + const expected = `sha512-${createHash("sha512").update(readFileSync(tarball)).digest("base64")}`; + for (let attempt = 1; attempt <= attempts; attempt++) { + let metadata; + try { + metadata = await queryVersion(packageName, version, fetchImpl, timeoutMs); + } catch (error) { + if (!error.retryable || attempt === attempts) throw error; + log(`${error.message}; retrying (${attempt}/${attempts})`); + await pause(delayMs); + continue; + } + if (metadata !== null) { + if (metadata.name !== packageName || metadata.version !== version) { + throw new Error("npm registry package identity does not match the requested version"); + } + if (typeof metadata.dist?.integrity !== "string" || metadata.dist.integrity.trim() !== expected) { + throw new Error(`npm ${packageName}@${version} tarball SHA-512 integrity mismatch`); + } + log(`npm ${packageName}@${version} matches the tested tarball`); + return 0; + } + if (attempt < attempts) { + log(`npm ${packageName}@${version} is not visible yet (${attempt}/${attempts}); retrying`); + await pause(delayMs); + } + } + log(`npm ${packageName}@${version} is not published`); + return NOT_FOUND; +} + +async function main() { + const { values } = parseArgs({ + options: { + package: { type: "string" }, + version: { type: "string" }, + tarball: { type: "string" }, + attempts: { type: "string", default: "1" }, + "delay-ms": { type: "string", default: "0" }, + "timeout-ms": { type: "string", default: "5000" }, + }, + }); + return verifyPackage({ + packageName: values.package, + version: values.version, + tarball: values.tarball, + attempts: Number(values.attempts), + delayMs: Number(values["delay-ms"]), + timeoutMs: Number(values["timeout-ms"]), + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().then((status) => { process.exitCode = status; }).catch((error) => { + console.error(`error: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/server/Cargo.lock b/server/Cargo.lock index 7ba922d..12921d9 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "allium-deck" -version = "0.0.15" +version = "0.1.0" dependencies = [ "serde", "serde_json", @@ -23,7 +23,7 @@ dependencies = [ [[package]] name = "allium-deck-server" -version = "0.0.15" +version = "0.1.0" dependencies = [ "allium-deck", "axum", diff --git a/server/Cargo.toml b/server/Cargo.toml index 6d061fd..5bbf4cd 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "allium-deck-server" -version = "0.0.15" +version = "0.1.0" edition = "2024" description = "HTTP service for the allium-deck recommendation engine" license = "MIT OR Apache-2.0" @@ -19,7 +19,7 @@ name = "allium-deck-server" path = "src/main.rs" [dependencies] -allium-deck = { path = "..", version = "=0.0.15" } +allium-deck = { path = "..", version = "=0.1.0" } axum = { version = "0.8.9", default-features = false, features = ["http1", "json", "tokio"] } tokio = { version = "1.53", features = ["rt-multi-thread", "net", "signal", "time", "sync", "macros"] } tower-http = { version = "0.7.1", features = ["catch-panic", "trace"] } diff --git a/server/openapi.json b/server/openapi.json index 8d7f3dc..43cd357 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "allium-deck", - "version": "0.0.15", + "version": "0.1.0", "summary": "Deck recommendation for Project Sekai: exact DFS with branch and bound.", "description": "Build parameters are the engine's own contract; every key documented in docs/parameters.md is accepted here, in camelCase or snake_case. The service adds only the request envelope and its own ceilings on `limit` and `timeoutMs`.", "license": { "name": "MIT OR Apache-2.0" } @@ -155,7 +155,8 @@ "properties": { "region": { "$ref": "#/components/schemas/Region" }, "user": { "$ref": "#/components/schemas/User" }, - "cardIds": { "type": "array", "items": { "type": "integer" } } + "cardIds": { "type": "array", "items": { "type": "integer" } }, + "multiUnitBonusEvaluation": { "$ref": "#/components/schemas/MultiUnitBonusEvaluation" } }, "required": ["user", "cardIds"] } @@ -273,9 +274,24 @@ "description": "The player's card collection, either as an object or as its JSON text.", "oneOf": [{ "type": "object" }, { "type": "string" }] }, + "MultiUnitBonusEvaluation": { + "type": "string", + "enum": ["by_deck", "force_on", "force_off"], + "default": "by_deck", + "description": "Activation policy for owned multi-unit area effects. force_off retains the item's other effects. The snake_case request alias is also accepted." + }, "Params": { "type": "object", "description": "Build parameters. Every key in docs/parameters.md is accepted, in camelCase or snake_case; unknown keys are ignored. `limit` and `timeoutMs` are lowered to this service's ceilings, reported by GET /v1/regions.", + "properties": { + "multiUnitBonusEvaluation": { "$ref": "#/components/schemas/MultiUnitBonusEvaluation" }, + "fixedConstraintMode": { + "type": "string", + "enum": ["slots", "members"], + "default": "slots", + "description": "Ordered input slots by default. In finale recommendations, members requires participation without fixing the leader; forcedLeaderCharacterId remains independent. Mandatory members with exact targetBonusList tiers are not supported. The snake_case alias is fixed_constraint_mode." + } + }, "additionalProperties": true }, "RecommendRequest": { @@ -396,6 +412,11 @@ "eventPoint": { "type": ["integer", "null"] }, "multiLiveScoreUp": { "type": ["number", "null"] }, "eventBonusTotal": { "type": ["number", "null"] }, + "shuffleBonusRate": { + "type": ["integer", "null"], + "enum": [0, 10, 30, 50, null], + "description": "WL3 finale original-unit diversity bonus, already included in eventBonusTotal. Zero in other events; null when no summary is available." + }, "mainHonorId": { "type": "integer", "description": "World Bloom final chapter only: the owned honor assumed as the deck's main honor, chosen for the leader character (largest leader bonus, then smallest ID). Its bonus is included in eventBonusTotal. Omitted otherwise." diff --git a/server/src/api/auxiliary.rs b/server/src/api/auxiliary.rs index 0f6cd6e..4c011e5 100644 --- a/server/src/api/auxiliary.rs +++ b/server/src/api/auxiliary.rs @@ -281,6 +281,8 @@ pub struct AreaItemsRequest { pub user: UserInput, #[serde(alias = "card_ids")] pub card_ids: Vec, + #[serde(default, alias = "multi_unit_bonus_evaluation")] + pub multi_unit_bonus_evaluation: allium_deck::power::MultiUnitBonusMode, } /// `POST /v1/area-items/recommend` @@ -307,7 +309,12 @@ async fn run_area_items( let game = snapshot.game(); snapshot .auxiliary() - .recommend_area_items(&user, &game, &card_ids) + .recommend_area_items_with_evaluation( + &user, + &game, + &card_ids, + request.multi_unit_bonus_evaluation, + ) .map_err(ApiError::BadRequest) }) .await?; @@ -387,3 +394,40 @@ fn record( } } } + +#[cfg(test)] +mod tests { + use super::AreaItemsRequest; + use allium_deck::power::MultiUnitBonusMode; + use serde_json::json; + + #[test] + fn area_item_evaluation_defaults_to_by_deck_and_accepts_both_spellings() { + let body = json!({"user": {}, "cardIds": [1, 2, 3, 4, 5]}); + let default: AreaItemsRequest = + serde_json::from_value(body.clone()).expect("default area item request parses"); + assert_eq!( + default.multi_unit_bonus_evaluation, + MultiUnitBonusMode::ByDeck + ); + + for key in ["multiUnitBonusEvaluation", "multi_unit_bonus_evaluation"] { + for (value, expected) in [ + ("by_deck", MultiUnitBonusMode::ByDeck), + ("force_on", MultiUnitBonusMode::ForceOn), + ("force_off", MultiUnitBonusMode::ForceOff), + ] { + let mut request = body.clone(); + request[key] = json!(value); + let parsed: AreaItemsRequest = + serde_json::from_value(request).expect("evaluation mode parses"); + assert_eq!(parsed.multi_unit_bonus_evaluation, expected); + } + for value in [json!("invalid"), json!(true), json!(1), json!(null)] { + let mut request = body.clone(); + request[key] = value; + assert!(serde_json::from_value::(request).is_err()); + } + } + } +} diff --git a/server/src/api/recommend.rs b/server/src/api/recommend.rs index 56a8af6..9a5dc0e 100644 --- a/server/src/api/recommend.rs +++ b/server/src/api/recommend.rs @@ -57,6 +57,7 @@ pub struct DeckOut { pub event_point: Option, pub multi_live_score_up: Option, pub event_bonus_total: Option, + pub shuffle_bonus_rate: Option, /// Final Chapter only: the owned honor assumed as the deck's main honor. #[serde(skip_serializing_if = "Option::is_none")] pub main_honor_id: Option, @@ -391,6 +392,7 @@ fn deck_out(rank: usize, pool: &CardPool, ctx: &SearchContext, result: &DeckResu event_point: summary.and_then(|summary| summary.event_point), multi_live_score_up: summary.map(|summary| summary.multi_live_score_up), event_bonus_total: summary.and_then(|summary| summary.event_bonus_total), + shuffle_bonus_rate: summary.map(|summary| summary.shuffle_bonus_rate), main_honor_id: summary.and_then(|summary| summary.main_honor_id), } } @@ -507,10 +509,22 @@ mod tests { event_point: None, multi_live_score_up: None, event_bonus_total: None, + shuffle_bonus_rate: None, main_honor_id, } } + #[test] + fn shuffle_bonus_is_serialized_in_percentage_units() { + for rate in [None, Some(0), Some(10), Some(30), Some(50)] { + let mut output = deck(None); + output.shuffle_bonus_rate = rate; + let value = serde_json::to_value(output).expect("deck serializes"); + assert_eq!(value["shuffleBonusRate"], serde_json::json!(rate)); + assert!(value.get("shuffle_bonus_rate").is_none()); + } + } + #[test] fn main_honor_id_is_serialized_only_when_present() { let without = serde_json::to_value(deck(None)).expect("deck serializes"); diff --git a/src/auxiliary.rs b/src/auxiliary.rs index 8207e01..d6201ab 100644 --- a/src/auxiliary.rs +++ b/src/auxiliary.rs @@ -40,6 +40,22 @@ impl AuxiliaryData { user: &UserProfile, game: &crate::handler::GameData<'_>, card_ids: &[i32], + ) -> Result, String> { + self.recommend_area_items_with_evaluation( + user, + game, + card_ids, + crate::power::MultiUnitBonusMode::ByDeck, + ) + } + + /// Recommend upgrades under the same multi-unit activation policy before and after each upgrade. + pub fn recommend_area_items_with_evaluation( + &self, + user: &UserProfile, + game: &crate::handler::GameData<'_>, + card_ids: &[i32], + evaluation: crate::power::MultiUnitBonusMode, ) -> Result, String> { if !(1..=DECK_SIZE).contains(&card_ids.len()) { return Err("card_ids must contain 1 to 5 cards".to_string()); @@ -55,7 +71,7 @@ impl AuxiliaryData { } let prepared = PreparedGameData::new(*game); - let current_power = fixed_deck_power(user, &prepared, card_ids)?; + let current_power = fixed_deck_power(user, &prepared, card_ids, evaluation)?; let current_levels = user .user_area_items .iter() @@ -125,7 +141,8 @@ impl AuxiliaryData { level: next_level, })) .collect(); - let power = fixed_deck_power(&upgraded, &prepared, card_ids)? - current_power; + let power = + fixed_deck_power(&upgraded, &prepared, card_ids, evaluation)? - current_power; if power <= 0 { continue; } @@ -135,10 +152,14 @@ impl AuxiliaryData { .iter() .find(|area| area.id == area_item.area_id) .ok_or_else(|| format!("area not found for area_id={}", area_item.area_id))?; - let shop_item_id = if next_level <= 10 { + let shop_item_id = if area_item.id == 56 { + 2100 + next_level + } else if next_level <= 10 { 1000 + (area_item.id - 1) * 10 + next_level + } else if next_level <= 15 { + 1550 + (area_item.id - 1) * 5 + (next_level - 10) } else { - 1540 + (area_item.id - 1) * 5 + next_level + 1825 + (area_item.id - 1) * 5 + (next_level - 15) }; let shop_item = self .shop_items @@ -409,9 +430,11 @@ fn fixed_deck_power( user: &UserProfile, prepared: &PreparedGameData<'_>, card_ids: &[i32], + evaluation: crate::power::MultiUnitBonusMode, ) -> Result { let mut params = BuildParams { target: ScoreTarget::Power, + multi_unit_bonus_mode: evaluation, ..BuildParams::default() }; params.fixed_cards = card_ids diff --git a/src/bin/recommend_cli.rs b/src/bin/recommend_cli.rs index 176e16b..7ce9e25 100644 --- a/src/bin/recommend_cli.rs +++ b/src/bin/recommend_cli.rs @@ -82,6 +82,7 @@ struct ParamOverrides { other_score: Option>, life: Option>, minimize: Option, + multi_unit_bonus_evaluation: Option, rarity_1_config: Option, rarity_2_config: Option, rarity_3_config: Option, @@ -147,7 +148,14 @@ fn run() -> Result<(), String> { match args.mode.as_deref() { Some("area-items") => { - return run_area_items(&args, &user, &game, masterdata, load_ms); + return run_area_items( + &args, + &user, + &game, + masterdata, + load_ms, + params.multi_unit_bonus_mode, + ); } Some("music") => { return run_music(&args, &user, &game, params, load_ms); @@ -537,6 +545,7 @@ fn run_area_items( game: &GameData<'_>, masterdata: &str, load_ms: f64, + evaluation: allium_deck::power::MultiUnitBonusMode, ) -> Result<(), String> { let auxiliary = load_auxiliary(masterdata)?; let card_ids = args @@ -545,7 +554,7 @@ fn run_area_items( .ok_or_else(|| "--mode area-items 需要 --card-ids(1..5 张卡,逗号分隔)".to_string())?; let start = Instant::now(); let result = auxiliary - .recommend_area_items(user, game, &card_ids) + .recommend_area_items_with_evaluation(user, game, &card_ids, evaluation) .map_err(|e| e.to_string())?; eprintln!("[area_items] {:.1}ms rows={}", ms(start), result.len()); eprintln!("[total] {:.1}ms", load_ms + ms(start)); @@ -786,6 +795,12 @@ fn parse_args() -> Result { parsed.overrides.other_score = Some(parse_optional_i32(&value()?, &flag)?) } "--life" => parsed.overrides.life = Some(parse_optional_i32(&value()?, &flag)?), + "--multi-unit-bonus-evaluation" => { + parsed.overrides.multi_unit_bonus_evaluation = Some( + serde_json::from_value(serde_json::Value::String(value()?)) + .map_err(|e| format!("{flag}: {e}"))?, + ); + } "--minimize" => parsed.overrides.minimize = Some(true), "--no-minimize" => parsed.overrides.minimize = Some(false), "--rarity1-config" => { @@ -910,6 +925,9 @@ fn apply_overrides(params: &mut BuildParams, overrides: ParamOverrides) { if let Some(value) = overrides.life { params.life = value; } + if let Some(value) = overrides.multi_unit_bonus_evaluation { + params.multi_unit_bonus_mode = value; + } if let Some(value) = overrides.minimize { params.minimize = value; } @@ -953,6 +971,7 @@ fn print_help() { --skill-reference-strategy average|max|min --live-skill-order best|worst|average|specific\n\ --specific-skill-order 0,1,2,3,4 --best-skill-as-leader --keep-after-training-state\n\ --multi-teammate-power N --multi-teammate-score-up N --multi-live-score-up-lower-bound N\n\ + --multi-unit-bonus-evaluation by_deck|force_on|force_off\n\ --other-score N --life N --minimize\n\ --rarity4-config level_max,skill_max,master_max,episode_read,canvas\n\ --single-card-config 123:level_max,skill_max,master_max" @@ -1342,6 +1361,7 @@ struct DeckOut { event_point: Option, multi_live_score_up: Option, event_bonus_total: Option, + shuffle_bonus_rate: Option, #[serde(skip_serializing_if = "Option::is_none")] main_honor_id: Option, } @@ -1408,6 +1428,7 @@ impl DeckOut { event_point: summary.and_then(|value| value.event_point), multi_live_score_up: summary.map(|value| value.multi_live_score_up), event_bonus_total: summary.and_then(|value| value.event_bonus_total), + shuffle_bonus_rate: summary.map(|value| value.shuffle_bonus_rate), main_honor_id: summary.and_then(|value| value.main_honor_id), } } diff --git a/src/engine.rs b/src/engine.rs index 9626665..41052fc 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -11,10 +11,10 @@ use std::path::Path; use crate::handler::{ BondsHonor, CardEpisode, CardMysekaiCanvasBonus, CardParameter, CardRarity, CharacterRank, Event, EventCard, EventCardBonusLimit, EventDeckBonus, EventFixtureBonusLimit, EventHonorBonus, - EventRarityBonusRate, EventSkillScoreUpLimit, GameCharacterUnit, GameData, Honor, HonorLevel, - MasterCard, MasterLesson, MusicDifficulty, MusicMeta, MysekaiGate, MysekaiGateLevel, Skill, - SkillEffect, UserAreaItem, UserCard, UserChallengeDeck, UserDeck, UserFixtureBonus, - UserGateBonus, UserHonor, UserProfile, UserWBSupportDeck, WBSupportDeckBonus, + EventRarityBonusRate, EventShuffleUnitBonus, EventSkillScoreUpLimit, GameCharacterUnit, + GameData, Honor, HonorLevel, MasterCard, MasterLesson, MusicDifficulty, MusicMeta, MysekaiGate, + MysekaiGateLevel, Skill, SkillEffect, UserAreaItem, UserCard, UserChallengeDeck, UserDeck, + UserFixtureBonus, UserGateBonus, UserHonor, UserProfile, UserWBSupportDeck, WBSupportDeckBonus, WBSupportDeckUnitEventLimitedBonus, WorldBloom, WorldBloomDiffAttrBonus, }; use crate::search::{SearchCompletion, SearchOutcome, SearchParams, SearchStats}; @@ -340,12 +340,23 @@ pub fn parse_build_params_json( crate::handler::types::MAX_TARGET_BONUS_BUCKETS, )?; params.minimize = bool_field(&value, "minimize").unwrap_or(false); + if let Some(mode) = field_alias_checked( + &value, + "multiUnitBonusEvaluation", + "multi_unit_bonus_evaluation", + )? { + params.multi_unit_bonus_mode = serde_json::from_value(mode.clone())?; + } params.music_id = i32_field_checked(&value, "musicId")?.or(i32_field_checked(&value, "music_id")?); params.music_diff = string_field(&value, "musicDiff").or_else(|| string_field(&value, "music_diff")); params.fixed_cards = int_array_alias(&value, "fixedCards", "fixed_cards"); params.fixed_characters = int_array_alias(&value, "fixedCharacters", "fixed_characters"); + if let Some(mode) = field_alias_checked(&value, "fixedConstraintMode", "fixed_constraint_mode")? + { + params.fixed_constraint_mode = serde_json::from_value(mode.clone())?; + } params.forced_leader_character_id = i32_field_checked(&value, "forcedLeaderCharacterId")? .or(i32_field_checked(&value, "forced_leader_character_id")?); params.excluded_cards = int_array_alias(&value, "excludedCards", "excluded_cards"); @@ -913,6 +924,9 @@ pub struct OwnedGameData { pub event_mysekai_fixture_performance_bonus_limits: Vec, /// 活动技能加成上限表。 pub event_skill_score_up_limits: Vec, + /// Optional per-event original-unit bonus table. + #[serde(default)] + pub event_shuffle_unit_bonuses: Vec, /// 歌曲元数据表:分难度的基础分与技能分系数。 pub music_metas: Vec, /// 歌曲难度表。 @@ -1174,6 +1188,15 @@ impl OwnedGameData { score_up_limit: entry.score_up_rate_limit, }) .collect(), + event_shuffle_unit_bonuses: sources + .optional::>("eventShuffleUnitBonuses.json")? + .into_iter() + .map(|entry| EventShuffleUnitBonus { + event_id: entry.event_id, + unit_count: entry.unit_count, + bonus_rate: entry.bonus_rate, + }) + .collect(), music_metas: music_rows .iter() .map(|row| MusicMeta { @@ -1270,6 +1293,7 @@ impl OwnedGameData { event_mysekai_fixture_performance_bonus_limits: &self .event_mysekai_fixture_performance_bonus_limits, event_skill_score_up_limits: &self.event_skill_score_up_limits, + event_shuffle_unit_bonuses: &self.event_shuffle_unit_bonuses, music_metas: &self.music_metas, music_difficulties: &self.music_difficulties, event_rarity_bonus_rates: &self.event_rarity_bonus_rates, @@ -1325,24 +1349,6 @@ fn flatten_card_parameters(card: &RawCard) -> Vec { } fn flatten_area_item_levels(raw: Vec) -> Vec { - let mut raw = raw; - raw.sort_by(|left, right| { - ( - left.area_item_id, - normalize_target_token(left.target_unit.as_deref()), - normalize_target_token(left.target_card_attr.as_deref()), - left.target_game_character_id, - left.level, - ) - .cmp(&( - right.area_item_id, - normalize_target_token(right.target_unit.as_deref()), - normalize_target_token(right.target_card_attr.as_deref()), - right.target_game_character_id, - right.level, - )) - }); - let mut result = Vec::with_capacity(raw.len()); for item in raw { let unit = normalize_target_token(item.target_unit.as_deref()); @@ -1353,9 +1359,17 @@ fn flatten_area_item_levels(raw: Vec) -> Vec, power1_bonus_rate: f64, - power1_all_match_bonus_rate: f64, + power2_bonus_rate: f64, + power3_bonus_rate: f64, + #[serde(default)] + power1_all_match_bonus_rate: Option, + #[serde(default)] + power2_all_match_bonus_rate: Option, + #[serde(default)] + power3_all_match_bonus_rate: Option, } #[derive(Debug, Clone, Deserialize)] @@ -1956,6 +1977,14 @@ struct RawEventSkillScoreUpLimit { score_up_rate_limit: i32, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawEventShuffleUnitBonus { + event_id: i32, + unit_count: i32, + bonus_rate: i32, +} + #[derive(Debug, Clone, Deserialize)] struct RawMusicMetaRow { music_id: i32, @@ -2093,6 +2122,12 @@ struct RawHonorLevel { bonus: i32, } +#[cfg(test)] +mod jp7_tests; + +#[cfg(test)] +mod membership_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/src/engine/jp7_tests.rs b/src/engine/jp7_tests.rs new file mode 100644 index 0000000..de7db98 --- /dev/null +++ b/src/engine/jp7_tests.rs @@ -0,0 +1,128 @@ +use super::*; +use crate::power::MultiUnitBonusMode; + +#[test] +fn optional_shuffle_table_and_finale_chapters_share_the_master_loader() { + let required = [ + "cards.json", + "gameCharacterUnits.json", + "events.json", + "skills.json", + "cardRarities.json", + "cardEpisodes.json", + "masterLessons.json", + "areaItemLevels.json", + "characterRanks.json", + "cardMysekaiCanvasBonuses.json", + "eventCards.json", + "eventDeckBonuses.json", + "worldBloomDifferentAttributeBonuses.json", + "eventRarityBonusRates.json", + ]; + let mut sources = MasterdataSources::from_strings( + required.into_iter().map(|name| (name.into(), "[]".into())), + "[]".into(), + ); + let plain = OwnedGameData::from_sources(&sources).unwrap(); + assert!(plain.event_shuffle_unit_bonuses.is_empty()); + sources.tables.insert( + "worldBlooms.json".into(), + serde_json::json!([ + {"eventId":931,"worldBloomChapterType":"finale","chapterNo":1} + ]) + .to_string(), + ); + sources.tables.insert( + "eventShuffleUnitBonuses.json".into(), + serde_json::json!([ + {"eventId":931,"unitCount":3,"bonusRate":71}, + {"eventId":931,"unitCount":5,"bonusRate":19} + ]) + .to_string(), + ); + let loaded = OwnedGameData::from_sources(&sources).unwrap(); + let game = loaded.as_ref(); + assert!(game.is_world_bloom_finale(931)); + assert!(!game.is_world_bloom_finale(932)); + assert_eq!(game.event_shuffle_unit_bonuses[0].bonus_rate, 71); + assert_eq!(game.event_shuffle_unit_bonuses[1].unit_count, 5); + let encoded = serde_json::to_value(&loaded).unwrap(); + let roundtrip: OwnedGameData = serde_json::from_value(encoded).unwrap(); + assert_eq!( + roundtrip.event_shuffle_unit_bonuses, + loaded.event_shuffle_unit_bonuses + ); + sources + .tables + .insert("eventShuffleUnitBonuses.json".into(), "null".into()); + assert!( + OwnedGameData::from_sources(&sources) + .unwrap_err() + .contains("eventShuffleUnitBonuses.json") + ); + sources + .tables + .insert("eventShuffleUnitBonuses.json".into(), "[]".into()); + sources.tables.insert( + "worldBlooms.json".into(), + serde_json::json!([ + {"eventId":931,"worldBloomChapterType":null,"chapterNo":1} + ]) + .to_string(), + ); + assert!( + !OwnedGameData::from_sources(&sources) + .unwrap() + .as_ref() + .is_world_bloom_finale(931) + ); +} + +#[test] +fn area_rows_keep_three_dimensions_nullability_and_master_order() { + let input = serde_json::json!([ + {"areaItemId":56,"level":1,"targetUnit":"multi_unit","targetGameCharacterId":0, + "power1BonusRate":1.0,"power2BonusRate":2.0,"power3BonusRate":3.0, + "power1AllMatchBonusRate":5.0,"power2AllMatchBonusRate":null,"power3AllMatchBonusRate":7.0}, + {"areaItemId":56,"level":1,"targetUnit":"any","targetGameCharacterId":0, + "power1BonusRate":0.1,"power2BonusRate":0.2,"power3BonusRate":0.3, + "power1AllMatchBonusRate":1.0,"power2AllMatchBonusRate":2.0,"power3AllMatchBonusRate":3.0} + ]); + let rows: Vec = serde_json::from_value(input).unwrap(); + let rows = flatten_area_item_levels(rows); + assert_eq!(rows[0].unit.as_deref(), Some("multi_unit")); + assert_eq!(rows[0].power_rate, [1.0, 2.0, 3.0]); + assert_eq!(rows[0].character_id, None); + assert_eq!(rows[0].power_all_match_rate, None); + assert_eq!(rows[1].power_all_match_rate, Some([1.0, 2.0, 3.0])); +} + +#[test] +fn evaluation_public_spellings_and_invalid_values() { + for key in ["multi_unit_bonus_evaluation", "multiUnitBonusEvaluation"] { + for (value, mode) in [ + ("by_deck", MultiUnitBonusMode::ByDeck), + ("force_on", MultiUnitBonusMode::ForceOn), + ("force_off", MultiUnitBonusMode::ForceOff), + ] { + let input = serde_json::json!({key:value}).to_string(); + assert_eq!( + parse_build_params_json(&input) + .unwrap() + .multi_unit_bonus_mode, + mode + ); + } + for value in [ + serde_json::json!("sometimes"), + serde_json::json!(true), + serde_json::json!(7), + ] { + assert!(parse_build_params_json(&serde_json::json!({key:value}).to_string()).is_err()); + } + } + assert_eq!( + parse_build_params_json("{}").unwrap().multi_unit_bonus_mode, + MultiUnitBonusMode::ByDeck + ); +} diff --git a/src/engine/membership_tests.rs b/src/engine/membership_tests.rs new file mode 100644 index 0000000..965320e --- /dev/null +++ b/src/engine/membership_tests.rs @@ -0,0 +1,22 @@ +use super::parse_build_params_json; +use crate::handler::FixedConstraintMode; + +#[test] +fn fixed_constraint_mode_is_explicit_and_defaults_to_slots() { + assert_eq!( + parse_build_params_json("{}").unwrap().fixed_constraint_mode, + FixedConstraintMode::Slots + ); + for key in ["fixedConstraintMode", "fixed_constraint_mode"] { + let request = format!(r#"{{"{key}":"members"}}"#); + assert_eq!( + parse_build_params_json(&request) + .unwrap() + .fixed_constraint_mode, + FixedConstraintMode::Members + ); + for invalid in [r#""other""#, "true", "12"] { + assert!(parse_build_params_json(&format!(r#"{{"{key}":{invalid}}}"#)).is_err()); + } + } +} diff --git a/src/handler/build.rs b/src/handler/build.rs index 1c7e4cb..37e4615 100644 --- a/src/handler/build.rs +++ b/src/handler/build.rs @@ -173,6 +173,27 @@ impl<'a> PreparedPoolBuild<'a> { )); } let event_ctx = build_event_context(game, params)?; + if params.fixed_constraint_mode == types::FixedConstraintMode::Members { + if matches!( + params.live_type, + crate::types::LiveType::Challenge | crate::types::LiveType::ChallengeAuto + ) { + return Err(BuildError::InvalidConfig( + "fixed_constraint_mode=members does not support challenge live".to_string(), + )); + } + if !event_ctx.as_ref().is_some_and(|ctx| ctx.is_finale) { + return Err(BuildError::InvalidConfig( + "fixed_constraint_mode=members requires a World Bloom final chapter" + .to_string(), + )); + } + if !params.target_bonus_list.is_empty() && requires_member_roles(params) { + return Err(BuildError::InvalidConfig( + "fixed_constraint_mode=members does not support target_bonus_list".to_string(), + )); + } + } if !params.target_bonus_list.is_empty() && !matches!(params.target, crate::types::ScoreTarget::Bonus) { @@ -283,9 +304,9 @@ impl<'a> PreparedPoolBuild<'a> { .unwrap_or(0); if from_table == 0 && limited_entry.is_some() - && crate::types::is_world_bloom_finale_event( - event_ctx.as_ref().map(|ctx| ctx.event_id).unwrap_or(0), - ) + && event_ctx + .as_ref() + .is_some_and(|ctx| crate::types::is_world_bloom_finale_event(ctx.event_id)) { // legacy 终章:当期卡行缺 leaderBonusRate 时队长兜底 20% // The compact context stores this exact 20% as 200 tenths. @@ -322,9 +343,9 @@ impl<'a> PreparedPoolBuild<'a> { } } - let event_scoped = event_ctx.as_ref().is_some_and(|ctx| { - ctx.support_deck_count == 0 && !crate::types::is_world_bloom_finale_event(ctx.event_id) - }); + let event_scoped = event_ctx + .as_ref() + .is_some_and(|ctx| ctx.support_deck_count == 0 && !ctx.is_finale); // 精确档位组卡(target=bonus + target_bonus_list)的候选语义: // 命中零头档位(如 25%~33%)依赖全盒的低加成尾部,任何按加成盲裁的 // 预过滤都会把这些档位变成不可达。专属路径只保留硬约束,并在综合力 @@ -454,9 +475,7 @@ pub(super) fn select_leader_honors( event_ctx: Option<&EventContext>, user: &types::UserProfile, ) -> Result>, BuildError> { - let Some(ctx) = event_ctx.filter(|ctx| { - crate::types::is_world_bloom_finale_event(ctx.event_id) && !ctx.honor_bonuses.is_empty() - }) else { + let Some(ctx) = event_ctx.filter(|ctx| ctx.is_finale && !ctx.honor_bonuses.is_empty()) else { return Ok(Vec::new()); }; let owned_honors: std::collections::HashSet = user @@ -596,6 +615,15 @@ pub(super) fn validate_fixed_constraints( Ok((fixed_card_ids, fixed_character_ids)) } +/// An empty pin list, or only the chosen leader, already has slot-mode semantics. +fn requires_member_roles(params: &types::BuildParams) -> bool { + !params.fixed_cards.is_empty() + || params + .fixed_characters + .iter() + .any(|&id| Some(id) != params.forced_leader_character_id) +} + pub(super) fn skill_states_for_card( default_image_kind: DefaultImage, after_training: bool, @@ -695,6 +723,14 @@ pub(super) fn build_search_context( target: params.target, fixed_card_ids, fixed_character_ids, + fixed_constraint_mode: if params.fixed_constraint_mode + == types::FixedConstraintMode::Members + && !requires_member_roles(params) + { + types::FixedConstraintMode::Slots + } else { + params.fixed_constraint_mode + }, // 指定队长对所有活动/模式生效;挑战 live 五张同角色,队长约束无意义。 forced_leader_character_id: if matches!( params.live_type, @@ -728,8 +764,10 @@ pub(super) fn build_search_context( support_decks_by_character, is_world_bloom: event_ctx .is_some_and(|ctx| matches!(ctx.event_type, crate::types::EventType::WorldBloom)), - is_final_chapter: event_ctx - .is_some_and(|ctx| crate::types::is_world_bloom_finale_event(ctx.event_id)), + is_wl3_finale: event_ctx + .is_some_and(|ctx| ctx.is_finale && ctx.world_bloom_event_turn == Some(3)), + shuffle_unit_bonus: event_ctx.map_or([0; 6], |ctx| ctx.shuffle_unit_bonus), + is_final_chapter: event_ctx.is_some_and(|ctx| ctx.is_finale), enforce_char_uniqueness: !matches!( params.live_type, crate::types::LiveType::Challenge | crate::types::LiveType::ChallengeAuto @@ -746,7 +784,10 @@ pub(super) fn build_search_context( multi_live_score_up_lower_bound: params.multi_live_score_up_lower_bound, extra_bonus_ub: diff_attr_bonus.into_iter().max().unwrap_or(0) as u32 + support_bonus_top_sum.ceil() as u32 - + support_bonus_top_sum_by_character, + + support_bonus_top_sum_by_character + + event_ctx.map_or(0, |ctx| { + u32::from(ctx.shuffle_unit_bonus.iter().copied().max().unwrap_or(0)) + }), w_power: 1.0, w_bonus: 1.0, skill_ub_global, @@ -779,13 +820,21 @@ pub(super) fn resolve_fixture_bonus_limit( game: &types::GameData<'_>, event_ctx: Option<&EventContext>, ) -> Option { - let event_id = event_ctx?.event_id; + let event_ctx = event_ctx?; + let event_id = event_ctx.event_id; game.event_mysekai_fixture_performance_bonus_limits .iter() .find(|entry| entry.event_id == event_id) .map(|entry| entry.bonus_rate_limit) - // 终章(legacy 180 与模拟 WL3 终章)固定 20。 - .or_else(|| crate::types::is_world_bloom_finale_event(event_id).then_some(20)) + .or_else(|| { + event_ctx + .is_finale + .then_some(if event_ctx.world_bloom_event_turn == Some(2) { + 20 + } else { + 60 + }) + }) } pub(super) fn build_card_pool_fully_prepared_internal( prepared: &PreparedGameData<'_>, @@ -799,9 +848,8 @@ pub(super) fn build_card_pool_fully_prepared_internal( let music = build.music.as_ref(); let prepared_cards = &build.cards; let mut cards = Vec::with_capacity(prepared_cards.len()); - let needs_support_cards = event_ctx.is_some_and(|ctx| { - ctx.support_deck_count > 0 || crate::types::is_world_bloom_finale_event(ctx.event_id) - }); + let needs_support_cards = + event_ctx.is_some_and(|ctx| ctx.support_deck_count > 0 || ctx.is_finale); let mut support_seeds: Vec = if needs_support_cards { Vec::with_capacity(prepared_cards.len()) } else { @@ -909,15 +957,24 @@ pub(super) fn build_card_pool_fully_prepared_internal( } else { params.live_type }; - let (pool, full, gathered) = sort_and_gather( + let (mut pool, full, gathered) = sort_and_gather( cards, params.target, event_ctx.is_some(), effective_live_type, - &fixed_card_ids, - &fixed_character_ids, + if params.fixed_constraint_mode == types::FixedConstraintMode::Members { + &[] + } else { + &fixed_card_ids + }, + if params.fixed_constraint_mode == types::FixedConstraintMode::Members { + &[] + } else { + &fixed_character_ids + }, include_details, )?; + pool.set_multi_unit_bonus_mode(params.multi_unit_bonus_mode); let mut search_ctx = build_search_context( gathered, &support_seeds, diff --git a/src/handler/capacity.rs b/src/handler/capacity.rs index 67e6b74..0ec446e 100644 --- a/src/handler/capacity.rs +++ b/src/handler/capacity.rs @@ -209,7 +209,7 @@ pub(super) fn validate_cards(cards: &[CardIntermediate]) -> Result<(), BuildErro for card in cards { public_card_id(card.game_card_id)?; ensure("character id", u64::from(card.character_id), 26)?; - ensure("attribute id", u64::from(card.attr), 5)?; + ensure("attribute id", u64::from(card.attr), 4)?; ensure("unit mask", u64::from(card.unit_mask_raw), 63)?; ensure( "per-card power unit profiles", @@ -301,6 +301,7 @@ mod tests { SearchContext { target, fixed_card_ids: Vec::new(), + fixed_constraint_mode: crate::handler::FixedConstraintMode::Slots, fixed_character_ids: Vec::new(), forced_leader_character_id: None, music_rate_pct: 130, @@ -319,6 +320,8 @@ mod tests { support_decks_by_character: Vec::new(), is_world_bloom: true, is_final_chapter: false, + is_wl3_finale: false, + shuffle_unit_bonus: [0; 6], enforce_char_uniqueness: true, minimize: false, live_type: LiveType::Multi, diff --git a/src/handler/event_bonus.rs b/src/handler/event_bonus.rs index df3468f..0f1b100 100644 --- a/src/handler/event_bonus.rs +++ b/src/handler/event_bonus.rs @@ -25,6 +25,8 @@ pub(crate) struct EventContext { pub event_id: i32, /// 活动类型。 pub event_type: EventType, + /// Finale rules apply to master-data chapters and supported simulated finales. + pub is_finale: bool, /// 当期卡表。 pub event_cards: Vec, /// deck bonus 规则。 @@ -41,6 +43,8 @@ pub(crate) struct EventContext { pub card_bonus_count_limit: usize, /// World Bloom 异色加成。 pub diff_attr_bonus: [u16; 6], + /// Event-provided bonus indexed by original-unit count. + pub shuffle_unit_bonus: [u16; 6], /// 支援 deck 取用数量。 pub support_deck_count: u8, /// World Bloom 章节角色 ID。 @@ -86,46 +90,80 @@ fn load_diff_attr_bonus(table: &[WorldBloomDiffAttrBonus]) -> [u16; 6] { result } -fn load_card_bonus_limit(table: &[EventCardBonusLimit], event_id: i32) -> usize { +fn load_card_bonus_limit( + table: &[EventCardBonusLimit], + event_id: i32, + finale_turn: Option, +) -> usize { table .iter() .find(|entry| entry.event_id == event_id) .map(|entry| entry.member_count_limit.max(0) as usize) - // 终章(legacy 180 与模拟 WL3 终章)最多 4 张享受 limited bonus。 - .unwrap_or_else(|| { - if crate::types::is_world_bloom_finale_event(event_id) { - 4 - } else { - 5 - } - }) + .unwrap_or(if finale_turn == Some(2) { 4 } else { 5 }) +} + +fn load_shuffle_unit_bonus( + game: &GameData<'_>, + event_id: i32, + finale_turn: Option, +) -> Result<[u16; 6], BuildError> { + // Keep the WL3 finale rule when optional master rows have not been supplied. + let mut result = if finale_turn == Some(3) { + [0, 0, 0, 10, 30, 50] + } else { + [0; 6] + }; + let rows: Vec<_> = game + .event_shuffle_unit_bonuses + .iter() + .filter(|row| row.event_id == event_id) + .collect(); + if !rows.is_empty() { + result = [0; 6]; + } + for row in rows { + if (1..=5).contains(&row.unit_count) { + let rate = row.bonus_rate.max(0) as u64; + capacity::ensure("shuffle unit bonus", rate, u64::from(u16::MAX))?; + result[row.unit_count as usize] = rate as u16; + } + } + Ok(result) } fn load_skill_limit(table: &[EventSkillScoreUpLimit], event_id: i32) -> Option { table .iter() .find(|entry| entry.event_id == event_id) - // 表内存的是百分比(如 230 = 230%),实际加分上限是扣除基数 100% 后的点数。 - .map(|entry| (entry.score_up_limit - 100).max(0) as u32) + .map(|entry| entry.score_up_limit.max(0) as u32) } -fn resolve_skill_limit(game: &GameData<'_>, params: &BuildParams, event_id: i32) -> Option { +fn resolve_skill_limit( + game: &GameData<'_>, + params: &BuildParams, + event_id: i32, +) -> Result, BuildError> { if matches!( params.live_type, crate::types::LiveType::Challenge | crate::types::LiveType::ChallengeAuto ) { - return None; + return Ok(None); } // 游戏真实数据优先:真实终章一旦在表中给出上限,以数据为准,不走兜底常量。 if let Some(limit) = load_skill_limit(game.event_skill_score_up_limits, event_id) { - return Some(limit); + return Ok(Some(limit)); } // 数据缺行时的终章兜底:legacy 终章 180 与模拟 WL3 终章均沿用上一届 // 真实终章的 140 点规则。 if crate::types::is_world_bloom_finale_event(event_id) { - return Some(140); + return Ok(Some(140)); } - None + if game.is_world_bloom_finale(event_id) { + return Err(BuildError::InvalidConfig(format!( + "eventSkillScoreUpLimits is missing a row for finale event {event_id}" + ))); + } + Ok(None) } fn load_support_deck_count(turn: Option, event_type: EventType) -> u8 { @@ -330,8 +368,26 @@ pub(crate) fn build_event_context( } bonuses }, - skill_score_up_limit: resolve_skill_limit(game, params, event_id), - card_bonus_count_limit: load_card_bonus_limit(game.event_card_bonus_limits, event_id), + skill_score_up_limit: resolve_skill_limit(game, params, event_id)?, + is_finale: game.is_world_bloom_finale(event_id), + card_bonus_count_limit: load_card_bonus_limit( + game.event_card_bonus_limits, + event_id, + game.is_world_bloom_finale(event_id) + .then_some(world_bloom_event_turn) + .flatten(), + ), + shuffle_unit_bonus: if matches!(event_type, EventType::WorldBloom) { + load_shuffle_unit_bonus( + game, + event_id, + game.is_world_bloom_finale(event_id) + .then_some(world_bloom_event_turn) + .flatten(), + )? + } else { + [0; 6] + }, diff_attr_bonus: if matches!(event_type, EventType::WorldBloom) { load_diff_attr_bonus(game.world_bloom_different_attribute_bonuses) } else { @@ -535,6 +591,7 @@ mod tests { EventContext { event_id: 0, event_type: EventType::Marathon, + is_finale: false, event_cards: Vec::new(), deck_bonuses: Vec::new(), rarity_bonuses: Vec::new(), @@ -543,6 +600,7 @@ mod tests { skill_score_up_limit: None, card_bonus_count_limit: 5, diff_attr_bonus: [0; 6], + shuffle_unit_bonus: [0; 6], support_deck_count: 0, world_bloom_character_id: None, world_bloom_event_turn: None, @@ -693,6 +751,7 @@ mod tests { world_bloom_support_deck_unit_event_limited_bonuses: &[], event_mysekai_fixture_performance_bonus_limits: &[], event_skill_score_up_limits: &[], + event_shuffle_unit_bonuses: &[], music_metas: &[], music_difficulties: &[], honors: &[], diff --git a/src/handler/gather.rs b/src/handler/gather.rs index e97f304..92773d9 100644 --- a/src/handler/gather.rs +++ b/src/handler/gather.rs @@ -14,7 +14,7 @@ pub(crate) struct CardIntermediate { pub card_rarity_type: i32, /// 角色 ID。 pub character_id: u8, - /// 属性 ID。 + /// Pool 使用的 0-based 属性索引(0..=4),不是 `Attr` 枚举值。 pub attr: u8, /// 原始 unit mask。 pub unit_mask_raw: u8, @@ -55,7 +55,7 @@ pub struct FullPrecisionCard { pub card_rarity_type: i32, /// 角色 ID。 pub character_id: u8, - /// 属性 ID。 + /// Pool 使用的 0-based 属性索引(0..=4),不是 `Attr` 枚举值。 pub attr: u8, /// 原始 unit mask。 pub unit_mask_raw: u8, @@ -71,6 +71,8 @@ pub struct FullPrecisionCard { pub skill_level: i32, /// 全精度 power 结果。 pub power: [[PowerDetail; 4]; 6], + /// Optional exact multi-unit power details in original/support/attribute bit order. + pub multi_power: Option<[PowerDetail; 8]>, /// 全精度 skill 结果。 pub skill: SkillInfo, /// 热路径活动 bonus。 @@ -89,6 +91,29 @@ pub struct FullPrecisionCard { pub leader_limit_bonus_x10: u16, } +impl FullPrecisionCard { + /// Resolve component details using the same effective total as the search. + /// Returns `None` if these details do not belong to the requested pool card. + pub fn effective_detail( + &self, + pool: &CardPool, + card: crate::pool::CardIdx, + composition: crate::power::DeckComposition, + ) -> Option { + if self.game_card_id != pool.game_id(card) { + return None; + } + let total = crate::power::effective_power(pool, card, composition); + // Every stored state of this card differs only in its area component. + self.power + .iter() + .flatten() + .chain(self.multi_power.iter().flatten()) + .find(|detail| detail.total.max(0) as u32 == total) + .copied() + } +} + pub(crate) struct GatheredContext { pub(crate) skill_max: Vec, pub(crate) leader_honor_bonus_x10: Vec, @@ -333,6 +358,9 @@ pub(crate) fn sort_and_gather( } builder.set_power_values(dense, power_values); + if let Some(details) = card.power.multi_details() { + builder.set_multi_power_values(dense, details.map(|detail| detail.total.max(0) as u32)); + } builder.set_power_lut(dense, power_lut); builder.set_power_max(dense, card.power.power_max.max(0) as u32); builder.set_skill(dense, slot); @@ -373,6 +401,7 @@ pub(crate) fn sort_and_gather( master_rank: card.master_rank, skill_level: card.skill_level, power: card.power.resolved(), + multi_power: card.power.multi_details(), skill: card.skill.full, event_bonus: card.event_bonus, power_min_exact: card.power.power_min, diff --git a/src/handler/index.rs b/src/handler/index.rs index ec4ddd6..8d0467a 100644 --- a/src/handler/index.rs +++ b/src/handler/index.rs @@ -179,19 +179,23 @@ impl PoolIndexes { .entry((entry.area_item_id, entry.level)) .or_default() .push(PowerAreaItem { - unit: entry - .unit - .as_deref() - .and_then(parse_unit_code) - .and_then(unit_to_pool_index) - .unwrap_or(PowerAreaItem::ANY), + unit: match entry.unit.as_deref().map(str::trim) { + None | Some("" | "any" | "none") => PowerAreaItem::ANY, + Some("multi_unit") => PowerAreaItem::MULTI, + Some(unit) => parse_unit_code(unit) + .and_then(unit_to_pool_index) + .unwrap_or(PowerAreaItem::UNKNOWN), + }, attr: entry .attr .as_deref() .and_then(parse_attr_code) .and_then(attr_to_pool_index) .unwrap_or(PowerAreaItem::ANY), - character_id: entry.character_id.unwrap_or(PowerAreaItem::ANY_CHARACTER), + character_id: entry + .character_id + .filter(|id| *id != 0) + .unwrap_or(PowerAreaItem::ANY_CHARACTER), power_rate: entry.power_rate, power_all_match_rate: entry.power_all_match_rate, }); @@ -357,6 +361,17 @@ impl PoolIndexes { pub(crate) fn area_items(&self, area_item_id: i32, level: i32) -> &[PowerAreaItem] { self.area_by_item_level .get(&(area_item_id, level)) + .or_else(|| { + let max_level = self + .area_by_item_level + .keys() + .filter(|(id, _)| *id == area_item_id) + .map(|(_, level)| *level) + .max()?; + (level > max_level) + .then(|| self.area_by_item_level.get(&(area_item_id, max_level))) + .flatten() + }) .map(Vec::as_slice) .unwrap_or(&[]) } diff --git a/src/handler/power.rs b/src/handler/power.rs index 3f6e6e6..832942c 100644 --- a/src/handler/power.rs +++ b/src/handler/power.rs @@ -17,6 +17,8 @@ pub(crate) struct PowerResult { pub gate_bonus: i32, /// 按 real unit × member_key 保存唯一变化的 area item 分量。 pub area_item_bonus: [[i32; 4]; 6], + /// Optional eight-state multi-unit area component. + pub multi_area_item_bonus: Option<[i32; 8]>, /// 精确最小综合力。 pub power_min: i32, /// 精确最大综合力。 @@ -44,6 +46,23 @@ impl PowerResult { } } + pub(crate) fn multi_details(&self) -> Option<[PowerDetail; 8]> { + self.multi_area_item_bonus.map(|areas| { + areas.map(|area_item_bonus| PowerDetail { + base: self.base, + area_item_bonus, + character_bonus: self.character_bonus, + fixture_bonus: self.fixture_bonus, + gate_bonus: self.gate_bonus, + total: self.base + + area_item_bonus + + self.character_bonus + + self.fixture_bonus + + self.gate_bonus, + }) + }) + } + pub(crate) fn resolved(&self) -> [[PowerDetail; 4]; 6] { std::array::from_fn(|unit| std::array::from_fn(|member_key| self.detail(unit, member_key))) } @@ -63,6 +82,7 @@ pub(crate) struct PreparedPowerContext { fixture_rate: [i32; 27], canvas_cards: Vec, area_items: Vec, + has_multi_effects: bool, gate_rate_by_unit: [f64; 6], gate_rate_universal: f64, gate_rate_all: f64, @@ -107,17 +127,31 @@ impl PreparedPowerContext { } let mut gate_rate_by_unit = [0.0_f64; 6]; + let mut gate_seen = [false; 6]; let mut gate_rate_universal = 0.0_f64; let mut gate_rate_all = 0.0_f64; + let mut selected_level = None; for entry in &user.user_mysekai_gate_bonuses { - let Some((unit_code, rate)) = resolve_user_gate_bonus(entry, game) else { + let resolved = resolve_user_gate_bonus(entry, game); + if let Some(level) = entry.mysekai_gate_level { + if selected_level.is_none_or(|previous| level > previous) { + selected_level = Some(level); + gate_rate_all = resolved.map_or(0.0, |(_, rate)| rate); + } + } else if selected_level.is_none() { + // Rate-only input has no level ordering; retain its maximum-rate contract. + gate_rate_all = gate_rate_all.max(resolved.map_or(0.0, |(_, rate)| rate)); + } + let Some((unit_code, rate)) = resolved else { continue; }; - gate_rate_all = gate_rate_all.max(rate); - if unit_code.trim().is_empty() { + if unit_code.trim().is_empty() && entry.mysekai_gate_id.is_none() { gate_rate_universal = gate_rate_universal.max(rate); - } else if let Some(unit) = parse_unit_code(unit_code).and_then(unit_to_pool_index) { - gate_rate_by_unit[unit as usize] = gate_rate_by_unit[unit as usize].max(rate); + } else if let Some(unit) = parse_unit_code(unit_code).and_then(unit_to_pool_index) + && !gate_seen[unit as usize] + { + gate_rate_by_unit[unit as usize] = rate; + gate_seen[unit as usize] = true; } } @@ -134,6 +168,9 @@ impl PreparedPowerContext { character_bonus_rate, fixture_rate, canvas_cards, + has_multi_effects: area_items + .iter() + .any(|row| row.unit == PowerAreaItem::MULTI), area_items, gate_rate_by_unit, gate_rate_universal, @@ -178,21 +215,20 @@ impl PreparedPowerContext { #[inline(always)] fn gate_rate(&self, unit_mask: u8) -> f64 { - let piapro_mask = 1u8 << unit_to_pool_index(Unit::Piapro).unwrap_or(5); - let mut rate = if unit_mask == piapro_mask { - self.gate_rate_all + let piapro_mask = 1u8 << 5; + if unit_mask == piapro_mask { + return self.gate_rate_all; + } + let preferred = if unit_mask & piapro_mask != 0 { + unit_mask & !piapro_mask } else { - self.gate_rate_universal + unit_mask }; - if unit_mask != piapro_mask { - let mut units = unit_mask; - while units != 0 { - let unit = units.trailing_zeros() as usize; - units &= units - 1; - rate = rate.max(self.gate_rate_by_unit[unit]); - } - } - rate + self.gate_rate_by_unit + .get(preferred.trailing_zeros() as usize) + .copied() + .unwrap_or(0.0) + .max(self.gate_rate_universal) } #[inline(always)] @@ -296,16 +332,10 @@ fn area_item_bonus_dims( let all_match = (item.unit != PowerAreaItem::ANY && same_unit) || (item.attr != PowerAreaItem::ANY && same_attr); - // 适配层约定:三维 area item 倍率已验证相等并折叠为单一倍率; - // 若 masterdata 出现三维不等,需扩展 AreaItemLevel 类型而不是继续复用该字段。 - let power_rate = if all_match { - item.power_all_match_rate - } else { - item.power_rate - }; - acc[0] += power_rate * 0.01_f64 * base[0] as f64; - acc[1] += power_rate * 0.01_f64 * base[1] as f64; - acc[2] += power_rate * 0.01_f64 * base[2] as f64; + let rates = item.rates(all_match); + for dim in 0..3 { + acc[dim] += rates[dim] * 0.01_f64 * base[dim] as f64; + } } [ @@ -315,17 +345,150 @@ fn area_item_bonus_dims( ] } +/// Selects mutually exclusive unit buckets using f32 rates, then accumulates +/// the retained rows in their original order using independent f64 dimensions. +fn multi_area_bonus( + items: &[PowerAreaItem], + base: [i32; 3], + character: i32, + membership: u8, + attr: u8, + key: usize, +) -> i32 { + const NONE: usize = 0; + const CHARACTER: usize = 1; + const MULTI: usize = 2; + const ORIGINAL: usize = 3; + const SUPPORT: usize = 4; + const ATTRIBUTE: usize = 5; + const ANY: usize = 6; + let original_bit = crate::power::original_unit(membership); + let original = original_bit.trailing_zeros() as u8; + let support = (membership & !original_bit).trailing_zeros() as u8; + let pick = |item: &PowerAreaItem| -> (usize, bool) { + if item.character_id != PowerAreaItem::ANY_CHARACTER { + ( + if item.character_id == character { + CHARACTER + } else { + NONE + }, + false, + ) + } else if item.unit == PowerAreaItem::MULTI { + (MULTI, false) + } else if item.unit != PowerAreaItem::ANY { + if item.unit == original { + (ORIGINAL, key & 4 != 0) + } else if item.unit == support { + (SUPPORT, key & 2 != 0) + } else { + (NONE, false) + } + } else if item.attr != PowerAreaItem::ANY { + ( + if item.attr == attr { ATTRIBUTE } else { NONE }, + key & 1 != 0, + ) + } else { + (ANY, false) + } + }; + let mut buff = [[0f32; 3]; 7]; + let mut normal = [[0f32; 3]; 7]; + let mut present = [false; 7]; + for item in items { + let (bucket, all_match) = pick(item); + if bucket == NONE { + continue; + } + present[bucket] = true; + let rates = item.rates(all_match); + for dim in 0..3 { + buff[bucket][dim] += rates[dim] as f32; + normal[bucket][dim] += item.power_rate[dim] as f32; + } + } + let sum3 = |values: [f32; 3]| (values[0] + values[1]) + values[2]; + let mut dropped = [false; 7]; + let mut use_normal = [false; 7]; + if present[ORIGINAL] && present[SUPPORT] { + dropped[if sum3(buff[ORIGINAL]) < sum3(buff[SUPPORT]) { + ORIGINAL + } else { + SUPPORT + }] = true; + } + if present[MULTI] { + let retained = [ORIGINAL, SUPPORT] + .into_iter() + .find(|&bucket| present[bucket] && !dropped[bucket]); + if let Some(bucket) = retained { + let extra = sum3(std::array::from_fn(|dim| { + buff[bucket][dim] - normal[bucket][dim] + })); + if extra >= sum3(buff[MULTI]) { + dropped[MULTI] = true; + } else { + use_normal[bucket] = true; + } + } + } + let mut acc = [0f64; 3]; + for item in items { + let (bucket, all_match) = pick(item); + if bucket == NONE || dropped[bucket] { + continue; + } + let rates = item.rates(all_match && !use_normal[bucket]); + for dim in 0..3 { + acc[dim] += rates[dim] * 0.01_f64 * f64::from(base[dim]); + } + } + acc.into_iter().map(|value| value.floor() as i32).sum() +} + +fn add_multi_power( + result: &mut PowerResult, + ctx: &PreparedPowerContext, + base: [i32; 3], + character: i32, + attr: u8, +) { + if !ctx.has_multi_effects { + return; + } + let areas = std::array::from_fn(|key| { + multi_area_bonus( + &ctx.area_items, + base, + character, + result.unit_mask, + attr, + key, + ) + }); + for area in areas { + let total = + result.base + result.character_bonus + result.fixture_bonus + result.gate_bonus + area; + result.power_min = result.power_min.min(total); + result.power_max = result.power_max.max(total); + } + result.multi_area_item_bonus = Some(areas); +} + fn resolve_user_gate_bonus<'a>( entry: &'a super::types::UserGateBonus, game: &'a GameData<'_>, ) -> Option<(&'a str, f64)> { if let (Some(gate_id), Some(level)) = (entry.mysekai_gate_id, entry.mysekai_gate_level) { let gate = game.mysekai_gates.iter().find(|gate| gate.id == gate_id)?; - let level = game + let rate = game .mysekai_gate_levels .iter() - .find(|row| row.mysekai_gate_id == gate_id && row.level == level)?; - return Some((gate.unit.as_str(), level.power_bonus_rate)); + .find(|row| row.mysekai_gate_id == gate_id && row.level == level) + .map_or(0.0, |row| row.power_bonus_rate); + return Some((gate.unit.as_str(), rate)); } if entry.bonus_rate > 0.0 { Some((entry.unit.as_str(), entry.bonus_rate)) @@ -542,6 +705,13 @@ pub(crate) fn build_power_batch_from_fn<'a>( } result.power_min = min_value; result.power_max = max_value; + add_multi_power( + &mut result, + ctx, + [base_dims[0][lane], base_dims[1][lane], base_dims[2][lane]], + input.master.character_id, + input.attr, + ); results.push(result); lane += 1; } @@ -608,9 +778,13 @@ pub(crate) fn build_power_scalar_reference( } result.power_min = min_value; result.power_max = max_value; + add_multi_power(&mut result, ctx, base, master.character_id, card_attr); result } +#[cfg(test)] +mod multi_tests; + /// 解析卡的 unit bitmask。 pub(crate) fn resolve_unit_mask(master: &MasterCard, game: &GameData<'_>) -> u8 { let primary = game @@ -680,6 +854,7 @@ mod tests { world_bloom_support_deck_unit_event_limited_bonuses: &[], event_mysekai_fixture_performance_bonus_limits: &[], event_skill_score_up_limits: &[], + event_shuffle_unit_bonuses: &[], music_metas: &[], music_difficulties: &[], event_rarity_bonus_rates: &[], @@ -735,6 +910,7 @@ mod tests { world_bloom_support_deck_unit_event_limited_bonuses: &[], event_mysekai_fixture_performance_bonus_limits: &[], event_skill_score_up_limits: &[], + event_shuffle_unit_bonuses: &[], music_metas: &[], music_difficulties: &[], event_rarity_bonus_rates: &[], diff --git a/src/handler/power/multi_tests.rs b/src/handler/power/multi_tests.rs new file mode 100644 index 0000000..0c5f787 --- /dev/null +++ b/src/handler/power/multi_tests.rs @@ -0,0 +1,86 @@ +use super::*; + +fn row(unit: u8, normal: [f64; 3], all: Option<[f64; 3]>) -> PowerAreaItem { + PowerAreaItem { + unit, + attr: PowerAreaItem::ANY, + character_id: PowerAreaItem::ANY_CHARACTER, + power_rate: normal, + power_all_match_rate: all, + } +} + +#[test] +fn multi_tied_extra_keeps_unit_rates_and_floors_each_dimension() { + let rows = [ + row(0, [1.0; 3], Some([6.0, 2.0, 1.0])), + row(PowerAreaItem::MULTI, [0.0, 0.0, 6.0], None), + ]; + // Extra rates sum to six for either choice; unit wins the tie. + // 101*6% -> 6, 203*2% -> 4, 307*1% -> 3. + assert_eq!(multi_area_bonus(&rows, [101, 203, 307], 1, 1, 0, 4), 13); + let rows = [rows[0], row(PowerAreaItem::MULTI, [0.0, 0.0, 7.0], None)]; + // Multi wins, so the unit row reverts to [1,1,1]. + assert_eq!( + multi_area_bonus(&rows, [101, 203, 307], 1, 1, 0, 4), + 1 + 2 + 24 + ); +} + +#[test] +fn multi_nullable_all_match_uses_the_whole_normal_row() { + let rows = [ + row(0, [1.0, 2.0, 3.0], None), + row(PowerAreaItem::MULTI, [1.0; 3], None), + ]; + assert_eq!( + multi_area_bonus(&rows, [101, 203, 307], 1, 1, 0, 4), + 2 + 6 + 12 + ); +} + +#[test] +fn multi_unit_support_decision_is_f32_with_original_winning_ties() { + let rows = [ + row(5, [2.0; 3], None), + row(0, [2.00000005; 3], None), + row(PowerAreaItem::MULTI, [0.0; 3], None), + ]; + assert_eq!( + multi_area_bonus(&rows, [2_000_000_000, 0, 0], 21, 33, 0, 0), + 40_000_000 + ); + let rows = [ + rows[0], + row(0, [3.0; 3], None), + row(PowerAreaItem::MULTI, [1.0; 3], None), + ]; + assert_eq!(multi_area_bonus(&rows, [100, 200, 300], 21, 33, 0, 0), 24); +} + +#[test] +fn multi_bucket_priority_is_character_then_unit_then_attribute() { + let mut character = row(PowerAreaItem::MULTI, [1.0; 3], Some([99.0; 3])); + character.character_id = 21; + character.attr = 4; + let mut unit = row(5, [2.0; 3], Some([4.0; 3])); + unit.attr = 4; + let rows = [character, unit]; + // Character and unit rows do not additionally require their attribute. + assert_eq!(multi_area_bonus(&rows, [100; 3], 21, 32, 0, 4), 15); +} + +#[test] +fn multi_accumulation_preserves_original_row_order() { + let tiny = 5.684341886080801e-13; + let rows = [ + row(PowerAreaItem::MULTI, [9999.999999999998, 0.0, 0.0], None), + row(PowerAreaItem::ANY, [tiny, 0.0, 0.0], None), + row(PowerAreaItem::ANY, [tiny, 0.0, 0.0], None), + ]; + // Adding each tiny term after the large term loses it. Adding the two + // tiny terms first crosses the integer boundary after the large term. + assert_eq!(multi_area_bonus(&rows, [1, 0, 0], 1, 1, 0, 0), 99); + let reordered = [rows[1], rows[2], rows[0]]; + assert_eq!(multi_area_bonus(&reordered, [1, 0, 0], 1, 1, 0, 0), 100); +} diff --git a/src/handler/tests.rs b/src/handler/tests.rs index b40cd95..d4147e5 100644 --- a/src/handler/tests.rs +++ b/src/handler/tests.rs @@ -1,7 +1,9 @@ //! handler 管线测试。 mod capacity; mod fractional_leader; +mod jp7; mod leader_honor; +mod membership; mod skill_unit_count; use crate::pool::EventBonusExact; @@ -56,6 +58,7 @@ fn sample_game<'a>( world_bloom_support_deck_unit_event_limited_bonuses: &[], event_mysekai_fixture_performance_bonus_limits: &[], event_skill_score_up_limits: &[], + event_shuffle_unit_bonuses: &[], music_metas: &[], music_difficulties: &[], event_rarity_bonus_rates: &[], @@ -317,8 +320,8 @@ fn handler_build_power_uses_f32_item_accumulation() { unit: None, attr: None, character_id: None, - power_rate: 1.0, - power_all_match_rate: 1.0, + power_rate: [1.0; 3], + power_all_match_rate: Some([1.0; 3]), }, types::AreaItemLevel { area_item_id: 2, @@ -326,8 +329,8 @@ fn handler_build_power_uses_f32_item_accumulation() { unit: None, attr: None, character_id: None, - power_rate: 1.0, - power_all_match_rate: 1.0, + power_rate: [1.0; 3], + power_all_match_rate: Some([1.0; 3]), }, ]; let game_units = [types::GameCharacterUnit { @@ -382,7 +385,7 @@ fn handler_build_power_uses_f32_item_accumulation() { assert_eq!(result.detail(1, 0).area_item_bonus, 6); assert_eq!(result.detail(1, 0).total, 309); assert_eq!(result.detail(0, 0), crate::types::PowerDetail::default()); - assert!(std::mem::size_of::() <= 128); + assert!(std::mem::size_of::() <= 168); } #[test] @@ -465,6 +468,7 @@ fn handler_build_card_pool_only_clamps_fixture_bonus_for_matching_event() { world_bloom_support_deck_unit_event_limited_bonuses: &[], event_mysekai_fixture_performance_bonus_limits: &fixture_limits, event_skill_score_up_limits: &[], + event_shuffle_unit_bonuses: &[], music_metas: &[], music_difficulties: &[], event_rarity_bonus_rates: &[], @@ -1663,6 +1667,7 @@ fn handler_build_card_pool_end_to_end_minimal() { world_bloom_support_deck_unit_event_limited_bonuses: &[], event_mysekai_fixture_performance_bonus_limits: &[], event_skill_score_up_limits: &[], + event_shuffle_unit_bonuses: &[], music_metas: &music, music_difficulties: &[], event_rarity_bonus_rates: &[], @@ -2039,6 +2044,7 @@ fn handler_build_power_keeps_all_cards_beyond_mask_capacity() { world_bloom_support_deck_unit_event_limited_bonuses: &[], event_mysekai_fixture_performance_bonus_limits: &[], event_skill_score_up_limits: &[], + event_shuffle_unit_bonuses: &[], music_metas: &[], music_difficulties: &[], event_rarity_bonus_rates: &[], diff --git a/src/handler/tests/capacity.rs b/src/handler/tests/capacity.rs index 9283afe..4470a0e 100644 --- a/src/handler/tests/capacity.rs +++ b/src/handler/tests/capacity.rs @@ -82,6 +82,72 @@ fn capacity_public_card_identity_is_not_clamped() { assert!(checked_build(&fixture, &game, &BuildParams::default()).is_err()); } +#[test] +fn capacity_pool_attributes_reject_out_of_range_without_panicking() { + for attr in 5..=u8::MAX { + let mut card = make_card(1, 1, 100, 50); + card.attr = attr; + let validation = super::super::capacity::validate_cards(std::slice::from_ref(&card)); + let gathered = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + sort_and_gather( + vec![card], + ScoreTarget::Power, + false, + LiveType::Multi, + &[], + &[], + true, + ) + })); + assert!( + gathered.is_ok(), + "pool attribute {attr} panicked after validation {validation:?}" + ); + for error in [validation, gathered.unwrap().map(|_| ())] { + assert!(matches!( + error, + Err(BuildError::CapacityExceeded { + field: "attribute id", + value, + max: 4, + }) if value == u64::from(attr) + )); + } + } +} + +#[test] +fn capacity_all_public_attributes_keep_zero_based_pool_indices() { + let mut fixture = pool_constraint_fixture(&[ + (1, 4, 100), + (2, 4, 100), + (3, 4, 100), + (4, 4, 100), + (5, 4, 100), + ]); + let attrs = ["cool", "cute", "happy", "pure", "mysterious"]; + for (card, attr) in fixture.master_cards.iter_mut().zip(attrs) { + card.attr = attr.to_string(); + } + let game = bonus_tier_game(&fixture); + let (pool, _) = checked_build(&fixture, &game, &BuildParams::default()) + .expect("all five game attributes fit the pool"); + assert_eq!(pool.count(), 5); + for card in pool.indices() { + let master = fixture + .master_cards + .iter() + .find(|master| master.id == i32::from(pool.game_id(card))) + .expect("public card belongs to the fixture"); + let expected = attrs.iter().position(|attr| *attr == master.attr).unwrap() as u8; + assert_eq!(pool.attr(card), expected); + } + assert_eq!( + types::attr_to_pool_index(crate::types::Attr::Mysterious), + Some(4) + ); +} + #[test] fn capacity_bonus_overflow_returns_an_error_before_packing() { let fixture = pool_constraint_fixture(&[(1, 4, 100)]); @@ -211,6 +277,7 @@ fn capacity_applies_a_real_event_cap_before_checking_skill_width() { }]; let game = GameData { event_skill_score_up_limits: &limits, + event_shuffle_unit_bonuses: &[], ..bonus_tier_game(&fixture) }; let params = BuildParams { @@ -221,11 +288,11 @@ fn capacity_applies_a_real_event_cap_before_checking_skill_width() { let (pool, _) = checked_build(&fixture, &game, ¶ms).unwrap(); assert_eq!(pool.count(), 1); let card = pool.card_idx(0).unwrap(); - assert_eq!(pool.skill_max(card), 140, "kind={kind}"); + assert_eq!(pool.skill_max(card), 240, "kind={kind}"); if let Some(reference) = pool.special().ref_skills().first() { assert_eq!( u16::from(pool.skill_min(card)) + u16::from(reference.max), - 140 + 240 ); } } diff --git a/src/handler/tests/jp7.rs b/src/handler/tests/jp7.rs new file mode 100644 index 0000000..0bd5970 --- /dev/null +++ b/src/handler/tests/jp7.rs @@ -0,0 +1,343 @@ +use super::*; +use crate::power::MultiUnitBonusMode; + +#[test] +fn master_finale_identity_and_shuffle_rates_reach_search_and_results() { + let mut fixture = pool_constraint_fixture(&[ + (1, 4, 100), + (5, 4, 100), + (9, 4, 100), + (21, 4, 100), + (22, 4, 100), + ]); + for row in &mut fixture.units { + row.unit = match row.game_character_id { + 1 => "light_sound", + 5 => "idol", + 9 => "street", + _ => "piapro", + } + .into(); + } + fixture.master_cards[3].support_unit = Some("theme_park".into()); + fixture.master_cards[4].support_unit = Some("school_refusal".into()); + fixture.effects[0].value = 200; + let user = pool_constraint_user(&fixture); + for event_id in [218, 931] { + let events = [types::Event { + id: event_id, + event_type: "world_bloom".into(), + }]; + let chapters = [types::WorldBloom { + event_id, + game_character_id: None, + chapter_no: 1, + world_bloom_chapter_type: Some("finale".into()), + }]; + let limits = [types::EventSkillScoreUpLimit { + event_id, + score_up_limit: 147, + }]; + let shuffle = [ + types::EventShuffleUnitBonus { + event_id, + unit_count: 4, + bonus_rate: 73, + }, + types::EventShuffleUnitBonus { + event_id: event_id + 1, + unit_count: 4, + bonus_rate: 99, + }, + ]; + let event_cards = [types::EventCard { + event_id, + card_id: 1, + bonus_rate_x10: 250, + leader_bonus_rate_x10: 0, + }]; + let game = GameData { + events: &events, + world_blooms: &chapters, + event_skill_score_up_limits: &limits, + event_shuffle_unit_bonuses: &shuffle, + event_cards: &event_cards, + ..bonus_tier_game(&fixture) + }; + let params = BuildParams { + event_id: Some(event_id), + target: ScoreTarget::Bonus, + ..Default::default() + }; + let (pool, ctx) = build_card_pool(&user, &game, ¶ms).unwrap(); + assert!(ctx.is_final_chapter); + assert!(ctx.is_wl3_finale); + assert_eq!(ctx.card_bonus_count_limit, 5); + assert_eq!(ctx.power_total_cap, Some(336_000)); + assert_eq!(ctx.shuffle_unit_bonus, [0, 0, 0, 0, 73, 0]); + assert_eq!(ctx.shuffle_bonus_upper(), 73); + assert!(ctx.extra_bonus_ub >= 73); + assert!( + (0..pool.count()) + .all(|dense| pool.skill_max(pool.card_idx(dense as u16).unwrap()) == 147) + ); + assert!(ctx.leader_limit_bonus_x10.iter().all(|value| *value == 0)); + let decks = pool_constraint_search(&pool, &ctx, ¶ms); + let summary = crate::search::summarize_deck(&pool, &ctx, &decks[0].cards).unwrap(); + assert_eq!(summary.shuffle_bonus_rate, 73); + assert_eq!(summary.event_bonus_total, Some(98.0)); + let no_shuffle = GameData { + event_shuffle_unit_bonuses: &[], + ..game + }; + let (_, plain) = build_card_pool(&user, &no_shuffle, ¶ms).unwrap(); + assert_eq!(plain.shuffle_unit_bonus, [0, 0, 0, 10, 30, 50]); + let other_shuffle = GameData { + event_shuffle_unit_bonuses: &shuffle[1..], + ..game + }; + let (_, absent_event) = build_card_pool(&user, &other_shuffle, ¶ms).unwrap(); + assert_eq!(absent_event.shuffle_unit_bonus, plain.shuffle_unit_bonus); + let no_skill_cap = GameData { + event_skill_score_up_limits: &[], + ..game + }; + assert!(matches!(build_card_pool(&user, &no_skill_cap, ¶ms), + Err(BuildError::InvalidConfig(message)) if message.contains("eventSkillScoreUpLimits"))); + let no_finale = GameData { + world_blooms: &[], + ..game + }; + let (_, ordinary) = build_card_pool(&user, &no_finale, ¶ms).unwrap(); + assert!(!ordinary.is_final_chapter); + } +} + +#[test] +fn simulated_shuffle_fallback_and_explicit_partial_tables_are_distinct() { + let fixture = pool_constraint_fixture(&[(1, 4, 100)]); + let params = BuildParams { + world_bloom_finale_turn: Some(3), + ..Default::default() + }; + let game = bonus_tier_game(&fixture); + let ctx = event_bonus::build_event_context(&game, ¶ms) + .unwrap() + .unwrap(); + assert_eq!(ctx.shuffle_unit_bonus, [0, 0, 0, 10, 30, 50]); + let shuffle = [types::EventShuffleUnitBonus { + event_id: crate::types::WL3_FAKE_FINALE_EVENT_ID, + unit_count: 2, + bonus_rate: 61, + }]; + let game = GameData { + event_shuffle_unit_bonuses: &shuffle, + ..game + }; + let ctx = event_bonus::build_event_context(&game, ¶ms) + .unwrap() + .unwrap(); + assert_eq!(ctx.shuffle_unit_bonus, [0, 0, 61, 0, 0, 0]); +} + +fn item(id: i32, unit: Option<&str>, rate: [f64; 3]) -> types::AreaItemLevel { + types::AreaItemLevel { + area_item_id: id, + level: 1, + unit: unit.map(str::to_string), + attr: None, + character_id: Some(0), + power_rate: rate, + power_all_match_rate: None, + } +} + +#[test] +fn owned_level_above_master_max_keeps_all_effect_rows() { + let rows = [ + item(56, None, [1.0, 2.0, 3.0]), + item(56, Some("multi_unit"), [4.0; 3]), + ]; + let fixture = pool_constraint_fixture(&[(1, 4, 100)]); + let game = GameData { + area_item_levels: &rows, + ..bonus_tier_game(&fixture) + }; + let idx = index::PoolIndexes::build(&game); + assert_eq!(idx.area_items(56, 20).len(), 2); + assert_eq!(idx.area_items(56, 0).len(), 0); + assert_eq!(idx.area_items(99, 20).len(), 0); + let mut user = pool_constraint_user(&fixture); + user.user_area_items = vec![types::UserAreaItem { + area_item_id: 56, + level: 20, + }]; + let params = BuildParams { + target: ScoreTarget::Power, + multi_unit_bonus_mode: MultiUnitBonusMode::ForceOn, + ..Default::default() + }; + let (pool, _, full) = build_card_pool_with_details(&user, &game, ¶ms).unwrap(); + assert!(pool.has_multi_power()); + assert_eq!(full[0].multi_power.unwrap()[0].area_item_bonus, 18); +} + +#[test] +fn modes_are_identical_without_owned_multi_rows() { + let rows = [ + item(1, None, [1.0; 3]), + item(56, Some("multi_unit"), [50.0; 3]), + ]; + let fixture = pool_constraint_fixture(&[ + (1, 4, 100), + (2, 4, 101), + (3, 4, 102), + (4, 4, 103), + (5, 4, 104), + ]); + let game = GameData { + area_item_levels: &rows, + ..bonus_tier_game(&fixture) + }; + let mut user = pool_constraint_user(&fixture); + user.user_area_items = vec![types::UserAreaItem { + area_item_id: 1, + level: 1, + }]; + let mut expected = None; + for mode in [ + MultiUnitBonusMode::ByDeck, + MultiUnitBonusMode::ForceOn, + MultiUnitBonusMode::ForceOff, + ] { + let params = BuildParams { + target: ScoreTarget::Power, + multi_unit_bonus_mode: mode, + ..Default::default() + }; + let (pool, ctx) = build_card_pool(&user, &game, ¶ms).unwrap(); + assert!(!pool.has_multi_power()); + let cards: Vec<_> = pool.indices().collect(); + let score = crate::search::resolve_power_for_cards(&pool, &cards); + assert_eq!(score, *expected.get_or_insert(score)); + assert!(!ctx.is_wl3_finale); + } +} + +#[test] +fn gates_select_level_before_rate_and_keep_missing_level_ties() { + let mut fixture = pool_constraint_fixture(&[(21, 4, 100)]); + fixture + .units + .iter_mut() + .find(|unit| unit.game_character_id == 21) + .unwrap() + .unit = "piapro".into(); + let gates = [ + types::MysekaiGate { + id: 1, + unit: "idol".into(), + }, + types::MysekaiGate { + id: 2, + unit: "street".into(), + }, + types::MysekaiGate { + id: 6, + unit: "none".into(), + }, + ]; + let levels = [ + types::MysekaiGateLevel { + mysekai_gate_id: 1, + level: 1, + power_bonus_rate: 9.0, + }, + types::MysekaiGateLevel { + mysekai_gate_id: 2, + level: 2, + power_bonus_rate: 1.0, + }, + ]; + for (entries, expected) in [ + (vec![(1, 1), (2, 2)], 3), + (vec![(1, 1), (6, 3)], 0), + (vec![(6, 2), (2, 2)], 0), + (vec![(2, 2), (6, 2)], 3), + (vec![(99, 3), (2, 2)], 0), + ] { + let game = GameData { + mysekai_gates: &gates, + mysekai_gate_levels: &levels, + ..bonus_tier_game(&fixture) + }; + let mut user = pool_constraint_user(&fixture); + user.user_mysekai_gate_bonuses = entries + .into_iter() + .map(|(id, level)| types::UserGateBonus { + mysekai_gate_id: Some(id), + mysekai_gate_level: Some(level), + unit: String::new(), + bonus_rate: 99.0, + }) + .collect(); + let (_, _, full) = + build_card_pool_with_details(&user, &game, &BuildParams::default()).unwrap(); + assert_eq!(full[0].power[5][0].gate_bonus, expected); + } +} + +#[test] +fn raw_master_skill_caps_and_finale_fallbacks_are_distinct() { + let fixture = pool_constraint_fixture(&[(1, 4, 100)]); + let user = pool_constraint_user(&fixture); + for cap in [140, 240] { + let mut effects = fixture.effects.clone(); + effects[0].value = 300; + let limits = [types::EventSkillScoreUpLimit { + event_id: 42, + score_up_limit: cap, + }]; + let game = GameData { + skill_effects: &effects, + event_skill_score_up_limits: &limits, + event_shuffle_unit_bonuses: &[], + ..bonus_tier_game(&fixture) + }; + let params = BuildParams { + event_id: Some(42), + event_type: Some("marathon".into()), + ..Default::default() + }; + let (pool, _) = build_card_pool(&user, &game, ¶ms).unwrap(); + assert_eq!(pool.skill_max(pool.card_idx(0).unwrap()), cap as u8); + } + for (turn, limited, fixture_cap) in [(2, 4, 20), (3, 5, 60)] { + let game = bonus_tier_game(&fixture); + let params = BuildParams { + world_bloom_finale_turn: Some(turn), + world_bloom_character_id: Some(1), + ..Default::default() + }; + let event = event_bonus::build_event_context(&game, ¶ms) + .unwrap() + .unwrap(); + assert_eq!(event.card_bonus_count_limit, limited); + assert_eq!( + resolve_fixture_bonus_limit(&game, Some(&event)), + Some(fixture_cap) + ); + let limits = [types::EventFixtureBonusLimit { + event_id: event.event_id, + bonus_rate_limit: 17, + }]; + let override_game = GameData { + event_mysekai_fixture_performance_bonus_limits: &limits, + ..game + }; + assert_eq!( + resolve_fixture_bonus_limit(&override_game, Some(&event)), + Some(17) + ); + } +} diff --git a/src/handler/tests/membership.rs b/src/handler/tests/membership.rs new file mode 100644 index 0000000..0e8464b --- /dev/null +++ b/src/handler/tests/membership.rs @@ -0,0 +1,80 @@ +use super::*; + +#[test] +fn final_members_builds_without_pin_order_and_rejects_unsupported_requests() { + let fixture = pool_constraint_fixture(&[ + (1, 4, 100), + (5, 4, 100), + (9, 4, 100), + (13, 4, 100), + (17, 4, 100), + (21, 4, 100), + ]); + let user = pool_constraint_user(&fixture); + let events = [types::Event { + id: 218, + event_type: "world_bloom".into(), + }]; + let chapters = [types::WorldBloom { + event_id: 218, + game_character_id: None, + chapter_no: 1, + world_bloom_chapter_type: Some("finale".into()), + }]; + let limits = [types::EventSkillScoreUpLimit { + event_id: 218, + score_up_limit: 140, + }]; + let game = GameData { + events: &events, + world_blooms: &chapters, + event_skill_score_up_limits: &limits, + ..bonus_tier_game(&fixture) + }; + let mut params = BuildParams { + event_id: Some(218), + fixed_cards: vec![1, 2], + forced_leader_character_id: Some(21), + fixed_constraint_mode: types::FixedConstraintMode::Members, + ..Default::default() + }; + let (pool, ctx) = build_card_pool(&user, &game, ¶ms).unwrap(); + assert!(ctx.uses_member_constraints()); + assert_eq!(ctx.fixed_prefix_len(), 0); + params.fixed_cards.reverse(); + let (reversed, _) = build_card_pool(&user, &game, ¶ms).unwrap(); + assert_eq!( + pool.indices() + .map(|card| pool.game_id(card)) + .collect::>(), + reversed + .indices() + .map(|card| reversed.game_id(card)) + .collect::>() + ); + params.target = ScoreTarget::Bonus; + params.target_bonus_list = vec![100]; + assert!(matches!(build_card_pool(&user, &game, ¶ms), + Err(BuildError::InvalidConfig(message)) if message.contains("target_bonus_list"))); + params.target_bonus_list.clear(); + params.fixed_cards.clear(); + params.target_bonus_list = vec![100]; + let (_, only_leader) = build_card_pool(&user, &game, ¶ms).unwrap(); + assert_eq!( + only_leader.fixed_constraint_mode, + types::FixedConstraintMode::Slots + ); + params.fixed_characters = vec![21]; + let (_, pinned_leader) = build_card_pool(&user, &game, ¶ms).unwrap(); + assert_eq!( + pinned_leader.fixed_constraint_mode, + types::FixedConstraintMode::Slots + ); + params.target_bonus_list.clear(); + let ordinary = GameData { + world_blooms: &[], + ..game + }; + assert!(matches!(build_card_pool(&user, &ordinary, ¶ms), + Err(BuildError::InvalidConfig(message)) if message.contains("requires a World Bloom final chapter"))); +} diff --git a/src/handler/types.rs b/src/handler/types.rs index 4b33879..13ceda1 100644 --- a/src/handler/types.rs +++ b/src/handler/types.rs @@ -70,6 +70,8 @@ pub struct GameData<'a> { pub event_mysekai_fixture_performance_bonus_limits: &'a [EventFixtureBonusLimit], /// 活动技能上限表。 pub event_skill_score_up_limits: &'a [EventSkillScoreUpLimit], + /// Optional event bonus by the number of original character units. + pub event_shuffle_unit_bonuses: &'a [EventShuffleUnitBonus], /// 歌曲元数据表。 pub music_metas: &'a [MusicMeta], /// 歌曲难度表。 @@ -82,6 +84,28 @@ pub struct GameData<'a> { pub bonds_honors: &'a [BondsHonor], } +impl GameData<'_> { + /// Finale identity comes from the chapter table, with legacy/simulated IDs supported. + pub fn is_world_bloom_finale(&self, event_id: i32) -> bool { + crate::types::is_world_bloom_finale_event(event_id) + || self.world_blooms.iter().any(|row| { + row.event_id == event_id + && row.world_bloom_chapter_type.as_deref() == Some("finale") + }) + } +} + +/// An event bonus for a given number of original character units in the main deck. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EventShuffleUnitBonus { + /// Event identifier. + pub event_id: i32, + /// Number of distinct original units; all Virtual Singers share one unit. + pub unit_count: i32, + /// Bonus in whole percentage points. + pub bonus_rate: i32, +} + /// Handler 使用的最小化用户数据。 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct UserProfile { @@ -107,6 +131,17 @@ pub struct UserProfile { pub user_honors: Vec, } +/// Whether fixed cards and characters name input slots or mandatory members. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FixedConstraintMode { + /// Preserve the public input-slot contract, including the leader in slot zero. + #[default] + Slots, + /// Require participation while leaving every role free except a chosen leader. + Members, +} + /// 构建卡池所需的 handler 参数。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct BuildParams { @@ -143,6 +178,9 @@ pub struct BuildParams { pub fixed_cards: Vec, /// 固定角色。 pub fixed_characters: Vec, + /// Final-chapter membership constraints; defaults to the existing slot contract. + #[serde(default)] + pub fixed_constraint_mode: FixedConstraintMode, /// 终章指定队长角色;非终章忽略。 pub forced_leader_character_id: Option, /// WL 角色 ID。 @@ -201,6 +239,9 @@ pub struct BuildParams { pub attr_filter: Option, /// 反向搜索:求最弱(最小化 power)而非最强。仅 Power 目标生效,其它目标忽略。 pub minimize: bool, + /// Activation policy for owned multi-unit area items. + #[serde(default, rename = "multi_unit_bonus_evaluation")] + pub multi_unit_bonus_mode: crate::power::MultiUnitBonusMode, } impl Default for BuildParams { @@ -221,6 +262,7 @@ impl Default for BuildParams { single_card_configs: Vec::new(), fixed_cards: Vec::new(), fixed_characters: Vec::new(), + fixed_constraint_mode: FixedConstraintMode::Slots, forced_leader_character_id: None, world_bloom_character_id: None, world_bloom_event_turn: None, @@ -249,6 +291,7 @@ impl Default for BuildParams { unit_filter: None, attr_filter: None, minimize: false, + multi_unit_bonus_mode: crate::power::MultiUnitBonusMode::default(), } } } @@ -442,10 +485,10 @@ pub struct AreaItemLevel { pub attr: Option, /// 适用角色。 pub character_id: Option, - /// 综合力倍率。 - pub power_rate: f64, - /// 全匹配综合力倍率。 - pub power_all_match_rate: f64, + /// Independent power rates for the three dimensions. + pub power_rate: [f64; 3], + /// All-match rates; missing any dimension makes the entire row use normal rates. + pub power_all_match_rate: Option<[f64; 3]>, } /// 角色所属团。 diff --git a/src/handler/world_bloom.rs b/src/handler/world_bloom.rs index 4c69f34..0846c26 100644 --- a/src/handler/world_bloom.rs +++ b/src/handler/world_bloom.rs @@ -745,7 +745,7 @@ fn synthesize_wb_legacy_rows(game: &types::GameData<'_>) -> SynthWbRows { /// (WL3 终章尚未有真实 masterdata 数据)。 /// /// 规则:全角色 5% deck bonus;WL3 各组章节的限定卡按 25%(队长额外 20%) -/// 挂到终章;WL1/2 限定卡按 20% 进入支援 limited 加成; +/// 挂到终章;支援 limited 行从 WL3 来源活动复制并保留倍率; /// `wl_3rd` 排行称号(top-1000)按 50% 合成队长荣誉加成。 fn synthesize_wb_finale_rows(game: &types::GameData<'_>) -> SynthWbRows { let mut rows = SynthWbRows::default(); @@ -791,14 +791,29 @@ fn synthesize_wb_finale_rows(game: &types::GameData<'_>) -> SynthWbRows { }); } - // 支援 limited:WL1/2 全部限定卡按 20%。 - let all_characters: Vec = game + // Source support rows retain their rates; duplicate (character, card) rows + // use the first eligible source row in master order. + let support_characters: std::collections::BTreeSet<_> = game .game_character_units .iter() - .map(|entry| entry.game_character_id) + .map(|row| row.game_character_id) .filter(|id| (1..=26).contains(id)) .collect(); - rows.support_limited_bonuses = synth_support_limited(game, 3, event_id, &all_characters); + let mut seen_support = std::collections::BTreeSet::new(); + rows.support_limited_bonuses = game + .world_bloom_support_deck_unit_event_limited_bonuses + .iter() + .filter(|row| { + source_event_ids.contains(&row.event_id) + && support_characters.contains(&row.game_character_id) + && row.bonus_rate > 0.0 + && seen_support.insert((row.game_character_id, row.card_id)) + }) + .map(|row| types::WBSupportDeckUnitEventLimitedBonus { + event_id, + ..row.clone() + }) + .collect(); // WL3 排行称号 → 50% 队长荣誉加成(每枚称号命中一个 leader 角色)。 for honor in game.honors { @@ -1025,6 +1040,7 @@ mod tests { world_bloom_support_deck_unit_event_limited_bonuses: &self.limited, event_mysekai_fixture_performance_bonus_limits: &[], event_skill_score_up_limits: &[], + event_shuffle_unit_bonuses: &[], music_metas: &[], music_difficulties: &[], honors: &self.honors, @@ -1270,7 +1286,20 @@ mod tests { #[test] fn finale_synthesis_follows_upstream_rules() { - let fixture = jp_like_fixture(); + let mut fixture = jp_like_fixture(); + assert!( + synthesize_wb_rows(&fixture.game(), WL3_FAKE_FINALE_EVENT_ID) + .support_limited_bonuses + .is_empty() + ); + fixture.limited.extend([ + limited(202, 1, 1, 0.0), + limited(202, 2, 2, -5.0), + limited(202, 27, 3, 12.0), + limited(202, 1, 1, 37.0), + limited(205, 1, 1, 13.0), + limited(207, 2, 2, 9.0), + ]); let rows = synthesize_wb_rows(&fixture.game(), WL3_FAKE_FINALE_EVENT_ID); // 源活动 = 202/205/207(170/179 是 WL2、118 是 WL1)。 @@ -1289,13 +1318,13 @@ mod tests { .all(|bonus| bonus.bonus_rate_x10 == 50 && bonus.attr.is_none()) ); - // 支援 limited:WL1/2 的 card2(WL2)20%;card1 属 WL1 也应 20%。 - // fixture 中 WL1 的 card1 在 eventCards 里 bonus=20>0,故 1/2 两张。 - assert_eq!(rows.support_limited_bonuses.len(), 2); - assert!( - rows.support_limited_bonuses - .iter() - .all(|bonus| bonus.bonus_rate == 20.0) + // The first eligible (character, card) row wins; rejected rows do not reserve the key. + assert_eq!( + rows.support_limited_bonuses, + vec![ + limited(WL3_FAKE_FINALE_EVENT_ID, 1, 1, 37.0), + limited(WL3_FAKE_FINALE_EVENT_ID, 2, 2, 9.0), + ] ); // 荣誉:9001 → (part1, cp1) → 角色 1;9002 → (part2, cp2) → 角色 4(part2 成员); diff --git a/src/lib.rs b/src/lib.rs index 7b6044e..16d795e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,6 +56,7 @@ pub mod auxiliary; pub mod engine; pub mod handler; pub mod pool; +pub mod power; pub mod search; pub(crate) mod simd; pub mod types; diff --git a/src/pool/builder.rs b/src/pool/builder.rs index 71cb2c9..a76c794 100644 --- a/src/pool/builder.rs +++ b/src/pool/builder.rs @@ -14,6 +14,8 @@ pub struct PoolBuilder { layout: PoolLayout, count: u16, special: SpecialTables, + multi_power: Option>, + multi_mode: crate::power::MultiUnitBonusMode, } impl PoolBuilder { @@ -25,6 +27,8 @@ impl PoolBuilder { layout, count: n, special: SpecialTables::default(), + multi_power: None, + multi_mode: crate::power::MultiUnitBonusMode::default(), } } @@ -217,8 +221,23 @@ impl PoolBuilder { self.special.push_limited_bonus(value_x10); } + /// Sets the activation policy of the optional multi-unit table. + pub fn set_multi_unit_bonus_mode(&mut self, mode: crate::power::MultiUnitBonusMode) { + self.multi_mode = mode; + } + + /// Sets the eight exact multi-unit powers, allocating a pool-wide sidecar on first use. + pub fn set_multi_power_values(&mut self, idx: u16, values: [u32; 8]) { + self.multi_power + .get_or_insert_with(|| vec![[0; 8]; usize::from(self.count)])[usize::from(idx)] = + values; + } + /// 冻结为只读 `CardPool`。 pub fn freeze(self) -> CardPool { - CardPool::from_parts(self.arena, self.layout, self.count, self.special) + let mut pool = CardPool::from_parts(self.arena, self.layout, self.count, self.special); + pool.multi_power = self.multi_power; + pool.multi_mode = self.multi_mode; + pool } } diff --git a/src/pool/card_pool.rs b/src/pool/card_pool.rs index 9a5d61b..fef736a 100644 --- a/src/pool/card_pool.rs +++ b/src/pool/card_pool.rs @@ -15,6 +15,8 @@ pub struct CardPool { layout: PoolLayout, count: u16, special: SpecialTables, + pub(super) multi_power: Option>, + pub(super) multi_mode: crate::power::MultiUnitBonusMode, } impl CardPool { @@ -29,6 +31,8 @@ impl CardPool { layout, count, special, + multi_power: None, + multi_mode: crate::power::MultiUnitBonusMode::default(), } } @@ -111,6 +115,51 @@ impl CardPool { } } + /// Activation policy for the optional multi-unit table. + pub fn multi_unit_bonus_mode(&self) -> crate::power::MultiUnitBonusMode { + self.multi_mode + } + + /// Updates the policy without reallocating the sidecar. + pub fn set_multi_unit_bonus_mode(&mut self, mode: crate::power::MultiUnitBonusMode) { + self.multi_mode = mode; + } + + /// Whether this pool owns effective multi-unit rows. + pub fn has_multi_power(&self) -> bool { + self.multi_power.is_some() + } + + /// Eight exact powers indexed by original/support/attribute ALL_MATCH bits. + pub fn multi_power_values(&self, idx: CardIdx) -> Option<&[u32; 8]> { + self.multi_power.as_ref().map(|values| &values[idx.raw()]) + } + + /// Minimum over every legacy and enabled multi-unit state. + pub fn power_min(&self, idx: CardIdx) -> u32 { + let legacy = if self.unit_mask_raw(idx) & 0x3f == 0 { + 0 + } else { + (0..8) + .map(|key| { + crate::search::evaluate::decode_u18( + self.power_values(idx), + self.power_lut(idx), + key, + ) + }) + .min() + .unwrap_or(0) + }; + if self.multi_mode != crate::power::MultiUnitBonusMode::ForceOff { + self.multi_power_values(idx).map_or(legacy, |values| { + legacy.min(*values.iter().min().unwrap_or(&0)) + }) + } else { + legacy + } + } + /// 返回技能主表槽位。 #[inline(always)] pub fn skill(&self, idx: CardIdx) -> SkillSlot { @@ -352,6 +401,7 @@ impl CardPool { assert!(retained <= u16::MAX as usize, "compacted pool is too large"); let mut builder = PoolBuilder::new(retained as u16); + builder.set_multi_unit_bonus_mode(self.multi_mode); for skill in self.special().unit_count().iter().copied() { builder.add_unit_count_skill(skill); } @@ -373,6 +423,9 @@ impl CardPool { let src = CardIdx::new(dense_idx as u16); builder.set_power_values(next_idx, *self.power_values(src)); + if let Some(values) = self.multi_power_values(src) { + builder.set_multi_power_values(next_idx, *values); + } builder.set_power_lut(next_idx, self.power_lut(src)); builder.set_skill(next_idx, self.skill(src)); builder.set_event_bonus_packed(next_idx, *self.event_bonus(src)); diff --git a/src/power.rs b/src/power.rs new file mode 100644 index 0000000..8dabb5c --- /dev/null +++ b/src/power.rs @@ -0,0 +1,143 @@ +//! Deck composition and area-item power selection. +use crate::pool::{CardIdx, CardPool}; +use serde::{Deserialize, Serialize}; + +/// Activation policy for owned multi-unit area effects. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MultiUnitBonusMode { + /// Activate only for a multi-unit deck. + #[default] + ByDeck, + /// Activate irrespective of deck composition. + ForceOn, + /// Ignore multi-unit effects, retaining all other effects of the same item. + ForceOff, +} + +impl MultiUnitBonusMode { + /// Whether the multi-unit table is active for this composition. + pub fn active(self, is_multi_unit: bool) -> bool { + match self { + Self::ByDeck => is_multi_unit, + Self::ForceOn => true, + Self::ForceOff => false, + } + } +} + +/// Original character unit, distinct from skill membership and support unit. +pub(crate) fn original_unit(mask: u8) -> u8 { + if mask & (1 << 5) != 0 { + 1 << 5 + } else { + mask & mask.wrapping_neg() + } +} + +/// Deck-wide facts shared by all effective-power consumers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DeckComposition { + /// Intersection of original-or-support memberships (not a single unit). + pub shared_units: u8, + /// Whether five members share their attribute. + pub shared_attribute: bool, + /// Multi-unit furniture activation predicate. + pub is_multi_unit: bool, + /// Union of original character units, used for finale shuffle bonuses. + pub original_unit_mask: u8, +} + +impl DeckComposition { + /// Computes composition in deck order. Partial decks never have ALL_MATCH. + pub fn from_cards(pool: &CardPool, cards: &[CardIdx]) -> Self { + let mut shared_units = 0x3f; + let mut original_unit_mask = 0; + let mut attrs = 0u8; + let mut multi_units = 0u8; + for &card in cards { + let mask = pool.unit_mask_raw(card); + let original = original_unit(mask); + shared_units &= mask; + original_unit_mask |= original; + attrs |= 1 << pool.attr(card); + if original != 1 << 5 { + multi_units |= original; + } + } + let mut need_vs = false; + for &card in cards { + let mask = pool.unit_mask_raw(card); + if original_unit(mask) != 1 << 5 { + continue; + } + let support = mask & !(1 << 5); + if support == 0 || multi_units & support != 0 { + need_vs = true; + } else { + multi_units |= support; + } + } + if need_vs { + multi_units |= 1 << 5; + } + Self { + shared_units: if cards.len() == 5 { shared_units } else { 0 }, + shared_attribute: cards.len() == 5 && attrs.count_ones() == 1, + is_multi_unit: multi_units.count_ones() > 1, + original_unit_mask, + } + } + + /// Index of `(original ALL_MATCH, support ALL_MATCH, attribute ALL_MATCH)`. + pub fn multi_key(self, membership: u8) -> usize { + let original = original_unit(membership); + let support = membership & !original; + usize::from(self.shared_units & original != 0) * 4 + + usize::from(self.shared_units & support != 0) * 2 + + usize::from(self.shared_attribute) + } + + /// Event-provided shuffle bonus for the distinct original character units. + pub fn shuffle_bonus(self, rates: &[u16; 6]) -> u32 { + u32::from(rates[self.original_unit_mask.count_ones() as usize]) + } +} + +/// The sole selector between the legacy profile table and the optional multi table. +#[inline] +pub fn effective_power(pool: &CardPool, card: CardIdx, composition: DeckComposition) -> u32 { + if pool + .multi_unit_bonus_mode() + .active(composition.is_multi_unit) + && let Some(values) = pool.multi_power_values(card) + { + return values[composition.multi_key(pool.unit_mask_raw(card))]; + } + legacy_power( + pool, + card, + composition.shared_units, + composition.shared_attribute, + ) +} + +#[inline] +pub(crate) fn legacy_power( + pool: &CardPool, + card: CardIdx, + shared_units: u8, + attr_all: bool, +) -> u32 { + let mask = pool.unit_mask_raw(card); + let lut = pool.power_lut(card); + (0..6) + .filter(|unit| mask & (1 << unit) != 0) + .map(|unit| { + let profile = ((lut >> (16 + unit)) & 1) as usize; + let key = usize::from(shared_units & (1 << unit) != 0) * 2 + usize::from(attr_all); + crate::search::evaluate::decode_u18(pool.power_values(card), lut, profile * 4 + key) + }) + .max() + .unwrap_or(0) +} diff --git a/src/search.rs b/src/search.rs index 368567d..21e8b37 100644 --- a/src/search.rs +++ b/src/search.rs @@ -31,6 +31,7 @@ pub mod dominance; /// 叶子求值:把一副确定的队伍算成分数。 pub mod evaluate; mod log_linear; +mod membership; mod objective; pub mod solver; mod tracker; @@ -131,6 +132,10 @@ fn search_with_budget( return (Vec::new(), SearchStats::default()); } + if ctx.uses_member_constraints() { + return membership::search(pool, ctx, params, budget); + } + let problem = problem::DeckProblem::from_context(ctx); // 挑战 live 的队伍必须五张同角色,该约束对所有 target 生效,必须先于 diff --git a/src/search/composition.rs b/src/search/composition.rs index e354d93..93831fb 100644 --- a/src/search/composition.rs +++ b/src/search/composition.rs @@ -1,11 +1,11 @@ //! Area-item composition regimes. //! -//! A card's resolved power depends on two deck-wide facts: whether all five -//! cards contain some unit, and whether all five share one attribute. The pool -//! stores, per card and per unit profile, the four resolved powers for the -//! member keys {neither, shared attribute, shared unit, both}. Their per-card -//! maximum assumes both deck-wide bonuses at once, which overstates the power -//! of every deck that shares neither. +//! Legacy card power depends on shared original-or-support membership and +//! shared attribute. An owned multi-unit effect adds a separate activation +//! dimension and an optional eight-state original/support/attribute table. +//! Each membership/attribute regime is paired with every enabled activation. +//! Per-card ceilings cover the reachable keys, without assuming all-match or +//! multi-unit effects increase power. //! //! Instead the feasible decks are covered by regimes. For a deck `D` let //! `A(D)` be the attribute shared by all five cards (if any) and `U(D)` the set @@ -108,6 +108,33 @@ impl Regime { /// (bit `k` = member key `k`, i.e. `shared_unit * 2 + shared_attr`) and over /// every unit profile of the card. pub(super) fn power_over_keys(pool: &CardPool, card: CardIdx, keys: u8) -> u32 { + let legacy = legacy_power_over_keys(pool, card, keys); + let multi = multi_power_over_keys(pool, card, keys); + match pool.multi_unit_bonus_mode() { + crate::power::MultiUnitBonusMode::ForceOff => legacy, + crate::power::MultiUnitBonusMode::ForceOn if pool.has_multi_power() => multi, + _ => legacy.max(multi), + } +} + +fn multi_power_over_keys(pool: &CardPool, card: CardIdx, keys: u8) -> u32 { + let Some(values) = pool.multi_power_values(card) else { + return 0; + }; + values + .iter() + .enumerate() + .filter(|(state, _)| { + let attr = state & 1; + let unit = usize::from(state & 6 != 0) * 2; + keys & (1 << (unit + attr)) != 0 + }) + .map(|(_, &value)| value) + .max() + .unwrap_or(0) +} + +fn legacy_power_over_keys(pool: &CardPool, card: CardIdx, keys: u8) -> u32 { let values = pool.power_values(card); let lut = pool.power_lut(card); let units = pool.unit_mask_raw(card); @@ -147,6 +174,17 @@ impl RegimePlan { objective: &ObjectiveBound, regime: Regime, order: usize, + ) -> Option { + Self::new_for_activation(pool, ctx, objective, regime, order, None) + } + + fn new_for_activation( + pool: &CardPool, + ctx: &SearchContext, + objective: &ObjectiveBound, + regime: Regime, + order: usize, + multi: Option, ) -> Option { let keys = regime.member_keys(); let mut keep = vec![false; pool.count()]; @@ -166,7 +204,11 @@ impl RegimePlan { continue; } let character = usize::from(pool.char_id(card)); - let power = power_over_keys(pool, card, keys); + let power = match multi { + Some(true) => multi_power_over_keys(pool, card, keys), + Some(false) => legacy_power_over_keys(pool, card, keys), + None => power_over_keys(pool, card, keys), + }; keep[card.raw()] = true; power_bound[card.raw()] = power; best_power[character] = best_power[character].max(power); @@ -232,7 +274,10 @@ impl RegimePlan { .map(|count| u32::from(ctx.diff_attr_bonus[count])) .max() .unwrap_or(0); - (diversity, support_bonus_ceiling(ctx)) + ( + diversity + ctx.shuffle_bonus_upper(), + support_bonus_ceiling(ctx), + ) } else { (ctx.extra_bonus_ub, 0) }; @@ -408,10 +453,31 @@ pub(super) fn search_regimes( ) -> (Vec, SearchStats), ) -> (Vec, SearchStats) { let objective = ObjectiveBound::from_context(ctx); - let mut plans = Regime::all() - .enumerate() - .filter_map(|(order, regime)| RegimePlan::new(pool, ctx, &objective, regime, order)) - .collect::>(); + let mut plans = Vec::new(); + for (order, regime) in Regime::all().enumerate() { + if pool.has_multi_power() { + for multi in [false, true] { + use crate::power::MultiUnitBonusMode; + if (multi && pool.multi_unit_bonus_mode() == MultiUnitBonusMode::ForceOff) + || (!multi && pool.multi_unit_bonus_mode() == MultiUnitBonusMode::ForceOn) + { + continue; + } + if let Some(plan) = RegimePlan::new_for_activation( + pool, + ctx, + &objective, + regime, + order * 2 + usize::from(multi), + Some(multi), + ) { + plans.push(plan); + } + } + } else if let Some(plan) = RegimePlan::new(pool, ctx, &objective, regime, order) { + plans.push(plan); + } + } plans.sort_unstable_by(|left, right| { right .ceiling diff --git a/src/search/context.rs b/src/search/context.rs index aa607aa..9844e41 100644 --- a/src/search/context.rs +++ b/src/search/context.rs @@ -35,6 +35,8 @@ pub struct SearchContext { pub fixed_card_ids: Vec, /// 必须入队的角色 ID,占据 `fixed_card_ids` 之后的槽位。 pub fixed_character_ids: Vec, + /// Fixed input slots by default; final-chapter members mode constrains participation. + pub fixed_constraint_mode: crate::handler::FixedConstraintMode, /// 必须占据队长位的角色。挑战 live 五张同角色,该约束无意义,建池时置 `None`。 pub forced_leader_character_id: Option, /// 歌曲活动倍率,扩大 100 倍。 @@ -63,6 +65,10 @@ pub struct SearchContext { pub is_world_bloom: bool, /// 当前是否适用终章规则(队长限定加成、独立的支援卡组与综合力上限)。 pub is_final_chapter: bool, + /// Whether the event is a third World Bloom finale; rates come from the table below. + pub is_wl3_finale: bool, + /// Original-unit bonus indexed by distinct original units in the main deck. + pub shuffle_unit_bonus: [u16; 6], /// challenge 模式下不要求角色唯一(pool 已过滤为同角色卡) pub enforce_char_uniqueness: bool, /// 反向搜索:求最弱(最小化 power)而非最强。仅 Power 目标生效,其它目标忽略。 @@ -94,7 +100,7 @@ pub struct SearchContext { pub w_bonus: f64, /// 池内技能值前五之和,即整副队伍技能加成的上界。 pub skill_ub_global: u32, - /// 享受 limited bonus 的最大张数;终章为 4,其余通常为 [`DECK_SIZE`]。 + /// 享受 limited bonus 的最大张数;WL2 终章缺省为 4,WL3 为 5,主表行优先。 pub card_bonus_count_limit: usize, /// 称号带来的综合力加成,作为固定项计入每副队伍。 pub honor_bonus: u32, @@ -114,6 +120,50 @@ pub struct SearchContext { } impl SearchContext { + /// Whether fixed items constrain membership instead of input positions. + pub fn uses_member_constraints(&self) -> bool { + self.fixed_constraint_mode == crate::handler::FixedConstraintMode::Members + } + + /// Number of leading input roles reserved by the public slot contract. + pub fn fixed_prefix_len(&self) -> usize { + if self.uses_member_constraints() { + 0 + } else { + (self.fixed_card_ids.len() + self.fixed_character_ids.len()).min(DECK_SIZE) + } + } + + /// Mandatory members are checked before any candidate becomes an incumbent. + pub(crate) fn deck_matches_members( + &self, + pool: &crate::pool::CardPool, + deck: &[crate::pool::CardIdx; DECK_SIZE], + ) -> bool { + self.fixed_card_ids + .iter() + .all(|&id| deck.iter().any(|&card| pool.game_id(card) == id)) + && self + .fixed_character_ids + .iter() + .all(|&id| deck.iter().any(|&card| pool.char_id(card) == id)) + } + + /// Admissible original-unit shuffle ceiling, independent of attribute diversity. + pub fn shuffle_bonus_upper(&self) -> u32 { + self.shuffle_unit_bonus.iter().copied().max().unwrap_or(0) as u32 + } + + /// Resolve the event's shuffle bonus; a supported Virtual Singer still counts as piapro. + pub fn shuffle_bonus_for( + &self, + pool: &crate::pool::CardPool, + deck: &[crate::pool::CardIdx; DECK_SIZE], + ) -> u32 { + crate::power::DeckComposition::from_cards(pool, deck) + .shuffle_bonus(&self.shuffle_unit_bonus) + } + /// 返回按 `keep` 位图压缩后的搜索上下文。 pub fn remap(&self, keep: &[bool]) -> Self { assert_eq!( @@ -168,7 +218,7 @@ impl SearchContext { self.best_skill_as_leader && !self.is_final_chapter && self.forced_leader_character_id.is_none() - && self.fixed_character_ids.is_empty() + && (self.uses_member_constraints() || self.fixed_character_ids.is_empty()) } /// 卡组是否满足指定队长约束(队里必须有该角色的卡)。 @@ -213,7 +263,8 @@ impl SearchContext { /// 当前是否存在固定 leader 约束。 #[inline(always)] pub fn has_fixed_leader(&self) -> bool { - self.forced_leader_character_id.is_some() || !self.fixed_character_ids.is_empty() + self.forced_leader_character_id.is_some() + || (!self.uses_member_constraints() && !self.fixed_character_ids.is_empty()) } /// 返回终章生效的固定队长角色。 @@ -226,12 +277,17 @@ impl SearchContext { /// 读取指定槽位固定卡 ID。 #[inline(always)] pub fn fixed_card_at(&self, slot: usize) -> Option { - self.fixed_card_ids.get(slot).copied() + (!self.uses_member_constraints()) + .then(|| self.fixed_card_ids.get(slot).copied()) + .flatten() } /// 读取指定槽位固定角色 ID。 #[inline(always)] pub fn fixed_character_at(&self, slot: usize) -> Option { + if self.uses_member_constraints() { + return None; + } let index = slot.checked_sub(self.fixed_card_ids.len())?; self.fixed_character_ids.get(index).copied() } @@ -264,7 +320,11 @@ impl SearchContext { pool: &crate::pool::CardPool, deck: &[crate::pool::CardIdx; DECK_SIZE], ) -> bool { - let constrained_prefix = (self.fixed_card_ids.len() + self.fixed_character_ids.len()) + if self.uses_member_constraints() && !self.deck_matches_members(pool, deck) { + return false; + } + let constrained_prefix = self + .fixed_prefix_len() .max(usize::from( self.is_final_chapter && self.forced_leader_character_id.is_some(), )) diff --git a/src/search/dfs.rs b/src/search/dfs.rs index 9de5ad7..fe0047b 100644 --- a/src/search/dfs.rs +++ b/src/search/dfs.rs @@ -316,7 +316,8 @@ pub(super) fn canonicalize_seed_result( let source = seed.cards; let mut used_source = [false; DECK_SIZE]; let mut deck = [source[0]; DECK_SIZE]; - let fixed_slots = (ctx.fixed_card_ids.len() + ctx.fixed_character_ids.len()) + let fixed_slots = ctx + .fixed_prefix_len() .max(usize::from(ctx.is_final_chapter)) .min(DECK_SIZE); @@ -438,6 +439,17 @@ impl SearchState<'_> { if self.timed_out() { return; } + if self.ctx.uses_member_constraints() + && !super::membership::prefix_can_complete( + self.pool, + self.ctx, + &deck[..depth], + (start..self.pool.count()).map(|dense| CardIdx::new(dense as u16)), + ) + { + self.stats.feasibility_prunes += 1; + return; + } if depth == DECK_SIZE { self.stats.leaf_nodes += 1; if self.leaf_below_threshold(deck, &partial) { diff --git a/src/search/dominance.rs b/src/search/dominance.rs index c9f1f58..20eb7bf 100644 --- a/src/search/dominance.rs +++ b/src/search/dominance.rs @@ -358,6 +358,14 @@ fn dominates( idx += 1; } + if pool.multi_unit_bonus_mode() != crate::power::MultiUnitBonusMode::ForceOff + && let (Some(left), Some(right)) = + (pool.multi_power_values(lhs), pool.multi_power_values(rhs)) + && left.iter().zip(right).any(|(a, b)| a < b) + { + return false; + } + if !skill_dominates(pool, lhs, rhs) { return false; } diff --git a/src/search/evaluate.rs b/src/search/evaluate.rs index df32116..d7dfb17 100644 --- a/src/search/evaluate.rs +++ b/src/search/evaluate.rs @@ -79,7 +79,9 @@ pub(crate) fn leaf_evaluate_checked( ctx: &SearchContext, deck: &[CardIdx; 5], ) -> Option { - if !ctx.deck_matches_forced_leader(pool, deck) { + if !ctx.deck_matches_forced_leader(pool, deck) + || (ctx.uses_member_constraints() && !ctx.deck_matches_slots(pool, deck)) + { return None; } let power_total = || ctx.clamp_power_total(resolve_power_target(pool, deck) + ctx.honor_bonus); @@ -203,6 +205,7 @@ fn build_summary( event_point, multi_live_score_up: permutation.multi_live_score_up, event_bonus_total: (ctx.has_event() || total_bonus > 0.0).then_some(total_bonus), + shuffle_bonus_rate: ctx.shuffle_bonus_for(pool, deck), main_honor_id: ctx .leader_honor_for_character(pool.char_id(ordered_cards[0])) .map(|honor| honor.honor_id), @@ -210,40 +213,10 @@ fn build_summary( } fn resolve_card_power_totals(pool: &CardPool, deck: &[CardIdx; 5]) -> [i32; 5] { - let mut attr_counts = [0u8; 6]; - let mut unit_counts = [0u8; 6]; - let mut pos = 0usize; - while pos < DECK_SIZE { - let card = unsafe { *deck.get_unchecked(pos) }; - let attr = pool.attr(card) as usize; - debug_assert!(attr < attr_counts.len()); - unsafe { - *attr_counts.get_unchecked_mut(attr) += 1; - } - let unit_mask = pool.unit_mask_raw(card); - let mut unit = 0usize; - while unit < 6 { - if unit_mask & (1u8 << unit) != 0 { - unsafe { - *unit_counts.get_unchecked_mut(unit) += 1; - } - } - unit += 1; - } - pos += 1; - } - - let mut totals = [0; DECK_SIZE]; - pos = 0; - while pos < DECK_SIZE { - let card = unsafe { *deck.get_unchecked(pos) }; - let attr = pool.attr(card) as usize; - let attr_member = unsafe { *attr_counts.get_unchecked(attr) }; - totals[pos] = - resolve_card_power(pool, card, &unit_counts, attr_member).min(i32::MAX as u32) as i32; - pos += 1; - } - totals + let composition = crate::power::DeckComposition::from_cards(pool, deck); + std::array::from_fn(|pos| { + crate::power::effective_power(pool, deck[pos], composition).min(i32::MAX as u32) as i32 + }) } #[inline(always)] @@ -263,39 +236,10 @@ fn card_event_bonus_for_display( #[inline(always)] pub(crate) fn resolve_power_target(pool: &CardPool, deck: &[CardIdx; 5]) -> u32 { - let mut attr_counts = [0u8; 6]; - let mut unit_counts = [0u8; 6]; - let mut pos = 0usize; - while pos < DECK_SIZE { - let card = unsafe { *deck.get_unchecked(pos) }; - let attr = pool.attr(card) as usize; - debug_assert!(attr < attr_counts.len()); - unsafe { - *attr_counts.get_unchecked_mut(attr) += 1; - } - let unit_mask = pool.unit_mask_raw(card); - let mut unit = 0usize; - while unit < 6 { - if unit_mask & (1u8 << unit) != 0 { - unsafe { - *unit_counts.get_unchecked_mut(unit) += 1; - } - } - unit += 1; - } - pos += 1; - } - - let mut total = 0u32; - pos = 0; - while pos < DECK_SIZE { - let card = unsafe { *deck.get_unchecked(pos) }; - let attr = pool.attr(card) as usize; - let attr_member = unsafe { *attr_counts.get_unchecked(attr) }; - total += resolve_card_power(pool, card, &unit_counts, attr_member); - pos += 1; - } - total + let composition = crate::power::DeckComposition::from_cards(pool, deck); + deck.iter() + .map(|&card| crate::power::effective_power(pool, card, composition)) + .sum() } /// Resolve total card power for a partial or complete fixed deck. @@ -303,28 +247,9 @@ pub(crate) fn resolve_power_target(pool: &CardPool, deck: &[CardIdx; 5]) -> u32 /// This helper is used by auxiliary calculations only. The fixed-size DFS /// evaluator above remains unchanged. pub fn resolve_power_for_cards(pool: &CardPool, deck: &[CardIdx]) -> u32 { - let mut attr_counts = [0u8; 6]; - let mut unit_counts = [0u8; 6]; - for &card in deck { - let attr = pool.attr(card) as usize; - if attr < attr_counts.len() { - attr_counts[attr] = attr_counts[attr].saturating_add(1); - } - let unit_mask = pool.unit_mask_raw(card); - for (unit, count) in unit_counts.iter_mut().enumerate() { - if unit_mask & (1u8 << unit) != 0 { - *count = count.saturating_add(1); - } - } - } + let composition = crate::power::DeckComposition::from_cards(pool, deck); deck.iter().fold(0u32, |total, &card| { - let attr = pool.attr(card) as usize; - total.saturating_add(resolve_card_power( - pool, - card, - &unit_counts, - attr_counts.get(attr).copied().unwrap_or(0), - )) + total.saturating_add(crate::power::effective_power(pool, card, composition)) }) } @@ -499,6 +424,7 @@ pub(crate) fn resolve_total_bonus( if ctx.is_world_bloom { total += ctx.diff_attr_bonus[attr_set.count_ones() as usize] as f64; + total += f64::from(ctx.shuffle_bonus_for(pool, deck)); total += calc_support_bonus(pool, ctx, deck, &game_ids); } total @@ -610,7 +536,7 @@ fn evaluate_permutation( } // A fully fixed lineup defines every skill slot, not just its leader. // Keep those positions when automatic leader selection is disabled. - if ctx.fixed_card_ids.len() != DECK_SIZE { + if ctx.uses_member_constraints() || ctx.fixed_card_ids.len() != DECK_SIZE { sort_tail_by_card_raw(pool, &mut order, deck); } } @@ -896,62 +822,6 @@ fn different_unit_count(pool: &CardPool, deck: &[CardIdx; 5], index: usize) -> u others.count_ones() } -#[inline(always)] -fn resolve_card_power( - pool: &CardPool, - card: CardIdx, - unit_counts: &[u8; 6], - attr_member: u8, -) -> u32 { - let unit_mask = pool.unit_mask_raw(card); - let lut = pool.power_lut(card); - let values = pool.power_values(card); - let mut best = 0u32; - let mut unit = 0usize; - while unit < 6 { - if unit_mask & (1u8 << unit) != 0 { - let slot = ((lut >> (16 + unit)) & 1) as usize; - let unit_member = unsafe { *unit_counts.get_unchecked(unit) }; - let key = member_key(unit_member, attr_member); - let idx = slot * 4 + key; - let value = decode_u18(values, lut, idx); - if value > best { - best = value; - } - } - unit += 1; - } - best -} - -/// Resolve one card's additive power inside a fixed all-unit/all-attribute scenario. -/// `unit_all` and `attr_all` describe deck-wide conditions, so callers can optimize -/// power exactly without enumerating every five-card combination. -pub(crate) fn resolve_card_power_scenario( - pool: &CardPool, - card: CardIdx, - unit_all: Option, - attr_all: bool, -) -> u32 { - let mut unit_counts = [0u8; 6]; - if let Some(unit) = unit_all.filter(|unit| *unit < unit_counts.len()) { - unit_counts[unit] = DECK_SIZE as u8; - } - resolve_card_power( - pool, - card, - &unit_counts, - if attr_all { DECK_SIZE as u8 } else { 0 }, - ) -} - -#[inline(always)] -fn member_key(unit_member: u8, attr_member: u8) -> usize { - let unit_all = (unit_member == DECK_SIZE as u8) as usize; - let attr_all = (attr_member == DECK_SIZE as u8) as usize; - unit_all * 2 + attr_all -} - #[inline(always)] fn resolve_unit_count_skill( table: &[UnitCountSkill], @@ -1109,6 +979,7 @@ mod tests { SearchContext { target: ScoreTarget::Score, fixed_card_ids: Vec::new(), + fixed_constraint_mode: crate::handler::FixedConstraintMode::Slots, fixed_character_ids: Vec::new(), forced_leader_character_id: None, music_rate_pct: 100, @@ -1124,6 +995,8 @@ mod tests { support_decks_by_character: Vec::new(), is_world_bloom: false, is_final_chapter: false, + is_wl3_finale: false, + shuffle_unit_bonus: [0; 6], enforce_char_uniqueness: true, minimize: false, live_type, diff --git a/src/search/log_linear.rs b/src/search/log_linear.rs index 5afc433..bd5349a 100644 --- a/src/search/log_linear.rs +++ b/src/search/log_linear.rs @@ -443,6 +443,7 @@ mod tests { SearchContext { target: ScoreTarget::Score, fixed_card_ids: Vec::new(), + fixed_constraint_mode: crate::handler::FixedConstraintMode::Slots, fixed_character_ids: Vec::new(), forced_leader_character_id: None, music_rate_pct: 120, @@ -462,6 +463,8 @@ mod tests { support_decks_by_character: vec![SupportDeck::default(); 27], is_world_bloom: true, is_final_chapter: true, + is_wl3_finale: false, + shuffle_unit_bonus: [0; 6], enforce_char_uniqueness: true, minimize: false, live_type, diff --git a/src/search/membership.rs b/src/search/membership.rs new file mode 100644 index 0000000..cc46124 --- /dev/null +++ b/src/search/membership.rs @@ -0,0 +1,110 @@ +//! Exact final-chapter search with mandatory members and free input roles. +//! +//! A single pool, budget and canonical tracker cover every leader. The generic +//! frontier enforces required membership at each prefix; candidate evaluation +//! solves the remaining observable placement before inserting it into Top-K. +//! Slot-dependent dominance and grouped fixed-prefix solvers are not reused. + +use crate::pool::{CardIdx, CardPool}; +use crate::types::{DECK_SIZE, ScoreTarget}; + +use super::budget::SearchBudget; +use super::{DeckResult, SearchContext, SearchParams, SearchStats, SuffixBound}; + +pub(super) fn search( + pool: &CardPool, + ctx: &SearchContext, + params: &SearchParams, + budget: &mut SearchBudget, +) -> (Vec, SearchStats) { + assert!( + ctx.is_final_chapter, + "members mode requires a final chapter" + ); + // With no pins, or only the chosen leader, keep the existing grouped search. + if ctx.fixed_card_ids.is_empty() + && (ctx.fixed_character_ids.is_empty() + || ctx.fixed_character_ids.as_slice() == ctx.forced_leader_character_id.as_slice()) + { + let mut slots = ctx.clone(); + slots.fixed_constraint_mode = crate::handler::FixedConstraintMode::Slots; + return super::search_with_budget(pool, &slots, params, budget); + } + if matches!(ctx.target, ScoreTarget::Power | ScoreTarget::Skill) { + return super::solver::numeric::search_simple_target(pool, ctx, params, budget); + } + let mut context = ctx.clone(); + context.final_chapter_member_keep.fill(true); + super::composition::search_regimes( + pool, + &context, + params, + budget, + super::tuning::SearchTuning::load().bounds, + |_budget, _stats| Vec::new(), + |pool, ctx, floor, _seeds, budget| { + let suffix = SuffixBound::build(pool, ctx); + super::dfs::dfs_search_with_budget( + pool, + ctx, + &suffix, + params, + Vec::new(), + floor, + budget, + ) + }, + ) +} + +/// A prefix can only continue when each missing required member remains in the +/// frontier and the missing members fit in the remaining slots. Availability +/// ignores interactions between future picks, making rejection conservative. +pub(super) fn prefix_can_complete( + pool: &CardPool, + ctx: &SearchContext, + prefix: &[CardIdx], + available: impl Iterator + Clone, +) -> bool { + let legal = |card: CardIdx| { + prefix.iter().all(|&other| { + pool.game_id(other) != pool.game_id(card) + && (!ctx.enforce_char_uniqueness || pool.char_id(other) != pool.char_id(card)) + }) + }; + let mut missing = 0usize; + let mut required_characters = 0u32; + for &id in &ctx.fixed_card_ids { + if prefix.iter().any(|&card| pool.game_id(card) == id) { + continue; + } + let Some(card) = available + .clone() + .find(|&card| pool.game_id(card) == id && legal(card)) + else { + return false; + }; + required_characters |= 1u32 << pool.char_id(card); + missing += 1; + } + for &id in ctx + .fixed_character_ids + .iter() + .chain(ctx.forced_leader_character_id.iter()) + { + if prefix.iter().any(|&card| pool.char_id(card) == id) + || required_characters & (1u32 << id) != 0 + { + continue; + } + if !available + .clone() + .any(|card| pool.char_id(card) == id && legal(card)) + { + return false; + } + required_characters |= 1u32 << id; + missing += 1; + } + missing <= DECK_SIZE - prefix.len() +} diff --git a/src/search/problem.rs b/src/search/problem.rs index dfed324..c9dda8e 100644 --- a/src/search/problem.rs +++ b/src/search/problem.rs @@ -48,7 +48,7 @@ impl DeckProblem { } else { SolverFamily::UniqueCombinations }; - let fixed_prefix = (ctx.fixed_card_ids.len() + ctx.fixed_character_ids.len()).min(5); + let fixed_prefix = ctx.fixed_prefix_len(); let solver_leader = ctx.is_final_chapter && family != SolverFamily::NumericObjective; let move_forced_leader = ctx.is_final_chapter && family == SolverFamily::NumericObjective diff --git a/src/search/solver/bonus_tiers.rs b/src/search/solver/bonus_tiers.rs index a50ef62..b12c2d6 100644 --- a/src/search/solver/bonus_tiers.rs +++ b/src/search/solver/bonus_tiers.rs @@ -1222,10 +1222,14 @@ impl<'a> Problem<'a> { let counts = if shares_attr { 1..=1 } else { 2..=DECK_SIZE }; let mut classes: Vec<(i64, u8)> = Vec::new(); for count in counts { - let value = i64::from(extras.diversity[count]) * 10 * self.scale; - match classes.iter_mut().find(|class| class.0 == value) { - Some(class) => class.1 |= 1 << count, - None => classes.push((value, 1 << count)), + let shuffle = &self.ctx.shuffle_unit_bonus; + for &shuffle in shuffle { + let value = + (i64::from(extras.diversity[count]) + i64::from(shuffle)) * 10 * self.scale; + match classes.iter_mut().find(|class| class.0 == value) { + Some(class) => class.1 |= 1 << count, + None => classes.push((value, 1 << count)), + } } } classes @@ -2325,8 +2329,11 @@ impl TierCertificate { most, ); let scale = problem.scale; - let diversity = (1..=DECK_SIZE) - .map(|count| i64::from(extras.diversity[count]) * 10 * scale) + let diversity = problem + .diversity_classes(false) + .into_iter() + .chain(problem.diversity_classes(true)) + .map(|(bonus, _)| bonus) .collect::>(); let mut reached = std::collections::BTreeSet::new(); let mut subsets = vec![(Vec::new(), 0usize)]; diff --git a/src/search/solver/final_chapter.rs b/src/search/solver/final_chapter.rs index 0e1aca7..5c47038 100644 --- a/src/search/solver/final_chapter.rs +++ b/src/search/solver/final_chapter.rs @@ -119,6 +119,7 @@ struct LeaderConst { limited_count: u8, extra_bonus_ub: u32, support_bonus_ub: u32, + shuffle_bonus_ub: u32, leader_attr_set: u8, use_group_attr_dp: bool, } @@ -258,7 +259,7 @@ impl LeaderRange { skill_max: leader.skill, bonus: leader.base_bonus_const + leader.limited_bonus, extra: if leader.use_group_attr_dp { - u32::from(diversity) + leader.support_bonus_ub + u32::from(diversity) + leader.support_bonus_ub + leader.shuffle_bonus_ub } else { leader.extra_bonus_ub }, @@ -1244,6 +1245,7 @@ fn build_leader_const(pool: &CardPool, ctx: &SearchContext, leader: CardIdx) -> limited_count, extra_bonus_ub: final_chapter_extra_bonus_bound(pool, ctx, leader, &[], MEMBER_COUNT), support_bonus_ub: final_chapter_support_bonus_bound_for_leader(pool, ctx, leader), + shuffle_bonus_ub: ctx.shuffle_bonus_upper(), leader_attr_set: 1u8 << pool.attr(leader), use_group_attr_dp: ctx.is_world_bloom && crate::search::tuning::SearchTuning::load().final_attr_dp, @@ -1912,7 +1914,7 @@ impl CharacterSearchState<'_> { }; let bound = &weights.bound; let extra = if self.ctx.is_world_bloom { - plan.diversity_bonus + partial.support_bonus_ceil + plan.diversity_bonus + partial.support_bonus_ceil + self.ctx.shuffle_bonus_upper() } else { self.ctx.extra_bonus_ub }; @@ -2085,6 +2087,7 @@ fn last_group_inputs( let extra = if leader.use_group_attr_dp { u32::from(diversity[usize::from(prefix.attr_set | (1u8 << terms.attr))]) + leader.support_bonus_ub + + leader.shuffle_bonus_ub } else { leader.extra_bonus_ub }; @@ -2113,6 +2116,7 @@ fn extra_bonus_ceiling( if leader.use_group_attr_dp { u32::from(tail.attr_bonus[remaining][usize::from(prefix.attr_set)]) + leader.support_bonus_ub + + leader.shuffle_bonus_ub } else { leader.extra_bonus_ub } @@ -2175,7 +2179,7 @@ fn selected_card_inputs( let bonus_sum = partial.base_bonus + base_bonus + plan.rem_base_bonus[chosen]; let limited_sum = plan.limited_sum(partial, chosen, ctx.card_bonus_count_limit, limited_bonus); let extra_bonus_ub = if ctx.is_world_bloom { - plan.diversity_bonus + partial.support_bonus_ceil + plan.diversity_bonus + partial.support_bonus_ceil + ctx.shuffle_bonus_upper() } else { ctx.extra_bonus_ub }; @@ -2257,7 +2261,9 @@ fn final_chapter_extra_bonus_bound( } let support = ctx.support_deck_for_leader(pool.char_id(leader)); - diff_ub + remaining_support(support, &selected, selected_len).0.ceil() as u32 + diff_ub + + remaining_support(support, &selected, selected_len).0.ceil() as u32 + + ctx.shuffle_bonus_upper() } /// Sum of the first `count` support entries whose game ids are not selected, @@ -2481,6 +2487,7 @@ mod skill_ceiling_tests { let ctx = SearchContext { target: ScoreTarget::Score, fixed_card_ids: Vec::new(), + fixed_constraint_mode: crate::handler::FixedConstraintMode::Slots, fixed_character_ids: Vec::new(), forced_leader_character_id: Some(0), music_rate_pct: 100, @@ -2496,6 +2503,8 @@ mod skill_ceiling_tests { support_decks_by_character: vec![SupportDeck::default(); 27], is_world_bloom: true, is_final_chapter: true, + is_wl3_finale: false, + shuffle_unit_bonus: [0; 6], enforce_char_uniqueness: true, minimize: false, live_type: LiveType::Solo, @@ -2689,6 +2698,7 @@ mod attribute_bound_tests { limited_count: 0, extra_bonus_ub: 0, support_bonus_ub: 0, + shuffle_bonus_ub: 0, leader_attr_set: 1u8 << leader_attr, use_group_attr_dp: true, }; @@ -2836,6 +2846,7 @@ mod attribute_bound_tests { limited_count: 1, extra_bonus_ub: 0, support_bonus_ub: 9, + shuffle_bonus_ub: 0, leader_attr_set: 1u8 << leader_attr, use_group_attr_dp: true, }; diff --git a/src/search/solver/numeric.rs b/src/search/solver/numeric.rs index cae9abe..df860b9 100644 --- a/src/search/solver/numeric.rs +++ b/src/search/solver/numeric.rs @@ -5,9 +5,7 @@ use crate::search::DeckResult; use crate::search::budget::SearchBudget; use crate::search::skill_ceiling::{Composition, SkillCeiling}; use crate::search::small_ids::SmallestIds; -use crate::search::{ - SearchContext, SearchParams, SearchStats, TopKTracker, evaluate, placement, tuning, -}; +use crate::search::{SearchContext, SearchParams, SearchStats, TopKTracker, placement, tuning}; use crate::types::{DECK_SIZE, ScoreTarget}; pub(crate) fn search_simple_target( @@ -105,14 +103,7 @@ impl<'a> SimpleExactState<'a> { let mut cards = pool.indices().collect::>(); let card_power_min = if matches!(ctx.target, ScoreTarget::Power) { pool.indices() - .map(|card| { - let values = pool.power_values(card); - let lut = pool.power_lut(card); - (0..8) - .map(|idx| evaluate::decode_u18(values, lut, idx)) - .min() - .unwrap_or(0) - }) + .map(|card| pool.power_min(card)) .collect::>() } else { Vec::new() @@ -153,7 +144,7 @@ impl<'a> SimpleExactState<'a> { cards, card_power_min, minimize, - fixed_prefix: (ctx.fixed_card_ids.len() + ctx.fixed_character_ids.len()).min(DECK_SIZE), + fixed_prefix: ctx.fixed_prefix_len(), skill_ceilings, suffix_small_ids, global_power_max, @@ -435,6 +426,17 @@ impl SimpleExactState<'_> { if self.timed_out() { return; } + if self.ctx.uses_member_constraints() + && !crate::search::membership::prefix_can_complete( + self.pool, + self.ctx, + &deck[..depth], + self.cards[min_free_pos..].iter().copied(), + ) + { + self.stats.feasibility_prunes += 1; + return; + } if depth == DECK_SIZE { self.stats.leaf_nodes += 1; if let Some(candidate) = placement::evaluate_candidate(self.pool, self.ctx, deck) { diff --git a/src/search/solver/power.rs b/src/search/solver/power.rs index 6827722..6031a17 100644 --- a/src/search/solver/power.rs +++ b/src/search/solver/power.rs @@ -1,16 +1,14 @@ //! Exact Top-K of the unconstrained maximizing Power target. //! -//! A card's resolved power depends on the rest of the deck only through two -//! deck-wide facts: the set of units carried by all five members and whether -//! all five share one attribute. A scenario fixes both facts; inside it every -//! card has one additive power ceiling, and a branch and bound over +//! A card's resolved power depends on shared original-or-support membership, +//! shared attribute, and optional multi-unit activation. A scenario fixes the +//! shared facts and bounds both enabled activation values; every card then +//! has one additive power ceiling, and a branch and bound over //! character-distinct decks runs against the one canonical tracker. use crate::pool::{CardIdx, CardPool}; use crate::search::budget::SearchBudget; use crate::search::small_ids::SmallestIds; -use crate::search::{ - DeckResult, SearchContext, SearchParams, SearchStats, TopKTracker, evaluate, placement, -}; +use crate::search::{DeckResult, SearchContext, SearchParams, SearchStats, TopKTracker, placement}; use crate::types::DECK_SIZE; /// Unit bits the power evaluator reads from a unit mask. @@ -106,14 +104,22 @@ fn unit_sets(pool: &CardPool) -> Vec { /// carry exactly the units of `units` in common and share an attribute iff /// `attr_all`; exact when `units` has at most one unit. fn scenario_power(pool: &CardPool, card: CardIdx, units: u8, attr_all: bool) -> u32 { - if units == 0 { - return evaluate::resolve_card_power_scenario(pool, card, None, attr_all); - } - (0..usize::from(UNIT_BITS)) - .filter(|&unit| units & (1 << unit) != 0) - .map(|unit| evaluate::resolve_card_power_scenario(pool, card, Some(unit), attr_all)) - .max() - .unwrap_or(0) + let composition = crate::power::DeckComposition { + shared_units: units, + shared_attribute: attr_all, + is_multi_unit: false, + original_unit_mask: 0, + }; + let legacy = crate::power::effective_power(pool, card, composition); + let multi = crate::power::effective_power( + pool, + card, + crate::power::DeckComposition { + is_multi_unit: true, + ..composition + }, + ); + legacy.max(multi) } /// Every scenario that admits at least five characters. A scenario with unit diff --git a/src/search/suffix.rs b/src/search/suffix.rs index 7219c6b..3a2cbf9 100644 --- a/src/search/suffix.rs +++ b/src/search/suffix.rs @@ -59,6 +59,7 @@ pub struct SuffixBound { is_final_chapter: bool, limited_bonus_cap: usize, extra_bonus_ub: u32, + shuffle_bonus_ub: u32, diff_attr_bonus: [u16; 6], support_cards: Vec<(u16, f64)>, support_count: usize, @@ -180,7 +181,10 @@ fn world_bloom_extra_bonus_fallback( .sum::() .ceil() .clamp(0.0, u32::MAX as f64) as u32; - ctx.extra_bonus_ub.max(diff.saturating_add(support)) + ctx.extra_bonus_ub.max( + diff.saturating_add(support) + .saturating_add(ctx.shuffle_bonus_upper()), + ) } impl SuffixBound { @@ -238,6 +242,7 @@ impl SuffixBound { limited_bonus_cap: ctx.card_bonus_count_limit, extra_bonus_ub, diff_attr_bonus: ctx.diff_attr_bonus, + shuffle_bonus_ub: ctx.shuffle_bonus_upper(), support_cards, support_count, power_order, @@ -270,8 +275,8 @@ impl SuffixBound { /// /// 场景 = (allowed, attr_opt):allowed 为仍可能全员同 unit 的 unit 集合 /// (已选卡 unit_mask 的 AND),attr_opt 为仍可能全同的属性。对每个场景, - /// 已选卡取该场景下的精确综合力,剩余槽取每角色场景最大值 top-k。 - /// 任意补全的真实 full-unit 集合是 allowed 的子集且场景值单调,故可采纳。 + /// 已选卡与剩余槽都取可行状态的逐卡最大值。真实 full-unit 集合是 + /// allowed 的子集;上界同时包含普通/全匹配与有效混编态,不假设倍率单调。 #[inline(always)] pub(crate) fn upper_bound_score_noevent_numerator( &self, @@ -900,7 +905,7 @@ impl SuffixBound { count += 1; } // Both bounds are admissible, hence so is the smaller one. - (diff_ub + support_ceiling).min(self.extra_bonus_ub) + (diff_ub + support_ceiling + self.shuffle_bonus_ub).min(self.extra_bonus_ub) } /// Maximum number of NEW attributes that any legal completion can add, @@ -1411,19 +1416,40 @@ pub(crate) fn card_scenario_power( let mask = pool.unit_mask_raw(card); let lut = pool.power_lut(card); let values = pool.power_values(card); - let mut best = 0u32; - let mut unit = 0usize; - while unit < 6 { - if mask & (1u8 << unit) != 0 { - let slot = ((lut >> (16 + unit)) & 1) as usize; - let unit_all = (allowed & (1u8 << unit) != 0) as usize; - let key = unit_all * 2 + attr_full as usize; - let value = super::evaluate::decode_u18(values, lut, slot * 4 + key); - if value > best { - best = value; + let mut best = 0; + if !pool.has_multi_power() + || pool.multi_unit_bonus_mode() != crate::power::MultiUnitBonusMode::ForceOn + { + for unit in 0..6 { + if mask & (1 << unit) == 0 { + continue; + } + let profile = ((lut >> (16 + unit)) & 1) as usize; + let key = usize::from(attr_full); + best = best.max(super::evaluate::decode_u18(values, lut, profile * 4 + key)); + if allowed & (1 << unit) != 0 { + best = best.max(super::evaluate::decode_u18( + values, + lut, + profile * 4 + 2 + key, + )); + } + } + } + if pool.multi_unit_bonus_mode() != crate::power::MultiUnitBonusMode::ForceOff + && let Some(multi) = pool.multi_power_values(card) + { + let original = crate::power::original_unit(mask); + let support = mask & !original; + for (key, &power) in multi.iter().enumerate() { + if (key & 1 != 0) != attr_full + || (key & 4 != 0 && allowed & original == 0) + || (key & 2 != 0 && allowed & support == 0) + { + continue; } + best = best.max(power); } - unit += 1; } best } @@ -1578,6 +1604,7 @@ mod support_envelope_tests { let ctx = SearchContext { target: ScoreTarget::Score, fixed_card_ids: Vec::new(), + fixed_constraint_mode: crate::handler::FixedConstraintMode::Slots, fixed_character_ids: Vec::new(), forced_leader_character_id: None, music_rate_pct: 100, @@ -1596,6 +1623,8 @@ mod support_envelope_tests { support_decks_by_character: profiles, is_world_bloom: true, is_final_chapter: true, + is_wl3_finale: false, + shuffle_unit_bonus: [0; 6], enforce_char_uniqueness: true, minimize: false, live_type: LiveType::Multi, diff --git a/src/search/tests/membership.rs b/src/search/tests/membership.rs new file mode 100644 index 0000000..49b1cdf --- /dev/null +++ b/src/search/tests/membership.rs @@ -0,0 +1,203 @@ +use super::*; +use crate::handler::FixedConstraintMode; + +fn membership_fixture() -> CardPool { + let mut cards = eight_cards_for_leader_tests(); + for (index, card) in cards.iter_mut().enumerate() { + card.char_id += 1; + card.power = 12_000 + index as u32 * 1_700; + card.power_max = card.power; + card.skill = SkillSlot { + skill_type: 0, + value: 30 + index as u8 * 7, + }; + card.skill_max = 30 + index as u8 * 7; + card.base_bonus = 15 + index as u8; + card.limited_bonus = (index as u8 % 3 + 1) * 10; + } + let mut cards = cards.to_vec(); + let mut alternative = cards[0]; + alternative.game_id = 450; + alternative.power += 9_000; + alternative.power_max = alternative.power; + cards.push(alternative); + build_pool(&cards) +} + +fn member_context(pool: &CardPool, target: ScoreTarget) -> SearchContext { + let mut ctx = ready_ctx(pool, target); + ctx.fixed_constraint_mode = FixedConstraintMode::Members; + ctx.is_final_chapter = true; + ctx.is_world_bloom = true; + ctx.event_type = Some(EventType::WorldBloom); + ctx.best_skill_as_leader = false; + ctx.card_bonus_count_limit = 3; + ctx.skill_scores = [[0.03, 0.09, 0.02, 0.13, 0.05, 0.04]; 3]; + ctx.support_decks_by_character = (0..=26) + .map(|leader| SupportDeck { + cards: vec![(200, 20.0 + f64::from(leader)), (450, 10.0)], + count: 1, + }) + .collect(); + ctx.extra_bonus_ub = 50; + ctx.leader_honor_bonus_x10 = pool + .indices() + .map(|card| u16::from(pool.char_id(card)) * 15) + .collect(); + ctx +} + +#[test] +fn final_members_all_targets_match_ordered_oracle() { + let pool = membership_fixture(); + let params = SearchParams { + top_k: 8, + timeout_ms: 0, + }; + for target in [ + ScoreTarget::Score, + ScoreTarget::Mysekai, + ScoreTarget::Bonus, + ScoreTarget::Power, + ScoreTarget::Skill, + ] { + for order in [ + LiveSkillOrder::Best, + LiveSkillOrder::Average, + LiveSkillOrder::Specific, + ] { + for forced in [None, Some(8)] { + let mut ctx = member_context(&pool, target); + ctx.live_skill_order = order; + ctx.specific_skill_order = Some([4, 1, 3, 0, 2]); + ctx.fixed_card_ids = vec![200]; + ctx.fixed_character_ids = vec![3, 5]; + ctx.forced_leader_character_id = forced; + let (expected, _) = ExactOracle::new(&pool, &ctx).search(¶ms); + let actual = search(&pool, &ctx, ¶ms); + assert_eq!(actual.completion(), SearchCompletion::Complete); + assert_eq!( + actual.results, expected, + "target={target:?} order={order:?} leader={forced:?}" + ); + assert!(!actual.results.is_empty()); + assert!( + actual + .results + .iter() + .all(|row| row.cards.iter().any(|&card| pool.game_id(card) == 200)) + ); + } + } + } +} + +#[test] +fn final_members_five_pins_leave_leader_and_input_order_free() { + let pool = membership_fixture(); + let params = SearchParams { + top_k: 8, + timeout_ms: 0, + }; + for target in [ + ScoreTarget::Score, + ScoreTarget::Bonus, + ScoreTarget::Power, + ScoreTarget::Skill, + ] { + for forced in [None, Some(5)] { + let mut ctx = member_context(&pool, target); + ctx.live_skill_order = LiveSkillOrder::Specific; + ctx.specific_skill_order = Some([4, 1, 3, 0, 2]); + ctx.fixed_card_ids = vec![200, 201, 202, 203, 204]; + ctx.forced_leader_character_id = forced; + let (expected, _) = ExactOracle::new(&pool, &ctx).search(¶ms); + let actual = search(&pool, &ctx, ¶ms); + assert_eq!(actual.completion(), SearchCompletion::Complete); + assert_eq!( + actual.results, expected, + "target={target:?} leader={forced:?}" + ); + assert_eq!(actual.results.len(), 1); + ctx.fixed_card_ids.reverse(); + assert_eq!(search(&pool, &ctx, ¶ms).results, expected); + if let Some(forced) = forced { + assert_eq!(pool.char_id(actual.results[0].cards[0]), forced); + } + } + } +} + +#[test] +fn final_members_minimum_power_and_character_pin_order_are_exact() { + let pool = membership_fixture(); + let mut ctx = member_context(&pool, ScoreTarget::Power); + ctx.minimize = true; + ctx.fixed_character_ids = vec![5, 3]; + let params = SearchParams { + top_k: 8, + timeout_ms: 0, + }; + let (expected, _) = ExactOracle::new(&pool, &ctx).search(¶ms); + let actual = search(&pool, &ctx, ¶ms); + assert_eq!(actual.completion(), SearchCompletion::Complete); + assert_eq!(actual.results, expected); + ctx.fixed_character_ids.reverse(); + assert_eq!(search(&pool, &ctx, ¶ms).results, expected); +} + +#[test] +fn final_members_share_one_expired_budget_and_never_certify_partial_search() { + let pool = membership_fixture(); + let mut ctx = member_context(&pool, ScoreTarget::Score); + ctx.fixed_card_ids = vec![200]; + let params = SearchParams { + top_k: 8, + timeout_ms: 0, + }; + let mut budget = + super::super::budget::SearchBudget::new(Some(super::super::budget::Instant::now())); + let (results, stats) = super::super::search_with_budget(&pool, &ctx, ¶ms, &mut budget); + assert!(results.is_empty()); + assert!(budget.hit); + assert_eq!(stats.completion(), SearchCompletion::TimedOut); +} + +#[test] +fn default_slots_still_reject_conflicting_final_leader() { + let pool = membership_fixture(); + let mut ctx = member_context(&pool, ScoreTarget::Score); + ctx.fixed_constraint_mode = FixedConstraintMode::Slots; + ctx.fixed_card_ids = vec![200]; + ctx.forced_leader_character_id = Some(8); + let actual = search( + &pool, + &ctx, + &SearchParams { + top_k: 3, + timeout_ms: 0, + }, + ); + assert_eq!(actual.completion(), SearchCompletion::Complete); + assert!(actual.results.is_empty()); +} + +#[test] +fn final_members_without_pins_preserve_existing_search_results() { + let pool = membership_fixture(); + let params = SearchParams { + top_k: 8, + timeout_ms: 0, + }; + for forced in [None, Some(5)] { + let mut ctx = member_context(&pool, ScoreTarget::Score); + ctx.card_bonus_count_limit = DECK_SIZE; + ctx.forced_leader_character_id = forced; + ctx.fixed_character_ids = forced.into_iter().collect(); + let members = search(&pool, &ctx, ¶ms); + ctx.fixed_constraint_mode = FixedConstraintMode::Slots; + let slots = search(&pool, &ctx, ¶ms); + assert_eq!(members.completion(), SearchCompletion::Complete); + assert_eq!(members.results, slots.results); + } +} diff --git a/src/search/tests/mod.rs b/src/search/tests/mod.rs index 7be80c9..8a22ea2 100644 --- a/src/search/tests/mod.rs +++ b/src/search/tests/mod.rs @@ -10,6 +10,8 @@ mod exact_mysekai; mod exact_power; mod exact_score; mod exact_world_bloom; +mod membership; +mod multi_unit; mod numeric_soundness; mod overflow; mod performance; @@ -109,6 +111,7 @@ fn ctx(target: ScoreTarget) -> SearchContext { SearchContext { target, fixed_card_ids: Vec::new(), + fixed_constraint_mode: crate::handler::FixedConstraintMode::Slots, fixed_character_ids: Vec::new(), forced_leader_character_id: None, music_rate_pct: 100, @@ -124,6 +127,8 @@ fn ctx(target: ScoreTarget) -> SearchContext { support_decks_by_character: Vec::new(), is_world_bloom: false, is_final_chapter: false, + is_wl3_finale: false, + shuffle_unit_bonus: [0; 6], enforce_char_uniqueness: true, minimize: false, live_type: LiveType::Solo, diff --git a/src/search/tests/multi_unit.rs b/src/search/tests/multi_unit.rs new file mode 100644 index 0000000..29958d6 --- /dev/null +++ b/src/search/tests/multi_unit.rs @@ -0,0 +1,367 @@ +use super::*; +use crate::power::{DeckComposition, MultiUnitBonusMode}; + +fn pool_with_multi(mode: MultiUnitBonusMode) -> CardPool { + pool_with_multi_shape(mode, 0) +} + +fn pool_with_multi_shape(mode: MultiUnitBonusMode, shape: u8) -> CardPool { + let masks = match shape { + 1 => [1, 1, 1, 1, 33, 33, 33, 2, 33], + 2 => [33; 9], + 3 => [32, 33, 34, 36, 40, 48, 33, 34, 32], + _ => [1, 1, 2, 4, 8, 16, 32, 33, 33], + }; + let chars = match shape { + 1 => [1, 2, 3, 4, 21, 22, 23, 9, 24], + 2 => [21, 22, 23, 24, 25, 26, 21, 22, 23], + 3 => [21; 9], + _ => [1, 2, 5, 9, 13, 17, 21, 22, 22], + }; + let mut builder = PoolBuilder::new(9); + builder.set_multi_unit_bonus_mode(mode); + for dense in 0..9u16 { + let base = 1_000 + u32::from(dense) * 27; + let (values, lut) = encode_power(base); + builder.set_power_values(dense, values); + builder.set_power_lut( + dense, + lut | if masks[usize::from(dense)] == 33 { + 1 << 21 + } else { + 0 + }, + ); + // Non-monotone ALL_MATCH states exercise bounds without assuming a + // positive all-match increment or that multi always increases power. + let mut multi = std::array::from_fn(|key| { + base + ((key * 37 + usize::from(dense) * 113) % 509) as u32 - 350 + }); + if dense == 0 { + multi[0] = 300_123; + } + builder.set_multi_power_values(dense, multi); + builder.set_power_max(dense, base.max(*multi.iter().max().unwrap())); + builder.set_char_id(dense, chars[usize::from(dense)]); + builder.set_attr(dense, if shape == 0 { (dense % 3) as u8 } else { 0 }); + builder.set_unit_mask(dense, masks[usize::from(dense)]); + builder.set_game_id(dense, 100 + dense); + builder.set_skill( + dense, + SkillSlot { + skill_type: 0, + value: 50, + }, + ); + builder.set_skill_min(dense, 50); + builder.set_skill_max(dense, 50); + builder.set_event_bonus(dense, EventBonusExact::from_whole(10, 0)); + } + builder.freeze() +} + +fn pool_with_empty_membership(mode: MultiUnitBonusMode, with_multi: bool) -> CardPool { + let mut builder = PoolBuilder::new(10); + builder.set_multi_unit_bonus_mode(mode); + for dense in 0..10u16 { + let empty = dense >= 5; + let power = if empty { 100 } else { 10 }; + let (values, lut) = encode_power(power); + builder.set_power_values(dense, values); + builder.set_power_lut(dense, lut); + if with_multi { + builder.set_multi_power_values(dense, [power * 2; 8]); + } + builder.set_power_max(dense, power * 2); + builder.set_char_id(dense, (dense % 5 + 1) as u8); + builder.set_attr(dense, (dense % 3) as u8); + builder.set_unit_mask(dense, if empty { 0 } else { 1 << (dense % 5) }); + builder.set_game_id(dense, 100 + dense); + } + builder.freeze() +} + +#[test] +fn empty_membership_power_minimum_bounds_the_effective_power() { + for mode in [ + MultiUnitBonusMode::ByDeck, + MultiUnitBonusMode::ForceOn, + MultiUnitBonusMode::ForceOff, + ] { + for with_multi in [false, true] { + let pool = pool_with_empty_membership(mode, with_multi); + let cards = std::array::from_fn::<_, 5, _>(|i| pool.card_idx(i as u16 + 5).unwrap()); + let composition = DeckComposition::from_cards(&pool, &cards); + for card in cards { + let effective = crate::power::effective_power(&pool, card, composition); + assert_eq!(pool.power_min(card), 0); + assert!(pool.power_min(card) <= effective); + } + } + } +} + +#[test] +fn empty_membership_minimization_keeps_zero_power_improvements() { + for mode in [ + MultiUnitBonusMode::ByDeck, + MultiUnitBonusMode::ForceOn, + MultiUnitBonusMode::ForceOff, + ] { + for with_multi in [false, true] { + let pool = pool_with_empty_membership(mode, with_multi); + let mut context = ready_ctx(&pool, ScoreTarget::Power); + context.minimize = true; + for top_k in [1, 7] { + let params = SearchParams { + top_k, + timeout_ms: 0, + }; + let (expected, _) = brute_force_search(&pool, &context, ¶ms); + let actual = search(&pool, &context, ¶ms); + assert_eq!(actual.completion(), SearchCompletion::Complete); + assert_results_match_bruteforce(&pool, &actual.results, &expected); + let minimum = if with_multi && mode == MultiUnitBonusMode::ForceOn { + 100 + } else { + 0 + }; + let summary = + crate::search::summarize_deck(&pool, &context, &actual.results[0].cards) + .unwrap(); + assert_eq!(summary.total_power, minimum); + } + } + } +} + +#[test] +fn multi_composition_distinguishes_shared_membership_and_original_shuffle() { + for (masks, expected_multi, expected_common, expected_shuffle) in [ + ([32; 5], false, 32, 0), + ([33; 5], true, 33, 0), + ([1, 1, 1, 1, 33], true, 1, 0), + ([1, 2, 4, 8, 32], true, 0, 50), + ([1, 2, 4, 33, 34], true, 0, 30), + ] { + let mut builder = PoolBuilder::new(5); + for (dense, mask) in masks.into_iter().enumerate() { + builder.set_unit_mask(dense as u16, mask); + } + let pool = builder.freeze(); + let c = DeckComposition::from_cards(&pool, &collect_first_five(&pool)); + assert_eq!( + ( + c.is_multi_unit, + c.shared_units, + c.shuffle_bonus(&[0, 0, 0, 10, 30, 50]) + ), + (expected_multi, expected_common, expected_shuffle) + ); + } +} + +#[test] +fn multi_sidecar_survives_compaction_and_resolves_nonmonotone_minimum() { + let pool = pool_with_multi(MultiUnitBonusMode::ForceOn); + let keep = [false, true, false, true, true, true, true, false, true]; + let compact = pool.compact(&keep); + for (new, old) in compact + .indices() + .zip(pool.indices().filter(|card| keep[card.raw()])) + { + assert_eq!( + compact.multi_power_values(new), + pool.multi_power_values(old) + ); + assert_eq!(compact.power_min(new), pool.power_min(old)); + assert_eq!(compact.game_id(new), pool.game_id(old)); + } + assert_eq!(compact.multi_unit_bonus_mode(), MultiUnitBonusMode::ForceOn); +} + +#[test] +fn multi_exact_topk_all_numeric_and_event_paths() { + let params = SearchParams { + top_k: 7, + timeout_ms: 0, + }; + for mode in [ + MultiUnitBonusMode::ByDeck, + MultiUnitBonusMode::ForceOn, + MultiUnitBonusMode::ForceOff, + ] { + let pool = pool_with_multi(mode); + for target in [ + ScoreTarget::Power, + ScoreTarget::Skill, + ScoreTarget::Score, + ScoreTarget::Bonus, + ScoreTarget::Mysekai, + ] { + for event in [false, true] { + let mut context = ready_ctx(&pool, target); + context.is_world_bloom = event; + context.event_type = event.then_some(EventType::WorldBloom); + context.diff_attr_bonus = [0, 0, 10, 20, 30, 50]; + context.extra_bonus_ub = if event { 50 } else { 0 }; + let (expected, _) = brute_force_search(&pool, &context, ¶ms); + let actual = search(&pool, &context, ¶ms); + assert_eq!(actual.completion(), SearchCompletion::Complete); + assert_results_match_bruteforce(&pool, &actual.results, &expected); + } + } + let mut context = ready_ctx(&pool, ScoreTarget::Power); + context.minimize = true; + let (expected, _) = brute_force_search(&pool, &context, ¶ms); + let actual = search(&pool, &context, ¶ms); + assert_eq!(actual.completion(), SearchCompletion::Complete); + assert_results_match_bruteforce(&pool, &actual.results, &expected); + } +} + +#[test] +fn multi_and_shared_membership_topk_cover_both_original_and_support_bits() { + let params = SearchParams { + top_k: 7, + timeout_ms: 0, + }; + for shape in [1, 2] { + for mode in [ + MultiUnitBonusMode::ByDeck, + MultiUnitBonusMode::ForceOn, + MultiUnitBonusMode::ForceOff, + ] { + let pool = pool_with_multi_shape(mode, shape); + for target in [ScoreTarget::Power, ScoreTarget::Score, ScoreTarget::Mysekai] { + let context = ready_ctx(&pool, target); + let (expected, _) = brute_force_search(&pool, &context, ¶ms); + let actual = search(&pool, &context, ¶ms); + assert_eq!(actual.completion(), SearchCompletion::Complete); + assert_results_match_bruteforce(&pool, &actual.results, &expected); + } + } + } +} + +#[test] +fn multi_challenge_topk_keeps_all_same_character_cards() { + let params = SearchParams { + top_k: 7, + timeout_ms: 0, + }; + for mode in [ + MultiUnitBonusMode::ByDeck, + MultiUnitBonusMode::ForceOn, + MultiUnitBonusMode::ForceOff, + ] { + let pool = pool_with_multi_shape(mode, 3); + for target in [ScoreTarget::Power, ScoreTarget::Score, ScoreTarget::Skill] { + let mut context = ready_ctx(&pool, target); + context.enforce_char_uniqueness = false; + context.live_type = LiveType::Challenge; + let expected = exhaustive_challenge_results(&pool, &context); + let actual = search(&pool, &context, ¶ms); + assert_eq!(actual.completion(), SearchCompletion::Complete); + assert_results_match_bruteforce(&pool, &actual.results, &expected[..params.top_k]); + } + } +} + +#[test] +fn multi_wl3_final_and_exact_bonus_tiers_keep_shuffle_reachable() { + let pool = pool_with_multi(MultiUnitBonusMode::ByDeck); + let params = SearchParams { + top_k: 5, + timeout_ms: 0, + }; + let mut context = ready_ctx(&pool, ScoreTarget::Score); + context.is_world_bloom = true; + context.is_final_chapter = true; + context.is_wl3_finale = true; + context.shuffle_unit_bonus = [0, 0, 0, 10, 30, 50]; + context.event_type = Some(EventType::WorldBloom); + context.live_type = LiveType::Multi; + context.live_skill_order = LiveSkillOrder::Average; + context.best_skill_as_leader = false; + context.power_total_cap = Some(336_000); + context.extra_bonus_ub = 50; + let expected = final_chapter_auto_oracle(&pool, &context, params.top_k); + let actual = search(&pool, &context, ¶ms); + assert_eq!(actual.completion(), SearchCompletion::Complete); + assert_results_match_bruteforce(&pool, &actual.results, &expected); + // Five 10% cards plus a 0/10/30/50 original-unit bonus. + let tiers = [50, 60, 80, 100]; + context.target = ScoreTarget::Bonus; + let outcome = search_targets(&pool, &context, ¶ms, &tiers); + assert_eq!(outcome.completion(), SearchCompletion::Complete); + let all = final_chapter_auto_oracle(&pool, &context, 512); + for tier in tiers { + let matches = |deck: &&DeckResult| { + (evaluate::resolve_total_bonus(&pool, &context, &deck.cards) - f64::from(tier)).abs() + < 1e-9 + }; + let expected = all + .iter() + .filter(matches) + .copied() + .take(params.top_k) + .collect::>(); + let actual = outcome + .results + .iter() + .filter(matches) + .copied() + .collect::>(); + assert_results_match_bruteforce(&pool, &actual, &expected); + } +} + +#[test] +fn custom_nonmonotone_shuffle_table_keeps_exact_search_bounds_admissible() { + let pool = pool_with_multi(MultiUnitBonusMode::ByDeck); + let params = SearchParams { + top_k: 5, + timeout_ms: 0, + }; + let mut context = ready_ctx(&pool, ScoreTarget::Score); + context.is_world_bloom = true; + context.is_final_chapter = true; + // A table is authoritative independently of a hard-coded turn flag. + context.is_wl3_finale = false; + context.shuffle_unit_bonus = [0, 0, 61, 7, 83, 19]; + context.event_type = Some(EventType::WorldBloom); + context.live_type = LiveType::Multi; + context.live_skill_order = LiveSkillOrder::Average; + context.best_skill_as_leader = false; + context.extra_bonus_ub = context.shuffle_bonus_upper(); + assert_eq!(context.shuffle_bonus_upper(), 83); + let expected = final_chapter_auto_oracle(&pool, &context, params.top_k); + let actual = search(&pool, &context, ¶ms); + assert_eq!(actual.completion(), SearchCompletion::Complete); + assert_results_match_bruteforce(&pool, &actual.results, &expected); + context.target = ScoreTarget::Bonus; + let tiers = [50, 111, 57, 133, 69]; + let actual = search_targets(&pool, &context, ¶ms, &tiers); + assert_eq!(actual.completion(), SearchCompletion::Complete); + let all = final_chapter_auto_oracle(&pool, &context, 512); + for tier in tiers { + let matches = |deck: &&DeckResult| { + (evaluate::resolve_total_bonus(&pool, &context, &deck.cards) - f64::from(tier)).abs() + < 1e-9 + }; + let expected = all + .iter() + .filter(matches) + .copied() + .take(params.top_k) + .collect::>(); + let found = actual + .results + .iter() + .filter(matches) + .copied() + .collect::>(); + assert_results_match_bruteforce(&pool, &found, &expected); + } +} diff --git a/src/search/types.rs b/src/search/types.rs index 7c1c7bc..33b03f2 100644 --- a/src/search/types.rs +++ b/src/search/types.rs @@ -66,6 +66,8 @@ pub struct DeckResultSummary { pub multi_live_score_up: f64, /// 活动加成合计百分比;无活动上下文时为 `None`。 pub event_bonus_total: Option, + /// Third World Bloom finale bonus from original-unit diversity, in percent. + pub shuffle_bonus_rate: u32, /// 终章假设佩戴的主称号 ID:队长角色对应的已持有称号中队长加成最高者, /// 同加成取 ID 最小者;该加成已计入 `event_bonus_total`。非终章或队长角色 /// 没有可用称号时为 `None`。 diff --git a/src/simd.rs b/src/simd.rs index 87f3576..b4fedc3 100644 --- a/src/simd.rs +++ b/src/simd.rs @@ -16,13 +16,24 @@ pub(crate) struct PowerAreaItem { pub(crate) unit: u8, pub(crate) attr: u8, pub(crate) character_id: i32, - pub(crate) power_rate: f64, - pub(crate) power_all_match_rate: f64, + pub(crate) power_rate: [f64; 3], + pub(crate) power_all_match_rate: Option<[f64; 3]>, } impl PowerAreaItem { pub(crate) const ANY: u8 = u8::MAX; + pub(crate) const MULTI: u8 = u8::MAX - 1; + pub(crate) const UNKNOWN: u8 = u8::MAX - 2; pub(crate) const ANY_CHARACTER: i32 = -1; + + #[inline(always)] + pub(crate) fn rates(self, all_match: bool) -> [f64; 3] { + if all_match { + self.power_all_match_rate.unwrap_or(self.power_rate) + } else { + self.power_rate + } + } } impl PowerCommon16 { @@ -260,15 +271,10 @@ fn power_area_single_unit_16_scalar( } let all_match = (item.unit != PowerAreaItem::ANY && member_key >= 2) || (item.attr != PowerAreaItem::ANY && member_key % 2 == 1); - let rate = if all_match { - item.power_all_match_rate - } else { - item.power_rate - }; - let factor = rate * 0.01_f64; + let rates = item.rates(all_match); let mut dim = 0usize; while dim < 3 { - acc[dim] += factor * base_dims[dim][lane] as f64; + acc[dim] += (rates[dim] * 0.01_f64) * base_dims[dim][lane] as f64; dim += 1; } } @@ -314,14 +320,10 @@ unsafe fn power_area_single_unit_16_avx512_unchecked( } let all_match = (item.unit != PowerAreaItem::ANY && member_key >= 2) || (item.attr != PowerAreaItem::ANY && member_key % 2 == 1); - let rate = if all_match { - item.power_all_match_rate - } else { - item.power_rate - } * 0.01_f64; - let rate = _mm512_set1_pd(rate); + let rates = item.rates(all_match); let mut dim = 0usize; while dim < 3 { + let rate = _mm512_set1_pd(rates[dim] * 0.01_f64); let mut half = 0usize; while half < 2 { let offset = half * 8; @@ -627,22 +629,22 @@ mod tests { unit: PowerAreaItem::ANY, attr: PowerAreaItem::ANY, character_id: PowerAreaItem::ANY_CHARACTER, - power_rate: 0.3, - power_all_match_rate: 0.7, + power_rate: [0.3, 0.4, 0.7], + power_all_match_rate: None, }, PowerAreaItem { unit: 2, attr: PowerAreaItem::ANY, character_id: PowerAreaItem::ANY_CHARACTER, - power_rate: 1.25, - power_all_match_rate: 2.75, + power_rate: [1.25, 2.0, 0.75], + power_all_match_rate: Some([2.75, 1.0, 3.125]), }, PowerAreaItem { unit: PowerAreaItem::ANY, attr: 3, character_id: 4, - power_rate: 0.85, - power_all_match_rate: 1.65, + power_rate: [0.85; 3], + power_all_match_rate: Some([1.65; 3]), }, ]; let active_lanes = 0b0111_1111_1111_1101; diff --git a/src/types.rs b/src/types.rs index c4725f4..28c4c2a 100644 --- a/src/types.rs +++ b/src/types.rs @@ -29,9 +29,7 @@ pub const WL3_FAKE_FINALE_EVENT_ID: i32 = 3_200_000; /// 终章事件判定:legacy WL2 终章(180)与模拟 WL3 终章(3_200_000)。 /// -/// 真实 masterdata 出现 -/// 新终章活动前,模拟终章共享 180 的终章规则(队长限定 bonus、技能上限 140、 -/// 加成卡上限 4、mysekai fixture 上限 20、禁用 best_skill_as_leader)。 +/// 各轮终章的限制与加成由活动主数据及对应轮次的 fallback 决定。 #[inline] pub const fn is_world_bloom_finale_event(event_id: i32) -> bool { event_id == FINAL_CHAPTER_EVENT_ID || event_id == WL3_FAKE_FINALE_EVENT_ID diff --git a/tests/test_release_packaging.py b/tests/test_release_packaging.py index 8af9f81..f34e903 100644 --- a/tests/test_release_packaging.py +++ b/tests/test_release_packaging.py @@ -1,7 +1,10 @@ from __future__ import annotations +import hashlib import importlib.util +import json import os +import re import subprocess import sys import tempfile @@ -28,6 +31,196 @@ def load_script(name: str): class ReleasePackagingTests(unittest.TestCase): + def run_npm_verifier_mock(self, assertions: str) -> None: + with tempfile.TemporaryDirectory() as tmp: + tarball = Path(tmp) / "package.tgz" + tarball.write_bytes(b"the exact npm package tested for release") + program = r''' +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +const { verifyPackage, NOT_FOUND } = await import(process.argv[1]); +const tarball = process.argv[2]; +const packageName = "@empty-sekai/allium-deck-wasm"; +const version = "0.1.0"; +const integrity = `sha512-${createHash("sha512").update(readFileSync(tarball)).digest("base64")}`; +const metadata = { name: packageName, version, dist: { integrity } }; +const ok = (value = metadata) => ({ status: 200, ok: true, json: async () => value }); +const status = (code) => ({ status: code, ok: false }); +const options = { packageName, version, tarball, log: () => {}, pause: async () => {} }; +''' + assertions + result = subprocess.run( + ["node", "--input-type=module", "-e", program, + (SCRIPTS / "verify_npm_package.mjs").as_uri(), str(tarball)], + cwd=ROOT, capture_output=True, text=True, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_npm_checksum_accepts_exact_tarball_after_visibility_and_network_delays(self) -> None: + self.run_npm_verifier_mock(r''' +const replies = [status(404), new Error("temporary connection failure"), status(503), ok()]; +let calls = 0; +let pauses = 0; +const result = await verifyPackage({ ...options, attempts: 4, + pause: async () => { pauses++; }, + fetchImpl: async (url, init) => { + assert.equal(url, "https://registry.npmjs.org/%40empty-sekai%2Fallium-deck-wasm/0.1.0"); + assert.ok(init.signal); + const reply = replies[calls++]; + if (reply instanceof Error) throw reply; + return reply; + }, +}); +assert.equal(result, 0); +assert.equal(calls, 4); +assert.equal(pauses, 3); +''') + + def test_npm_checksum_rejects_changed_tarball_and_wrong_identity_without_retry(self) -> None: + self.run_npm_verifier_mock(r''' +for (const body of [ + { ...metadata, dist: { integrity: "sha512-different" } }, + { ...metadata, dist: { shasum: "older-hash-without-integrity" } }, + { ...metadata, name: "another-package" }, + { ...metadata, version: "0.0.15" }, +]) { + let calls = 0; + await assert.rejects(verifyPackage({ ...options, attempts: 3, + fetchImpl: async () => { calls++; return ok(body); }, + }), /mismatch|identity/); + assert.equal(calls, 1); +} +''') + + def test_npm_checksum_only_404_is_confirmed_absence(self) -> None: + self.run_npm_verifier_mock(r''' +let absentCalls = 0; +assert.equal(await verifyPackage({ ...options, attempts: 2, + fetchImpl: async () => { absentCalls++; return status(404); }, +}), NOT_FOUND); +assert.equal(absentCalls, 2); +for (const reply of [status(401), status(429), status(500), ok(null), ok([]), + { status: 200, ok: true, json: async () => { throw new Error("malformed JSON"); } }]) { + await assert.rejects(verifyPackage({ ...options, attempts: 2, + fetchImpl: async () => reply, + }), /HTTP|JSON/); +} +await assert.rejects(verifyPackage({ ...options, attempts: 2, + fetchImpl: async () => { throw new Error("offline"); }, +}), /request failed/); +''') + + def test_npm_checksum_request_timeout_is_an_error(self) -> None: + self.run_npm_verifier_mock(r''' +await assert.rejects(verifyPackage({ ...options, timeoutMs: 1, + fetchImpl: async (url, init) => new Promise((resolve, reject) => { + init.signal.addEventListener("abort", () => reject(new Error("request timed out"))); + }), +}), /request failed.*timed out/); +''') + + def test_npm_publish_checks_integrity_before_and_after_upload(self) -> None: + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + publish = workflow.split(" publish-npm:", 1)[1].split(" publish-crate:", 1)[0] + self.assertIn("actions/checkout@", publish) + self.assertEqual(publish.count("node scripts/verify_npm_package.mjs"), 2) + self.assertIn('case "$LOOKUP_STATUS" in', publish) + self.assertIn("3) ;;", publish) + self.assertIn('*) exit "$LOOKUP_STATUS"', publish) + self.assertIn("PUBLISH_STATUS=$?", publish) + self.assertIn("--attempts 6 --delay-ms 3000 --timeout-ms 5000", publish) + + def test_manual_preflight_builds_all_assets_without_publication(self) -> None: + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + jobs = dict(re.findall(r"^ ([a-z-]+):\n(.*?)(?=^ [a-z-]+:\n|\Z)", workflow, re.M | re.S)) + for name in ["test", "build-binaries", "build-wasm-release", "release-assets"]: + self.assertNotRegex(jobs[name], re.compile(r"^ if:", re.M), name) + for name in ["publish-npm", "publish-crate", "release"]: + self.assertIn("if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')", jobs[name]) + self.assertIn("release-assets", jobs[name]) + self.assertNotIn("npm publish", jobs["build-wasm-release"]) + self.assertNotIn("id-token: write", jobs["build-wasm-release"]) + self.assertIn("needs: [test, build-binaries, build-wasm-release]", jobs["release-assets"]) + self.assertIn("pattern: release-build-*", jobs["release-assets"]) + self.assertIn('npm publish --provenance --access public "release/allium-deck-wasm-v${VERSION}.tgz"', jobs["publish-npm"]) + self.assertIn("artifacts/SHA256SUMS", jobs["release"]) + + def test_asset_names_use_version_instead_of_dispatch_branch(self) -> None: + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + wasm_build = workflow.split(" build-wasm-release:", 1)[1].split(" release-assets:", 1)[0] + self.assertNotIn("GITHUB_REF_NAME", wasm_build) + self.assertNotIn("github.ref_name", wasm_build) + self.assertIn('NPM_TGZ="allium-deck-wasm-v${VERSION}.tgz"', wasm_build) + + def test_npm_metadata_is_finalized_before_archiving(self) -> None: + packager = load_script("package_wasm") + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + package = root / "package.json" + package.write_text(json.dumps({ + "name": "@empty-sekai/allium-deck-wasm", + "version": "0.1.0", + "files": ["allium_deck.js", "allium_deck_bg.wasm"], + }), encoding="utf-8") + packager.prepare_npm_metadata(root, "0.1.0", "empty-sekai/allium-deck") + result = json.loads(package.read_text(encoding="utf-8")) + self.assertEqual(result["repository"]["url"], "git+https://github.com/empty-sekai/allium-deck.git") + self.assertEqual(result["publishConfig"]["access"], "public") + self.assertEqual(result["files"], ["allium_deck.js", "allium_deck_bg.wasm"]) + original = package.read_bytes() + with self.assertRaisesRegex(ValueError, "version"): + packager.prepare_npm_metadata(root, "0.1.1", "empty-sekai/allium-deck") + self.assertEqual(package.read_bytes(), original) + + def test_browser_bundle_and_npm_input_have_identical_metadata(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + pkg = root / "pkg" + pkg.mkdir() + (pkg / "package.json").write_text(json.dumps({ + "name": "@empty-sekai/allium-deck-wasm", + "version": "0.1.0", + }), encoding="utf-8") + (pkg / "allium_deck.js").write_bytes(b"export default {}") + (pkg / "allium_deck_bg.wasm").write_bytes(b"wasm fixture") + output = root / "dist" + subprocess.run([ + sys.executable, str(SCRIPTS / "package_wasm.py"), + "--pkg-dir", str(pkg), "--out-dir", str(output), + "--version", "0.1.0", "--source-repository", "empty-sekai/allium-deck", + "--source-revision", "a" * 40, + ], check=True, cwd=ROOT, capture_output=True) + metadata = (pkg / "package.json").read_bytes() + self.assertEqual((output / "package.json").read_bytes(), metadata) + manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8")) + package_entry = next(row for row in manifest["files"] if row["name"] == "package.json") + self.assertEqual(package_entry["sha256"], hashlib.sha256(metadata).hexdigest()) + self.assertEqual(manifest["source"]["revision"], "a" * 40) + + def test_checksum_manifest_covers_exactly_five_native_and_two_wasm_assets(self) -> None: + checksum = load_script("create_release_checksums") + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + names = checksum.expected_assets("0.1.0") + self.assertEqual(len(names), 7) + for name in names: + (root / name).write_bytes(name.encode()) + output = checksum.create_checksums(root, "0.1.0") + expected = "".join(f"{hashlib.sha256(name.encode()).hexdigest()} {name}\n" for name in sorted(names)) + self.assertEqual(output.read_bytes(), expected.encode()) + self.assertEqual(checksum.create_checksums(root, "0.1.0").read_bytes(), expected.encode()) + missing = sorted(names)[0] + (root / missing).unlink() + with self.assertRaisesRegex(ValueError, "missing"): + checksum.create_checksums(root, "0.1.0") + (root / missing).write_bytes(b"") + with self.assertRaisesRegex(ValueError, "nonempty"): + checksum.create_checksums(root, "0.1.0") + (root / missing).write_bytes(missing.encode()) + (root / "allium-deck-wasm-v0.0.15.tgz").write_bytes(b"old") + with self.assertRaisesRegex(ValueError, "unexpected"): + checksum.create_checksums(root, "0.1.0") + def test_release_serializes_attempts_for_each_tag(self) -> None: workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") self.assertIn("concurrency:", workflow) diff --git a/tests/testdata_adapter/input_transform.rs b/tests/testdata_adapter/input_transform.rs index 4ed505d..12879d2 100644 --- a/tests/testdata_adapter/input_transform.rs +++ b/tests/testdata_adapter/input_transform.rs @@ -18,6 +18,7 @@ pub fn transform_input( let build = BuildParams { region: input.region.clone(), + multi_unit_bonus_mode: input.multi_unit_bonus_evaluation, event_id: input.event_id, music_id: input.music_id, music_diff: input.music_diff.clone(), @@ -77,8 +78,8 @@ pub fn transform_input( .map(|entry| UserGateBonus { mysekai_gate_id: Some(entry.mysekai_gate_id), mysekai_gate_level: Some(entry.mysekai_gate_level), - unit: gate_unit(entry.mysekai_gate_id).to_string(), - bonus_rate: (entry.mysekai_gate_level.max(0) as f64) * 0.1, + unit: String::new(), + bonus_rate: 0.0, }) .collect(), user_mysekai_canvas_bonus_cards: legacy_user @@ -106,17 +107,6 @@ pub fn transform_input( Ok((build, user, search)) } -fn gate_unit(gate_id: i32) -> &'static str { - match gate_id { - 1 => "light_sound", - 2 => "idol", - 3 => "street", - 4 => "theme_park", - 5 => "school_refusal", - _ => "piapro", - } -} - fn transform_user_card(card: &LegacyUserCard) -> UserCard { UserCard { card_id: card.card_id, diff --git a/tests/testdata_adapter/legacy_types.rs b/tests/testdata_adapter/legacy_types.rs index b3039f4..9fe8cc9 100644 --- a/tests/testdata_adapter/legacy_types.rs +++ b/tests/testdata_adapter/legacy_types.rs @@ -57,6 +57,8 @@ pub struct LegacyInput { pub unit_filter: Option, #[serde(default)] pub attr_filter: Option, + #[serde(default, alias = "multiUnitBonusEvaluation")] + pub multi_unit_bonus_evaluation: allium_deck::power::MultiUnitBonusMode, } /// 旧引擎 output JSON 的单条结果。 diff --git a/tests/testdata_adapter/masterdata_loader.rs b/tests/testdata_adapter/masterdata_loader.rs index 4b2ab44..5ed53e6 100644 --- a/tests/testdata_adapter/masterdata_loader.rs +++ b/tests/testdata_adapter/masterdata_loader.rs @@ -5,10 +5,10 @@ use std::path::Path; use allium_deck::handler::{ BondsHonor, CardEpisode, CardMysekaiCanvasBonus, CardParameter, CardRarity, CharacterRank, Event, EventCard, EventCardBonusLimit, EventDeckBonus, EventFixtureBonusLimit, EventHonorBonus, - EventRarityBonusRate, EventSkillScoreUpLimit, GameCharacterUnit, GameData, Honor, HonorLevel, - MasterCard, MasterLesson, MusicDifficulty, MusicMeta, MysekaiGate, MysekaiGateLevel, Skill, - SkillEffect, WBSupportDeckBonus, WBSupportDeckUnitEventLimitedBonus, WorldBloom, - WorldBloomDiffAttrBonus, + EventRarityBonusRate, EventShuffleUnitBonus, EventSkillScoreUpLimit, GameCharacterUnit, + GameData, Honor, HonorLevel, MasterCard, MasterLesson, MusicDifficulty, MusicMeta, MysekaiGate, + MysekaiGateLevel, Skill, SkillEffect, WBSupportDeckBonus, WBSupportDeckUnitEventLimitedBonus, + WorldBloom, WorldBloomDiffAttrBonus, }; use serde::Deserialize; use serde::de::DeserializeOwned; @@ -43,6 +43,7 @@ pub struct OwnedGameData { Vec, pub event_mysekai_fixture_performance_bonus_limits: Vec, pub event_skill_score_up_limits: Vec, + pub event_shuffle_unit_bonuses: Vec, pub music_metas: Vec, pub music_difficulties: Vec, pub event_rarity_bonus_rates: Vec, @@ -280,6 +281,16 @@ impl OwnedGameData { score_up_limit: entry.score_up_rate_limit, }) .collect(), + event_shuffle_unit_bonuses: load_optional_json::>( + &masterdata_dir.join("eventShuffleUnitBonuses.json"), + )? + .into_iter() + .map(|row| EventShuffleUnitBonus { + event_id: row.event_id, + unit_count: row.unit_count, + bonus_rate: row.bonus_rate, + }) + .collect(), music_metas: master_music_rows .iter() .map(|row| MusicMeta { @@ -377,6 +388,7 @@ impl OwnedGameData { event_mysekai_fixture_performance_bonus_limits: &self .event_mysekai_fixture_performance_bonus_limits, event_skill_score_up_limits: &self.event_skill_score_up_limits, + event_shuffle_unit_bonuses: &self.event_shuffle_unit_bonuses, music_metas: &self.music_metas, music_difficulties: &self.music_difficulties, event_rarity_bonus_rates: &self.event_rarity_bonus_rates, @@ -407,24 +419,6 @@ fn flatten_card_parameters(card: &RawCard) -> Vec { fn flatten_area_item_levels( raw: Vec, ) -> Vec { - let mut raw = raw; - raw.sort_by(|left, right| { - ( - left.area_item_id, - normalize_target_token(left.target_unit.as_deref()), - normalize_target_token(left.target_card_attr.as_deref()), - left.target_game_character_id, - left.level, - ) - .cmp(&( - right.area_item_id, - normalize_target_token(right.target_unit.as_deref()), - normalize_target_token(right.target_card_attr.as_deref()), - right.target_game_character_id, - right.level, - )) - }); - let mut result = Vec::with_capacity(raw.len()); for item in raw { let unit = normalize_target_token(item.target_unit.as_deref()); @@ -435,9 +429,17 @@ fn flatten_area_item_levels( level: item.level, unit, attr, - character_id: item.target_game_character_id, - power_rate: item.power1_bonus_rate, - power_all_match_rate: item.power1_all_match_bonus_rate, + character_id: item.target_game_character_id.filter(|id| *id != 0), + power_rate: [ + item.power1_bonus_rate, + item.power2_bonus_rate, + item.power3_bonus_rate, + ], + power_all_match_rate: item + .power1_all_match_bonus_rate + .zip(item.power2_all_match_bonus_rate) + .zip(item.power3_all_match_bonus_rate) + .map(|((a, b), c)| [a, b, c]), }); } result @@ -795,7 +797,14 @@ struct RawAreaItemLevel { #[serde(default)] target_game_character_id: Option, power1_bonus_rate: f64, - power1_all_match_bonus_rate: f64, + power2_bonus_rate: f64, + power3_bonus_rate: f64, + #[serde(default)] + power1_all_match_bonus_rate: Option, + #[serde(default)] + power2_all_match_bonus_rate: Option, + #[serde(default)] + power3_all_match_bonus_rate: Option, } #[derive(Debug, Clone, Deserialize)] @@ -902,6 +911,14 @@ struct RawEventSkillScoreUpLimit { score_up_rate_limit: i32, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawEventShuffleUnitBonus { + event_id: i32, + unit_count: i32, + bonus_rate: i32, +} + #[derive(Debug, Clone, Deserialize)] struct RawMusicMetaRow { music_id: i32, diff --git a/wasm/Cargo.lock b/wasm/Cargo.lock index 6670c05..71c9f78 100644 --- a/wasm/Cargo.lock +++ b/wasm/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "allium-deck" -version = "0.0.15" +version = "0.1.0" dependencies = [ "serde", "serde_json", @@ -14,7 +14,7 @@ dependencies = [ [[package]] name = "allium-deck-wasm" -version = "0.0.15" +version = "0.1.0" dependencies = [ "allium-deck", "console_error_panic_hook", diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index bff27fb..0264d2d 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -1,10 +1,11 @@ [package] name = "allium-deck-wasm" -version = "0.0.15" +version = "0.1.0" edition = "2024" description = "Browser WASM bindings for allium-deck" license = "MIT OR Apache-2.0" repository = "https://github.com/empty-sekai/allium-deck" +readme = "README.md" publish = false [workspace] @@ -32,7 +33,7 @@ crate-type = ["cdylib"] wasm-opt = ["-O3", "--converge", "--enable-bulk-memory", "--enable-sign-ext", "--enable-nontrapping-float-to-int", "--enable-mutable-globals", "--enable-reference-types", "--strip-dwarf", "--strip-producers"] [dependencies] -allium-deck = { path = "..", version = "=0.0.15" } +allium-deck = { path = "..", version = "=0.1.0" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/wasm/README.md b/wasm/README.md new file mode 100644 index 0000000..219c170 --- /dev/null +++ b/wasm/README.md @@ -0,0 +1,23 @@ +# allium-deck-wasm + +Browser WebAssembly bindings for the allium-deck Project Sekai recommendation +engine. Callers provide master data and a user collection; no game data is bundled. + +## Business-rule source + +JP7 business rules were ported from +**[Team-Haruki/sekai-deck-recommend-cpp](https://github.com/Team-Haruki/sekai-deck-recommend-cpp)** +at [`496caed78a07ffe1f2d1f1553047dbf6b2ed0314`](https://github.com/Team-Haruki/sekai-deck-recommend-cpp/commit/496caed78a07ffe1f2d1f1553047dbf6b2ed0314), +including area-item, MySEKAI gate and World Link finale rules. They are implemented +in allium-deck's Rust engine. Master-table shuffle overrides and browser +integration cases also draw on Moesekai's published patches. + +See the [source map](https://github.com/empty-sekai/allium-deck/blob/v0.1.0/docs/game-rule-sources.md) +for attribution and behavior differences, and the +[parameter reference](https://github.com/empty-sekai/allium-deck/blob/v0.1.0/docs/parameters.md) +for input and completion contracts. A timed-out search does not certify complete +Top-K. + +## License + +MIT OR Apache-2.0. Both license texts are included in the package. diff --git a/wasm/src/auxiliary_api.rs b/wasm/src/auxiliary_api.rs index 4ed303d..df23dcd 100644 --- a/wasm/src/auxiliary_api.rs +++ b/wasm/src/auxiliary_api.rs @@ -36,9 +36,12 @@ pub fn recommend_area_items(options_json: &str) -> Result, #[serde(skip_serializing_if = "Option::is_none")] event_bonus_total: Option, + shuffle_bonus_rate: Option, #[serde(skip_serializing_if = "Option::is_none")] main_honor_id: Option, } @@ -347,6 +348,7 @@ impl Renderer<'_> { skill_score: summary.multi_live_score_up, multi_live_score_up: Some(summary.multi_live_score_up), event_bonus_total: summary.event_bonus_total, + shuffle_bonus_rate: Some(summary.shuffle_bonus_rate), main_honor_id: summary.main_honor_id, } } @@ -381,6 +383,7 @@ impl Renderer<'_> { .sum(), multi_live_score_up: None, event_bonus_total: None, + shuffle_bonus_rate: None, main_honor_id: None, } }