diff --git a/.busted b/.busted index 7c7b78d94..b79a03a02 100644 --- a/.busted +++ b/.busted @@ -1,6 +1,6 @@ return { _all = { - coverage = false, + coverage = true, verbose = true, }, default = { @@ -8,6 +8,5 @@ return { lpath = "../runtime/lua/?.lua;../runtime/lua/?/init.lua", helper = "HeadlessWrapper.lua", ROOT = { "../spec" }, - ["exclude-tags"] = "builds", } } diff --git a/.github/ISSUE_TEMPLATE/ai_fix.yaml b/.github/ISSUE_TEMPLATE/ai_fix.yaml new file mode 100644 index 000000000..ce63040e1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ai_fix.yaml @@ -0,0 +1,59 @@ +name: AI fix request +description: Report a well-scoped bug or task for the AI agent to fix automatically. +labels: [ai-fix] +body: + - type: markdown + attributes: + value: | + Issues with this template are automatically picked up by the AI agent when labelled **ai-fix**. + Keep the description concrete and focused — the agent works best with a single, well-defined task. + + - type: checkboxes + id: scope + attributes: + label: Confirm scope + options: + - label: This is a single, well-scoped task (not a broad refactor or multi-feature request) + required: true + - label: I've checked there are no open PRs already addressing this + required: true + + - type: textarea + id: description + attributes: + label: What needs to be fixed or done? + description: Be specific. Include file names, function names, or error messages where possible. + placeholder: | + e.g. The Boneshatter damage calculation in `src/Modules/CalcOffence.lua` does not account for + the stun threshold modifier added in 0.2.0. Expected value: X, actual value: Y. + validations: + required: true + + - type: textarea + id: acceptance + attributes: + label: Acceptance criteria + description: How will you know the fix is correct? What should the tests verify? + placeholder: | + - Calculation matches expected value from PoE2 tooltip + - Existing busted tests still pass + - New test added covering the stun threshold case + validations: + required: true + + - type: textarea + id: build_code + attributes: + label: PoB build code (if relevant) + description: A build code that reproduces the issue helps the agent verify the fix. + render: shell + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional context + description: Screenshots, error messages, or links to PoE2 patch notes. + validations: + required: false diff --git a/.github/workflows/ai-fix.yml b/.github/workflows/ai-fix.yml new file mode 100644 index 000000000..1b6861136 --- /dev/null +++ b/.github/workflows/ai-fix.yml @@ -0,0 +1,305 @@ +name: AI Fix +on: + issues: + types: [labeled] + +jobs: + apply-fix: + if: github.event.label.name == 'ai-fix' + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: write + issues: write + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + EMERGENCY_ANTHROPIC_API_KEY: ${{ secrets.EMERGENCY_ANTHROPIC_API_KEY }} + outputs: + branch: ${{ steps.push.outputs.branch }} + issue_num: ${{ steps.meta.outputs.issue_num }} + issue_title: ${{ steps.meta.outputs.issue_title }} + steps: + - uses: actions/checkout@v4 + with: + ref: dev + fetch-depth: 0 + + - name: Verify ANTHROPIC_API_KEY is NOT set + run: | + if [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then + echo "::error::ANTHROPIC_API_KEY is set; would override Pro subscription auth and incur PAYG charges" + exit 1 + fi + + - name: Extract issue metadata + id: meta + run: | + echo "issue_num=$(jq -r '.issue.number' "$GITHUB_EVENT_PATH")" >> "$GITHUB_OUTPUT" + echo "issue_title=$(jq -r '.issue.title' "$GITHUB_EVENT_PATH")" >> "$GITHUB_OUTPUT" + + - name: Install agent tooling if missing + run: | + command -v claude || npm install -g @anthropic-ai/claude-code + command -v opencode || curl -fsSL https://opencode.ai/install | bash + + - name: Checkout or create fix branch + id: branch_setup + run: | + BRANCH="ai-fix/issue-${{ steps.meta.outputs.issue_num }}" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + + if git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null 2>&1; then + echo "is_retry=true" >> "$GITHUB_OUTPUT" + echo "Branch $BRANCH exists — checking out for retry" + git checkout -b "$BRANCH" "origin/$BRANCH" + else + echo "is_retry=false" >> "$GITHUB_OUTPUT" + echo "Branch $BRANCH does not exist — creating fresh from dev" + git checkout -b "$BRANCH" + fi + + - name: Build agent prompt + run: | + ISSUE_BODY=$(jq -r '.issue.body' "$GITHUB_EVENT_PATH") + + # If resuming an existing branch, include prior commit context so the + # agent knows what was already attempted and can build on or correct it + RETRY_CONTEXT="" + if [[ "${{ steps.branch_setup.outputs.is_retry }}" == "true" ]]; then + PREV_COMMITS=$(git log "origin/dev..HEAD" --oneline 2>/dev/null || echo "none") + RETRY_CONTEXT=" + NOTE: A previous fix attempt exists on this branch. Prior commits: + ${PREV_COMMITS} + + Review the existing changes and either build on them or correct them. + Do not revert work that is already correct." + fi + + cat > /tmp/fix-prompt.txt << PROMPT_EOF + You are resolving GitHub issue #${{ steps.meta.outputs.issue_num }} in jay9297/PathOfBuilding-PoE2. + + Title: ${{ steps.meta.outputs.issue_title }} + + Body: + ${ISSUE_BODY} + ${RETRY_CONTEXT} + + Follow the rules in CLAUDE.md. Make the minimal correct change. + Add or update tests to cover your fix. Do NOT run tests yourself. + PROMPT_EOF + + - name: Run AI agent + run: | + source scripts/agent-lib.sh + run_agent_with_fallback /tmp/fix-prompt.txt fix 80 + + - name: Commit and push branch + id: push + run: | + BRANCH="${{ steps.branch_setup.outputs.branch }}" + git config user.name "ai-agent[bot]" + git config user.email "ai-agent@users.noreply.github.com" + git add -A + + if git diff --cached --quiet; then + # Agent made no new changes — check if prior work already exists on the branch + AHEAD=$(git rev-list --count "origin/dev..HEAD" 2>/dev/null || echo "0") + if [[ "${{ steps.branch_setup.outputs.is_retry }}" == "true" && "$AHEAD" -gt 0 ]]; then + echo "Agent made no new changes but branch has $AHEAD existing commit(s) — proceeding with existing work" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "::error::No changes produced by agent and no existing commits on branch" + exit 1 + fi + + git commit -m "fix: resolve issue #${{ steps.meta.outputs.issue_num }} + + Generated by AI agent. Requires human review before merge." + + source scripts/agent-lib.sh + retry_op 5 10 git push -u origin "$BRANCH" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + + - name: Post failure comment + if: failure() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + source scripts/agent-lib.sh + retry_op 3 10 gh issue comment ${{ steps.meta.outputs.issue_num }} \ + --body "⚠️ **AI Fix Failed** + + The \`apply-fix\` job failed after exhausting all agent tiers and retry cycles. + This likely indicates an API outage or expired credentials. + + **What to check:** + - \`CLAUDE_CODE_OAUTH_TOKEN\` — is the Pro subscription active? + - \`OPENCODE_API_KEY\` — is the key valid? + - \`EMERGENCY_ANTHROPIC_API_KEY\` — set this secret for last-resort fallback. + + Re-add the \`ai-fix\` label once credentials are restored to retry." || true + + verify-and-fix: + needs: apply-fix + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: write + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + EMERGENCY_ANTHROPIC_API_KEY: ${{ secrets.EMERGENCY_ANTHROPIC_API_KEY }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.apply-fix.outputs.branch }} + fetch-depth: 0 + + - name: Configure git + run: | + git config user.name "ai-agent[bot]" + git config user.email "ai-agent@users.noreply.github.com" + + - name: Install agent tooling if missing + run: | + command -v claude || npm install -g @anthropic-ai/claude-code + command -v opencode || curl -fsSL https://opencode.ai/install | bash + + - name: Cache test image + id: image-cache + uses: actions/cache@v4 + with: + path: /tmp/pob-test-image.tar + key: pob-test-image-${{ hashFiles('docker-compose.yml') }} + restore-keys: pob-test-image- + + - name: Pull or restore image + run: | + if [[ "${{ steps.image-cache.outputs.cache-hit }}" == "true" ]]; then + docker load < /tmp/pob-test-image.tar + else + docker pull ghcr.io/pathofbuildingcommunity/pathofbuilding-tests:latest + docker save ghcr.io/pathofbuildingcommunity/pathofbuilding-tests:latest \ + -o /tmp/pob-test-image.tar + fi + + # Separated into its own step — no ${{ }} expressions here so the heredoc + # delimiter is never mangled by GitHub Actions' format() evaluator + - name: Build verify-fix prompt template + run: | + cat > /tmp/verify-fix-prompt-template.txt << 'PROMPT_EOF' + The AI agent previously made changes to fix a GitHub issue, but the test + suite is now failing. Fix the failing tests without reverting the original + fix. Make the minimal change needed. Follow CLAUDE.md. + + Failing test output: + PROMPT_EOF + + - name: Verify and auto-fix loop + run: | + source scripts/agent-lib.sh + + MAX_RETRIES=3 + ATTEMPT=0 + + run_tests() { + local output_file="$1" + local failed=0 + echo "--- unit ---" | tee -a "$output_file" + docker compose run --rm busted-tests --exclude-tags builds,data \ + 2>&1 | tee -a "$output_file" || failed=1 + echo "--- builds ---" | tee -a "$output_file" + docker compose run --rm busted-tests --tags builds \ + 2>&1 | tee -a "$output_file" || failed=1 + echo "--- data ---" | tee -a "$output_file" + docker compose run --rm busted-tests --tags data \ + 2>&1 | tee -a "$output_file" || failed=1 + return $failed + } + + while [ $ATTEMPT -lt $MAX_RETRIES ]; do + ATTEMPT=$((ATTEMPT + 1)) + OUTPUT_FILE="/tmp/test-output-attempt-${ATTEMPT}.txt" + > "$OUTPUT_FILE" + + echo "::group::Test attempt $ATTEMPT of $MAX_RETRIES" + if run_tests "$OUTPUT_FILE"; then + echo "✅ Tests passed on attempt $ATTEMPT" + echo "::endgroup::" + exit 0 + fi + echo "::endgroup::" + echo "::warning::Tests failed on attempt $ATTEMPT" + + cp "$OUTPUT_FILE" "/tmp/test-failures-attempt-${ATTEMPT}.txt" + + if [ $ATTEMPT -ge $MAX_RETRIES ]; then + echo "::error::Tests still failing after $MAX_RETRIES fix attempts" + exit 1 + fi + + # Rebuild prompt fresh each iteration with the latest failure output + cp /tmp/verify-fix-prompt-template.txt /tmp/verify-fix-prompt-current.txt + cat "$OUTPUT_FILE" >> /tmp/verify-fix-prompt-current.txt + + echo "🤖 Running agent to fix test failures (attempt $ATTEMPT)..." + run_agent_with_fallback /tmp/verify-fix-prompt-current.txt fix 20 + + if git diff --quiet && git diff --cached --quiet; then + echo "::error::Agent produced no changes — cannot recover from test failures" + exit 1 + fi + + git add -A + retry_op 5 10 git commit -m "fix(tests): address failures — attempt $ATTEMPT [ai-agent] + + Issue: #${{ needs.apply-fix.outputs.issue_num }}" + retry_op 5 10 git push + done + + - name: Upload test failure logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-failures-issue-${{ needs.apply-fix.outputs.issue_num }} + path: /tmp/test-failures-attempt-*.txt + retention-days: 7 + + open-pr: + needs: [apply-fix, verify-and-fix] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.apply-fix.outputs.branch }} + + - name: Create pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + source scripts/agent-lib.sh + + # If a PR already exists for this branch (from a previous run), skip creation + EXISTING_PR=$(gh pr list \ + --head "${{ needs.apply-fix.outputs.branch }}" \ + --json number --jq '.[0].number' 2>/dev/null || echo "") + + if [[ -n "$EXISTING_PR" ]]; then + echo "PR #${EXISTING_PR} already exists for this branch — skipping creation" + exit 0 + fi + + retry_op 5 15 gh pr create \ + --base dev \ + --head "${{ needs.apply-fix.outputs.branch }}" \ + --title "${{ needs.apply-fix.outputs.issue_title }}" \ + --body "Closes #${{ needs.apply-fix.outputs.issue_num }} + + 🤖 Generated by AI agent. Automated review will follow. + Final approval required from @jay9297." \ + --label "ai-generated" diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml new file mode 100644 index 000000000..7371c6f20 --- /dev/null +++ b/.github/workflows/ai-review.yml @@ -0,0 +1,156 @@ +name: AI Review +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: ai-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + review: + name: ai-review + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + pull-requests: write + contents: read + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + EMERGENCY_ANTHROPIC_API_KEY: ${{ secrets.EMERGENCY_ANTHROPIC_API_KEY }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + filter: blob:none + + - name: Verify ANTHROPIC_API_KEY is NOT set + run: | + if [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then + echo "::error::ANTHROPIC_API_KEY is set; would override Pro subscription auth" + exit 1 + fi + + - name: Install agent tooling if missing + run: | + command -v claude || npm install -g @anthropic-ai/claude-code + command -v opencode || curl -fsSL https://opencode.ai/install | bash + + - name: Generate diff + run: | + git diff "origin/${{ github.base_ref }}...HEAD" > /tmp/pr.diff + git diff --stat "origin/${{ github.base_ref }}...HEAD" > /tmp/pr.summary + + - name: Guard against oversized diff + run: | + LINES=$(wc -l < /tmp/pr.diff) + echo "Diff size: ${LINES} lines" + if [[ "$LINES" -gt 3000 ]]; then + echo "::warning::Diff exceeds 3000 lines — skipping AI review" + exit 0 + fi + + - name: Build review prompt + run: | + cat > /tmp/review-prompt.txt << 'PROMPT_EOF' + You are a senior Lua developer performing a thorough code review on a + Path of Building 2 PR. Follow CLAUDE.md. + + Review checklist — address each item explicitly: + 1. Calculation correctness: does the math match PoE2 mechanics? + 2. Modifier parsing: ModParser.lua changes need new test cases — present? + 3. Nil safety: any unguarded table accesses introduced? + 4. Data integrity: data file changes parse cleanly? + 5. Performance: new allocations in hot loops? + 6. Test coverage: which new code paths lack coverage? + 7. Style: matches surrounding conventions? + + Be specific. Cite line numbers. Format as Markdown matching the checklist. + On the very last line output exactly one of: + VERDICT:APPROVE + VERDICT:REQUEST_CHANGES + VERDICT:COMMENT + + Diff summary: + PROMPT_EOF + cat /tmp/pr.summary >> /tmp/review-prompt.txt + echo "" >> /tmp/review-prompt.txt + echo "Full diff:" >> /tmp/review-prompt.txt + cat /tmp/pr.diff >> /tmp/review-prompt.txt + + - name: Run AI review + run: | + source scripts/agent-lib.sh + run_agent_with_fallback /tmp/review-prompt.txt review 20 > /tmp/review.md + + - name: Save review artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: ai-review-${{ github.event.pull_request.number }}-${{ github.run_attempt }} + path: /tmp/review.md + retention-days: 7 + + - name: Parse verdict + id: verdict + run: | + VERDICT=$(grep '^VERDICT:' /tmp/review.md | tail -1 | cut -d: -f2 || echo "COMMENT") + sed -i '/^VERDICT:/d' /tmp/review.md + echo "result=${VERDICT}" >> "$GITHUB_OUTPUT" + + - name: Post or update review comment + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUM: ${{ github.event.pull_request.number }} + run: | + source scripts/agent-lib.sh + + EXISTING_ID=$(gh pr view "$PR_NUM" --json comments \ + --jq '.comments[] | select(.author.login == "github-actions[bot]") | select(.body | startswith("")) | .id' \ + | head -1) + + FULL_BODY=" + $(cat /tmp/review.md)" + + if [[ -n "$EXISTING_ID" ]]; then + retry_op 5 15 gh api \ + --method PATCH \ + "/repos/${{ github.repository }}/issues/comments/${EXISTING_ID}" \ + -f body="$FULL_BODY" + else + retry_op 5 15 gh pr comment "$PR_NUM" --body "$FULL_BODY" + fi + + - name: Submit formal review + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + source scripts/agent-lib.sh + + case "${{ steps.verdict.outputs.result }}" in + APPROVE) EVENT="APPROVE" ;; + REQUEST_CHANGES) EVENT="REQUEST_CHANGES" ;; + *) EVENT="COMMENT" ;; + esac + + retry_op 5 15 gh pr review ${{ github.event.pull_request.number }} \ + --event "$EVENT" \ + --body "Verdict: **${{ steps.verdict.outputs.result }}**. See review comment above." + + - name: Post failure comment on total failure + if: failure() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + retry_op 3 10 gh pr comment ${{ github.event.pull_request.number }} \ + --body "⚠️ **AI Review Failed** + + All agent tiers and retry cycles were exhausted. This likely indicates an API outage or expired credentials. + + **What to check:** + - \`CLAUDE_CODE_OAUTH_TOKEN\` — Pro subscription active? + - \`OPENCODE_API_KEY\` — key valid? + - \`EMERGENCY_ANTHROPIC_API_KEY\` — set for last-resort fallback. + + Push a new commit to re-trigger once credentials are restored." || true diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 000000000..4719e3cf6 --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,22 @@ +name: Auto-Merge on Approval +on: + pull_request_review: + types: [submitted] + +jobs: + automerge: + if: | + github.event.review.state == 'approved' && + github.event.review.user.login == 'jay9297' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Enable auto-merge + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr merge ${{ github.event.pull_request.number }} \ + --squash --auto \ + --repo ${{ github.repository }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..5e4c4bb96 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,141 @@ +name: CI +on: + pull_request: + branches: [dev] + push: + branches: [dev] + +jobs: + test: + name: test (${{ matrix.suite.name }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + suite: + - name: unit + args: "--exclude-tags builds,data" + - name: builds + args: "--tags builds" + - name: data + args: "--tags data" + steps: + - uses: actions/checkout@v4 + + - name: Cache test image + id: image-cache + uses: actions/cache@v4 + with: + path: /tmp/pob-test-image.tar + # Key on the compose file — if the image tag/ref changes, cache invalidates + key: pob-test-image-${{ hashFiles('docker-compose.yml') }} + restore-keys: pob-test-image- + + - name: Pull or restore image + run: | + if [[ "${{ steps.image-cache.outputs.cache-hit }}" == "true" ]]; then + echo "Restoring image from cache" + docker load < /tmp/pob-test-image.tar + else + echo "Cache miss — pulling from GHCR" + docker pull ghcr.io/pathofbuildingcommunity/pathofbuilding-tests:latest + docker save ghcr.io/pathofbuildingcommunity/pathofbuilding-tests:latest \ + -o /tmp/pob-test-image.tar + fi + + - name: Run ${{ matrix.suite.name }} tests + run: docker compose run --rm busted-tests ${{ matrix.suite.args }} + + coverage: + name: coverage + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: test + steps: + - uses: actions/checkout@v4 + + - name: Cache test image + id: image-cache + uses: actions/cache@v4 + with: + path: /tmp/pob-test-image.tar + key: pob-test-image-${{ hashFiles('docker-compose.yml') }} + restore-keys: pob-test-image- + + - name: Pull or restore image + run: | + if [[ "${{ steps.image-cache.outputs.cache-hit }}" == "true" ]]; then + docker load < /tmp/pob-test-image.tar + else + docker pull ghcr.io/pathofbuildingcommunity/pathofbuilding-tests:latest + docker save ghcr.io/pathofbuildingcommunity/pathofbuilding-tests:latest \ + -o /tmp/pob-test-image.tar + fi + + - name: Generate coverage report + run: | + # The compose volume is :ro so luacov can't write back to the workspace. + # Run the container with a writable bind mount for the output file only, + # keeping the source tree read-only. + docker run --rm \ + -e HOME=/tmp \ + --security-opt no-new-privileges:true \ + -w /workdir \ + -v "${{ github.workspace }}:/workdir:ro" \ + -v "${{ github.workspace }}/luacov.report.out:/workdir/luacov.report.out" \ + ghcr.io/pathofbuildingcommunity/pathofbuilding-tests:latest \ + busted --lua=luajit --coverage + + - name: Verify coverage file exists + run: | + if [ ! -f luacov.report.out ]; then + echo "::warning::luacov.report.out not found — check luacov config inside the container" + fi + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: luacov.report.out + retention-days: 30 + + - name: Coverage threshold check + run: | + THRESHOLD=${COVERAGE_THRESHOLD:-0} + if [ "$THRESHOLD" -eq 0 ]; then + echo "Coverage threshold not yet set — skipping enforcement" + exit 0 + fi + if [ ! -f luacov.report.out ]; then + echo "::error::luacov.report.out not found — cannot enforce coverage threshold" + exit 1 + fi + ACTUAL=$(grep -E "^Total" luacov.report.out | awk '{print int($NF)}') + if [ -z "$ACTUAL" ]; then + echo "::error::Could not parse coverage percentage from luacov.report.out" + exit 1 + fi + echo "Coverage: $ACTUAL% (threshold: $THRESHOLD%)" + if [ "$ACTUAL" -lt "$THRESHOLD" ]; then + echo "::error::Coverage $ACTUAL% is below threshold $THRESHOLD%" + exit 1 + fi + + secrets: + name: secret-scan + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Scan for secrets + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml index c6c8cbe42..580c646e3 100644 --- a/.github/workflows/spellcheck.yml +++ b/.github/workflows/spellcheck.yml @@ -2,9 +2,9 @@ name: Spell Checker on: push: - branches: [ dev ] + branches: [dev] pull_request: - branches: [ dev ] + branches: [dev] workflow_dispatch: inputs: ref: @@ -12,30 +12,35 @@ on: default: dev required: false +concurrency: + group: spellcheck-${{ github.ref }} + cancel-in-progress: true + jobs: spellcheck: runs-on: ubuntu-latest + timeout-minutes: 15 env: - CONFIG_URL: https://raw.githubusercontent.com/Nightblade/pob-dict/main + CONFIG_URL: https://raw.githubusercontent.com/Nightblade/pob-dict/main steps: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ inputs.ref }} + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} - name: Fetch config file and dictionaries - run: - curl --silent --show-error --parallel --remote-name-all - ${{ env.CONFIG_URL }}/cspell.json - ${{ env.CONFIG_URL }}/pob-dict.txt - ${{ env.CONFIG_URL }}/poe-dict.txt - ${{ env.CONFIG_URL }}/ignore-dict.txt - ${{ env.CONFIG_URL }}/extra-en-dict.txt - ${{ env.CONFIG_URL }}/contribs-dict.txt + run: | + curl --silent --show-error --fail --parallel --remote-name-all \ + "${{ env.CONFIG_URL }}/cspell.json" \ + "${{ env.CONFIG_URL }}/pob-dict.txt" \ + "${{ env.CONFIG_URL }}/poe-dict.txt" \ + "${{ env.CONFIG_URL }}/ignore-dict.txt" \ + "${{ env.CONFIG_URL }}/extra-en-dict.txt" \ + "${{ env.CONFIG_URL }}/contribs-dict.txt" - name: Run cspell uses: streetsidesoftware/cspell-action@v8 with: - files: '**' # needed as workaround for non-incremental runs (overrides in config still work ok) + files: '**' config: "cspell.json" incremental_files_only: ${{ github.event_name != 'workflow_dispatch' }} diff --git a/.gitignore b/.gitignore index e707d9bac..2f4b7f16b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ .vscode/ *.code-workspace inspect.lua +.mcp.json +.opencode.json +scripts/ # Development files *.lnk @@ -24,10 +27,14 @@ manifest-updated.xml # GGPK Export src/Export/ggpk/metadata/ +src/Export/ggpk/Metadata/ src/Export/ggpk/data/ +src/Export/ggpk/Data/ src/Export/ggpk/art/ src/Export/ggpk/*.exe src/Export/ggpk/*.dll +src/Export/ggpk/bun_extract_file +src/Export/ggpk/*.so #tree data src/TreeData/**/*.scm @@ -45,3 +52,8 @@ runtime/imgui.ini src/poe_api_response.json +.omc/ +.claude/ +.remember/ +.sisyphus/ +.code-review-graph/ diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 000000000..494fc9ff6 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,6 @@ +std = "lua51+luajit" +ignore = { + "212", -- unused argument + "213", -- unused loop variable +} +files["spec/"] = { std = "+busted" } diff --git a/.luacov b/.luacov new file mode 100644 index 000000000..0192ead7b --- /dev/null +++ b/.luacov @@ -0,0 +1,6 @@ +return { + ["include"] = { "src/" }, + ["exclude"] = { "spec/", "runtime/", "tests/" }, + reporter = "default", + reportfile = "luacov.report.out", +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..114af3989 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,102 @@ + +## MCP Tools: code-review-graph + +**IMPORTANT: This project has a knowledge graph. ALWAYS use the +code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore +the codebase.** The graph is faster, cheaper (fewer tokens), and gives +you structural context (callers, dependents, test coverage) that file +scanning cannot. + +### When to use graph tools FIRST + +- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep +- **Understanding impact**: `get_impact_radius` instead of manually tracing imports +- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files +- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for +- **Architecture questions**: `get_architecture_overview` + `list_communities` + +Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need. + +### Key Tools + +| Tool | Use when | +|------|----------| +| `detect_changes` | Reviewing code changes — gives risk-scored analysis | +| `get_review_context` | Need source snippets for review — token-efficient | +| `get_impact_radius` | Understanding blast radius of a change | +| `get_affected_flows` | Finding which execution paths are impacted | +| `query_graph` | Tracing callers, callees, imports, tests, dependencies | +| `semantic_search_nodes` | Finding functions/classes by name or keyword | +| `get_architecture_overview` | Understanding high-level codebase structure | +| `refactor_tool` | Planning renames, finding dead code | + +### Workflow + +1. The graph auto-updates on file changes (via hooks). +2. Use `detect_changes` for code review. +3. Use `get_affected_flows` to understand impact. +4. Use `query_graph` pattern="tests_for" to check coverage. + +--- + +# Path of Building 2 — Agent Instructions + +You are working on a fork of PathOfBuildingCommunity/PathOfBuilding-PoE2, an +offline build planner for Path of Exile 2. The codebase is ~100% Lua (5.1 / LuaJIT). + +## Critical rules + +1. **Never break numeric calculations.** PoB's value is in correctly modelling + PoE2's stat math. If you touch anything in `src/Modules/Calcs/` or `src/Modules/CalcSections.lua`, + you MUST add or update tests in `spec/System/` that assert specific stat values + within tolerance (±0.01 for DPS, exact for hit point pools). + +2. **Modifier parsing is fragile.** `src/Modules/ModParser.lua` is the single most + load-bearing file. Any change requires: + - Adding test cases for the new modifier strings in `spec/System/TestModParser.lua` + - Verifying existing test suite still passes (`docker compose run --rm tests`) + +3. **Data file changes need validation.** Game data lives in `src/Data/`. After + modifying any data file, run the data validation tests (`docker compose run --rm tests --tags data`). + +4. **Lua nil-safety is your responsibility.** Lua does not have a type system. + When you call `someTable.field.subfield`, guard with `someTable and someTable.field and someTable.field.subfield` + or use `rawget`. Nil reference crashes are the #1 source of bug reports in PoB. + +5. **Performance matters in calculation hot loops.** Avoid table allocations + inside `Calc:Build*` functions; reuse tables and use local variable caching + for frequently-accessed table fields. + +## How to test changes + +```bash +docker compose run --rm tests # full Busted suite +docker compose run --rm tests --tags builds # build snapshot tests (slow) +docker compose run --rm tests --tags data # data file validation only +docker compose run --rm tests --coverage # with luacov +``` + +## How to validate before opening a PR + +1. All tests pass: `docker compose run --rm tests` +2. Coverage did not drop: compare `luacov.report.out` against main +3. No new luac warnings: `luac -p src/**/*.lua` +4. The commit message follows Conventional Commits (`feat:`, `fix:`, `refactor:`, etc.) + +## Project structure + +- `src/` — main Lua source. Entrypoint is `src/Launch.lua`. +- `src/Modules/Calcs/` — the calculation engine. The most important code in the project. +- `src/Modules/ModParser.lua` — converts modifier strings into structured stat objects. +- `src/Data/` — game data: gems, items, passive tree, base item types. +- `spec/System/` — Busted test suite. +- `runtime/lua/` — extra Lua libraries used at runtime. +- `tests/` — Docker-based test harness. + +## Fork rules + +1. **NEVER open PRs against the upstream repo** (`PathOfBuildingCommunity/PathOfBuilding-PoE2`). All PRs must target the fork (`jay9297/PathOfBuilding-PoE2`). Upstream sync is done by merging `origin/dev` locally, not via PRs. + +2. **Default push target is `fork`.** All `git push` and PR operations default to `fork/dev`. The git config enforces this via `remote.pushDefault=fork` and `branch.dev.remote=fork`. + +3. **Upstream sync is via local merge, not PR.** To sync with upstream: `git fetch origin && git merge origin/dev` on the local `dev` branch, then push to fork. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..9645daa7d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,94 @@ + +## MCP Tools: code-review-graph + +**IMPORTANT: This project has a knowledge graph. ALWAYS use the +code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore +the codebase.** The graph is faster, cheaper (fewer tokens), and gives +you structural context (callers, dependents, test coverage) that file +scanning cannot. + +### When to use graph tools FIRST + +- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep +- **Understanding impact**: `get_impact_radius` instead of manually tracing imports +- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files +- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for +- **Architecture questions**: `get_architecture_overview` + `list_communities` + +Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need. + +### Key Tools + +| Tool | Use when | +|------|----------| +| `detect_changes` | Reviewing code changes — gives risk-scored analysis | +| `get_review_context` | Need source snippets for review — token-efficient | +| `get_impact_radius` | Understanding blast radius of a change | +| `get_affected_flows` | Finding which execution paths are impacted | +| `query_graph` | Tracing callers, callees, imports, tests, dependencies | +| `semantic_search_nodes` | Finding functions/classes by name or keyword | +| `get_architecture_overview` | Understanding high-level codebase structure | +| `refactor_tool` | Planning renames, finding dead code | + +### Workflow + +1. The graph auto-updates on file changes (via hooks). +2. Use `detect_changes` for code review. +3. Use `get_affected_flows` to understand impact. +4. Use `query_graph` pattern="tests_for" to check coverage. + +--- + +# Path of Building 2 — Agent Instructions + +You are working on a fork of PathOfBuildingCommunity/PathOfBuilding-PoE2, an +offline build planner for Path of Exile 2. The codebase is ~100% Lua (5.1 / LuaJIT). + +## Critical rules + +1. **Never break numeric calculations.** PoB's value is in correctly modelling + PoE2's stat math. If you touch anything in `src/Modules/Calcs/` or `src/Modules/CalcSections.lua`, + you MUST add or update tests in `spec/System/` that assert specific stat values + within tolerance (±0.01 for DPS, exact for hit point pools). + +2. **Modifier parsing is fragile.** `src/Modules/ModParser.lua` is the single most + load-bearing file. Any change requires: + - Adding test cases for the new modifier strings in `spec/System/TestModParser.lua` + - Verifying existing test suite still passes (`docker compose run --rm tests`) + +3. **Data file changes need validation.** Game data lives in `src/Data/`. After + modifying any data file, run the data validation tests (`docker compose run --rm tests --tags data`). + +4. **Lua nil-safety is your responsibility.** Lua does not have a type system. + When you call `someTable.field.subfield`, guard with `someTable and someTable.field and someTable.field.subfield` + or use `rawget`. Nil reference crashes are the #1 source of bug reports in PoB. + +5. **Performance matters in calculation hot loops.** Avoid table allocations + inside `Calc:Build*` functions; reuse tables and use local variable caching + for frequently-accessed table fields. + +## How to test changes + +```bash +docker compose run --rm tests # full Busted suite +docker compose run --rm tests --tags builds # build snapshot tests (slow) +docker compose run --rm tests --tags data # data file validation only +docker compose run --rm tests --coverage # with luacov +``` + +## How to validate before opening a PR + +1. All tests pass: `docker compose run --rm tests` +2. Coverage did not drop: compare `luacov.report.out` against main +3. No new luac warnings: `luac -p src/**/*.lua` +4. The commit message follows Conventional Commits (`feat:`, `fix:`, `refactor:`, etc.) + +## Project structure + +- `src/` — main Lua source. Entrypoint is `src/Launch.lua`. +- `src/Modules/Calcs/` — the calculation engine. The most important code in the project. +- `src/Modules/ModParser.lua` — converts modifier strings into structured stat objects. +- `src/Data/` — game data: gems, items, passive tree, base item types. +- `spec/System/` — Busted test suite. +- `runtime/lua/` — extra Lua libraries used at runtime. +- `tests/` — Docker-based test harness. diff --git a/Dockerfile b/Dockerfile index d1abe3a90..a234bfcda 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,8 @@ RUN --mount=type=cache,from=luarocks,source=/opt,target=/opt make -C /opt/luaroc RUN luarocks install busted 2.2.0-1;\ luarocks install cluacov 0.1.2-1;\ luarocks install luacov-coveralls 0.2.3-1;\ - luarocks install luautf8 0.1.6-1 + luarocks install luautf8 0.1.6-1;\ + luarocks install luacheck RUN --mount=type=cache,from=emmyluadebugger,source=/opt,target=/opt make -C /opt/EmmyLuaDebugger/build/ install RUN --mount=type=cache,from=luajit,source=/opt,target=/opt make -C /opt/LuaJIT/ install diff --git a/README.md b/README.md index 8e4cc222f..417ccb793 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,77 @@ Head over to the [Releases](https://github.com/PathOfBuildingCommunity/PathOfBui * Share builds with other users by generating a share code * Automatic updating; most updates will only take a couple of seconds to apply +## Running Tests + +The test suite uses [Busted](https://lunarmodules.github.io/busted/) with LuaJIT and runs inside a pre-built Docker/Podman image. + +### Prerequisites + +You need either **Docker** or **Podman** installed and available in your PATH. No other local dependencies are required — the test image bundles LuaJIT, Busted, and LuaCov. + +### Run all tests + +**With Docker:** +```bash +docker run --rm \ + -e HOME=/tmp \ + -v "$(pwd)":/workdir:ro \ + -w /workdir \ + ghcr.io/pathofbuildingcommunity/pathofbuilding-tests:latest \ + busted --lua=luajit +``` + +**With Podman:** +```bash +podman run --rm \ + -e HOME=/tmp \ + -v "$(pwd)":/workdir:ro \ + -w /workdir \ + ghcr.io/pathofbuildingcommunity/pathofbuilding-tests:latest \ + busted --lua=luajit +``` + +**With Docker Compose** (if `docker compose` v2 is available): +```bash +docker compose run --rm busted-tests +``` + +### Run a subset of tests + +Pass a `--tags` filter to scope which tests run: + +```bash +# Build snapshot tests (slow) +... busted --lua=luajit --tags builds + +# Data file validation only +... busted --lua=luajit --tags data +``` + +### Run with coverage + +```bash +... busted --lua=luajit --coverage +``` + +Coverage output is written to `luacov.report.out`. Compare against `main` to ensure coverage does not drop. + +### Test layout + +| Path | Purpose | +|------|---------| +| `spec/System/` | Busted test suite (all `*_spec.lua` files) | +| `src/HeadlessWrapper.lua` | Test bootstrap / headless PoB initialiser | +| `.busted` | Busted configuration (working dir, helper, Lua path) | +| `docker-compose.yml` | Convenience wrapper for the test container | +| `Dockerfile` | Definition of the test image | + +### Notes + +- Tests must pass before opening a PR: `370 successes` is the baseline on `dev`. +- The two known failures in `TestWard_spec.lua` are in-progress work (Ward regen/bypass not yet implemented). +- Numeric calculation tests assert specific stat values within ±0.01 tolerance. + ## Changelog You can find the full version history [here](CHANGELOG.md). @@ -61,3 +132,37 @@ You can find instructions on how to contribute code and bug reports [here](CONTR For 3rd-party licences, see [LICENSE](LICENSE.md). The licencing information is considered to be part of the documentation. + +## AI Workflow Escape Hatches + +The automated PR workflow (AI fix → AI review → auto-merge) can be controlled as follows: + +**Disable AI workflows temporarily** +- Go to Actions → select the workflow → "..." menu → Disable workflow + +**Manually trigger an AI review on an existing PR** +```bash +gh workflow run ai-review.yml --repo jay9297/PathOfBuilding-PoE2 +``` + +**Override an AI review with a manual one** +- Simply post your own review comment on the PR — your approval is always required before merge regardless of the AI review verdict. + +**Rotate the `CLAUDE_CODE_OAUTH_TOKEN`** +```bash +# On your local machine (claude must be logged in via Pro): +claude setup-token +# Then update the secret: +gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo jay9297/SinsGuide +gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo jay9297/PathOfBuilding-PoE2 +``` + +**Rotate the `OPENCODE_API_KEY`** +```bash +# Get new key from https://opencode.ai console, then: +gh secret set OPENCODE_API_KEY --repo jay9297/SinsGuide +gh secret set OPENCODE_API_KEY --repo jay9297/PathOfBuilding-PoE2 +``` + +**Skip AI fix on an issue** +- Do not add the `ai-fix` label. The workflow only triggers on that label. diff --git a/docker-compose.yml b/docker-compose.yml index b01cfb360..9f1ee0985 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: environment: HOME: /tmp container_name: pathofbuilding-tests - command: busted --lua=luajit + entrypoint: ["busted", "--lua=luajit"] security_opt: - no-new-privileges:true ports: diff --git a/scripts/agent-lib.sh b/scripts/agent-lib.sh new file mode 100644 index 000000000..ffc15b64a --- /dev/null +++ b/scripts/agent-lib.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# Agent execution library — retry logic for ai-agent.sh and infrastructure ops. +# Source this file; do not execute it directly. +# +# ai-agent.sh exit codes: +# 0 = success +# 1 = all tiers exhausted (quota, rate limit, transient) → retry with backoff +# 2 = missing CLI or credentials → fail fast, no retry +# 3 = ANTHROPIC_API_KEY set (config error) → fail fast, no retry + +set -euo pipefail + +# --------------------------------------------------------------------------- +# retry_op +# Generic retry with exponential backoff for infrastructure operations. +# --------------------------------------------------------------------------- +retry_op() { + local max_attempts="$1" + local base_delay="$2" + shift 2 + local attempt=0 + local delay="$base_delay" + + while [ $attempt -lt $max_attempts ]; do + attempt=$((attempt + 1)) + echo "▶ Attempt $attempt/$max_attempts: $*" + if "$@"; then + return 0 + fi + if [ $attempt -lt $max_attempts ]; then + echo "⏳ Failed — retrying in ${delay}s..." + sleep "$delay" + delay=$((delay * 2)) + fi + done + + echo "::error::Command failed after $max_attempts attempts: $*" + return 1 +} + +# --------------------------------------------------------------------------- +# run_agent_with_fallback +# +# Calls ai-agent.sh which handles its own tier cascade internally. +# This function adds an outer retry loop for when all tiers are exhausted +# (typically an API outage or quota reset window). +# +# Retry strategy: +# Within a cycle: up to MAX_RETRIES attempts, 30s → 60s → 120s backoff +# Between cycles: 5min → 10min wait before cycling through tiers again +# Max cycles: MAX_CYCLES +# --------------------------------------------------------------------------- +run_agent_with_fallback() { + local prompt_file="$1" + local mode="$2" + local max_turns="$3" + + local MAX_RETRIES=3 + local BASE_DELAY=30 # doubles each retry within a cycle + local MAX_CYCLES=3 + local CYCLE_BASE_DELAY=300 # 5min; doubles between cycles + + # Read prompt into a variable to pass to ai-agent.sh. + # The 3000-line diff guard in the review workflow keeps this within safe limits. + local task + task=$(cat "$prompt_file") + + local cycle=0 + while [ $cycle -lt $MAX_CYCLES ]; do + cycle=$((cycle + 1)) + + if [ $cycle -gt 1 ]; then + local cycle_wait=$(( CYCLE_BASE_DELAY * (2 ** (cycle - 2)) )) + echo "::warning::All tiers exhausted on cycle $((cycle - 1)). Waiting ${cycle_wait}s before cycle $cycle..." + sleep "$cycle_wait" + fi + + local attempt=0 + local delay="$BASE_DELAY" + + while [ $attempt -lt $MAX_RETRIES ]; do + attempt=$((attempt + 1)) + echo "🔄 Cycle $cycle, attempt $attempt/$MAX_RETRIES" + + local exit_code=0 + ./scripts/ai-agent.sh "$task" \ + --mode "$mode" \ + --max-turns "$max_turns" || exit_code=$? + + case $exit_code in + 0) + echo "✅ Agent succeeded (cycle $cycle, attempt $attempt)" + return 0 + ;; + 2) + # Missing CLI or credentials — retrying won't help + echo "::error::Agent exited 2: CLI not found or credentials missing." + echo "::error::Check CLAUDE_CODE_OAUTH_TOKEN and OPENCODE_API_KEY secrets." + return 2 + ;; + 3) + # ANTHROPIC_API_KEY is set — configuration error, not transient + echo "::error::Agent exited 3: ANTHROPIC_API_KEY must not be set." + return 3 + ;; + 1) + # All internal tiers exhausted — quota, rate limit, or outage + if [ $attempt -lt $MAX_RETRIES ]; then + echo "⏳ All tiers exhausted — retrying in ${delay}s..." + sleep "$delay" + delay=$((delay * 2)) + fi + ;; + *) + echo "::warning::Unexpected exit code $exit_code — treating as transient" + if [ $attempt -lt $MAX_RETRIES ]; then + sleep "$delay" + delay=$((delay * 2)) + fi + ;; + esac + done + + echo "❌ Cycle $cycle exhausted" + done + + echo "::error::Agent failed across all $MAX_CYCLES retry cycles. Manual intervention required." + return 1 +} diff --git a/scripts/ai-agent.sh b/scripts/ai-agent.sh new file mode 100755 index 000000000..74843d3a8 --- /dev/null +++ b/scripts/ai-agent.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# AI agent wrapper with multi-tier fallback. +# +# Tier 1: Claude Code via Pro subscription (CLAUDE_CODE_OAUTH_TOKEN) +# Tier 2: OpenCode Go with task-appropriate model +# - review mode → opencode-go/glm-5.1 (heavy reasoning) +# - fix mode → opencode/deepseek-v4-flash-free (free tier) +# → opencode-go/qwen3.6-plus (Go subscription fallback) +# → opencode-go/deepseek-v4-flash (last resort) +# +# Verified model slugs as of opencode v1.15.7 (2026-05-25): +# opencode-go/glm-5.1 +# opencode/deepseek-v4-flash-free +# opencode-go/qwen3.6-plus +# opencode-go/deepseek-v4-flash +# +# Usage: ./ai-agent.sh "" [--mode review|fix] [--max-turns N] +# +# Required env: CLAUDE_CODE_OAUTH_TOKEN, OPENCODE_API_KEY +# MUST NOT BE SET: ANTHROPIC_API_KEY (would override Pro OAuth token and incur PAYG charges) + +set -euo pipefail + +TASK="${1:?usage: ai-agent.sh \"\" [--mode review|fix] [--max-turns N]}" +MODE="fix" +MAX_TURNS="25" + +shift +while [[ $# -gt 0 ]]; do + case "$1" in + --mode) MODE="$2"; shift 2 ;; + --max-turns) MAX_TURNS="$2"; shift 2 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +# Safety: ANTHROPIC_API_KEY must NOT be set, or Claude Code switches to pay-per-token +if [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then + echo "[ai-agent] FATAL: ANTHROPIC_API_KEY is set; this would override Pro subscription auth" >&2 + echo "[ai-agent] unset ANTHROPIC_API_KEY before continuing" >&2 + exit 3 +fi + +# Tool allowlist differs by mode. Reviews must not modify files. +if [[ "$MODE" == "review" ]]; then + ALLOWED_TOOLS="Read,Grep,Glob,Bash" + OPENCODE_MODEL_PRIMARY="${OPENCODE_MODEL_REVIEW:-opencode-go/glm-5.1}" + OPENCODE_MODEL_SECONDARY="" + OPENCODE_MODEL_TERTIARY="" +else + ALLOWED_TOOLS="Read,Write,Edit,Grep,Glob,Bash" + OPENCODE_MODEL_PRIMARY="${OPENCODE_MODEL_FIX_FREE:-opencode/deepseek-v4-flash-free}" + OPENCODE_MODEL_SECONDARY="${OPENCODE_MODEL_FIX_GO:-opencode-go/qwen3.6-plus}" + OPENCODE_MODEL_TERTIARY="${OPENCODE_MODEL_FIX_FALLBACK:-opencode-go/deepseek-v4-flash}" +fi + +is_quota_or_overload_error() { + local text="$1" + echo "$text" | grep -qiE 'usage_limit|rate_limit|overloaded|insufficient_quota|429|529|billing|payment_required|reached your usage limit|quota|free.*limit|daily.*limit' +} + +log() { echo "[ai-agent] $*" >&2; } + +try_claude_code() { + if ! command -v claude >/dev/null 2>&1; then + log "claude CLI not found, skipping tier 1" + return 2 + fi + if [[ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then + log "CLAUDE_CODE_OAUTH_TOKEN not set, skipping tier 1" + return 2 + fi + log "tier 1: Claude Code via Pro subscription" + local out exit_code + set +e + out=$(CLAUDE_CODE_OAUTH_TOKEN="$CLAUDE_CODE_OAUTH_TOKEN" \ + claude --print "$TASK" \ + --allowedTools "$ALLOWED_TOOLS" \ + --max-turns "$MAX_TURNS" \ + --output-format text \ + --dangerously-skip-permissions 2>&1) + exit_code=$? + set -e + if is_quota_or_overload_error "$out"; then + log "claude code: hit quota/overload, falling through to tier 2" + return 1 + fi + if [[ $exit_code -ne 0 ]]; then + log "claude code exited $exit_code, falling through" + log "stderr tail: $(echo "$out" | tail -5)" + return 1 + fi + echo "$out" + return 0 +} + +try_opencode_model() { + local model="$1" + if ! command -v opencode >/dev/null 2>&1; then + log "opencode CLI not found" + return 2 + fi + if [[ -z "${OPENCODE_API_KEY:-}" ]]; then + log "OPENCODE_API_KEY not set, skipping opencode" + return 2 + fi + log "tier 2: OpenCode with model $model" + local out exit_code + set +e + out=$(OPENCODE_API_KEY="$OPENCODE_API_KEY" \ + opencode run \ + --model "$model" \ + "$TASK" 2>&1) + exit_code=$? + set -e + if is_quota_or_overload_error "$out"; then + log "opencode $model: hit quota/limit, trying next model" + return 1 + fi + if [[ $exit_code -ne 0 ]]; then + log "opencode $model exited $exit_code, trying next model" + log "stderr tail: $(echo "$out" | tail -5)" + return 1 + fi + echo "$out" + return 0 +} + +# Tier 1: Claude Code via Pro subscription +if try_claude_code; then exit 0; fi + +# Tier 2: OpenCode with model cascade +if try_opencode_model "$OPENCODE_MODEL_PRIMARY"; then exit 0; fi +if [[ -n "$OPENCODE_MODEL_SECONDARY" ]]; then + if try_opencode_model "$OPENCODE_MODEL_SECONDARY"; then exit 0; fi +fi +if [[ -n "$OPENCODE_MODEL_TERTIARY" ]]; then + if try_opencode_model "$OPENCODE_MODEL_TERTIARY"; then exit 0; fi +fi + +log "all tiers exhausted — manual intervention needed" +exit 1 diff --git a/spec/System/TestImportTab_spec.lua b/spec/System/TestImportTab_spec.lua index b887aabd1..0a2c8f8ab 100644 --- a/spec/System/TestImportTab_spec.lua +++ b/spec/System/TestImportTab_spec.lua @@ -3,6 +3,57 @@ describe("ImportTab", function() newBuild() end) + it("parses [Ward|Runic Ward] property from GGG API data and sets armourData.Ward (import path)", function() + local importTab = build.importTab + -- Simulate a GGG API item with localized Ward property name + local mockItemData = { + typeLine = "Runeforged Serpentscale Coat", + name = "Empyrean Shelter", + frameType = 2, + inventoryId = "BodyArmour", + id = "body1", + ilvl = 36, + mirrored = false, + corrupted = false, + properties = { + { name = "[Ward|Runic Ward]", values = {{"104", 0}}, type = 104 }, + }, + } + -- Returns (item, slotName) + local item = importTab:ImportItem(mockItemData, "Body Armour") + assert.is_not_nil(item) + assert.are.equals(104, item.armourData.Ward) + end) + + it("parses [Ward|Runic Ward] property with a Ward rune mod without double-counting", function() + local importTab = build.importTab + -- GGG API item data that includes rune mods adding Ward + local mockItemData = { + typeLine = "Runeforged Itinerant Jacket", + name = "Loath Coat", + frameType = 2, + inventoryId = "BodyArmour", + id = "body2", + ilvl = 67, + mirrored = false, + corrupted = false, + properties = { + { name = "[Ward|Runic Ward]", values = {{"104", 0}}, type = 104 }, + { name = "[Evasion|Evasion Rating]", values = {{"157", 0}}, type = 4 }, + { name = "[EnergyShield|Energy Shield]", values = {{"61", 0}}, type = 6 }, + }, + runeMods = { + "{rune}{enchant}112% increased Ward\n+65 to maximum Ward", + }, + } + local item = importTab:ImportItem(mockItemData, "Body Armour") + assert.is_not_nil(item) + -- Property line value is authoritative for Ward; rune INC/flat mods must not be re-applied + assert.are.equals(104, item.armourData.Ward) + -- Rune mods should be parsed into runeModLines + assert.are.equals(2, #item.runeModLines) + end) + it("builds character lists for private Ruthless league names without a Ruthless tree", function() local importTab = build.importTab importTab.lastCharList = { diff --git a/spec/System/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index 2de3505d6..b11a4e998 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -460,6 +460,47 @@ describe("TestItemParse", function() assert.are.equals(6, item.jewelSocketCount) end) + + it("Runeforged prefix is stripped from base name and item parses Runic Ward", function() + local item = new("Item", [[ + Rarity: Rare + Empyrean Shelter + Runeforged Serpentscale Coat + -------- + Evasion Rating: 543 + Runic Ward: 83 + -------- + Item Level: 36 + ]]) + assert.are.equals("Serpentscale Coat", item.baseName) + assert.are.equals(83, item.armourData.Ward) + end) + + it("Has +N to Evasion Rating per player level parses to BASE Evasion with Level multiplier", function() + local item = new("Item", [[ + Rarity: Rare + Striker's Grip + Fists of Stone + -------- + Implicits: 3 + Has +2 to Evasion Rating per player level (implicit) + Has +1 to maximum Energy Shield per player level (implicit) + Has +1 to maximum Runic Ward per player level (implicit) + ]]) + -- All three implicit mod lines should be parsed (no extra/unsupported flag) + assert.are.equals(3, #item.implicitModLines) + assert.is_nil(item.implicitModLines[1].extra) + assert.is_nil(item.implicitModLines[2].extra) + assert.is_nil(item.implicitModLines[3].extra) + -- Check raw mod values + assert.are.equals(2, item.baseModList[1].value) + assert.are.equals(1, item.baseModList[2].value) + assert.are.equals(1, item.baseModList[3].value) + -- Check mod names map to correct stats + assert.are.equals("Evasion", item.baseModList[1].name) + assert.are.equals("EnergyShield", item.baseModList[2].name) + assert.are.equals("Ward", item.baseModList[3].name) + end) end) describe("TestAdvancedItemParse #item", function() @@ -650,4 +691,22 @@ describe("TestAdvancedItemParse #item", function() Note: ~b/o 2 chaos ]]) end) + + it("Runeforged item Ward survives BuildModList recalculation (paste path)", function() + local item = new("Item", [[ + Rarity: Rare + Empyrean Shelter + Runeforged Serpentscale Coat + -------- + Evasion Rating: 543 + Runic Ward: 83 + -------- + Item Level: 36 + ]]) + assert.are.equals(83, item.armourData.Ward) + -- BuildModList must preserve the property line value verbatim. + -- "Runic Ward: 83" is the game's final post-quality value; PoB must NOT re-apply quality. + item:BuildModList() + assert.are.equals(83, item.armourData.Ward) + end) end) \ No newline at end of file diff --git a/spec/System/TestStonefist_spec.lua b/spec/System/TestStonefist_spec.lua new file mode 100644 index 000000000..41147e186 --- /dev/null +++ b/spec/System/TestStonefist_spec.lua @@ -0,0 +1,131 @@ +describe("TestStonefist", function() + before_each(function() + newBuild() + end) + + teardown(function() + -- newBuild() takes care of resetting everything in setup() + end) + + -- ModParser: flag parsing + + it("GloveBaseTypeTransform flag is set from ascendancy mod string", function() + build.configTab.input.customMods = "\z + Gloves you equip have their base type transformed to fists of stone while equipped\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.is_true(build.calcsTab.mainEnv.modDB:Flag(nil, "GloveBaseTypeTransform")) + end) + + it("IgnoreAttributeRequirementsForGloves flag is set from ascendancy mod string", function() + build.configTab.input.customMods = "\z + Ignore attribute requirements to equip gloves\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.is_true(build.calcsTab.mainEnv.modDB:Flag(nil, "IgnoreAttributeRequirementsForGloves")) + end) + + it("GloveExplicitModTransform flag is set from ascendancy mod string", function() + build.configTab.input.customMods = "\z + their explicit modifiers are transformed into more powerful related modifiers\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.is_true(build.calcsTab.mainEnv.modDB:Flag(nil, "GloveExplicitModTransform")) + end) + + -- CalcPerform: base type transform overwrites glove armour values + + it("GloveBaseTypeTransform: equipping pure-evasion gloves gains Armour from Fists of Stone base", function() + -- Suede Bracers: Evasion only, no Armour stat + build.itemsTab:CreateDisplayItemFromRaw([[ + New Item + Suede Bracers + Evasion: 10 + ]]) + build.itemsTab:AddDisplayItem() + runCallback("OnFrame") + + local baseArmour = build.calcsTab.mainOutput.Armour or 0 + + -- Apply transform flag + build.configTab.input.customMods = "\z + Gloves you equip have their base type transformed to fists of stone while equipped\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + local transformedArmour = build.calcsTab.mainOutput.Armour or 0 + -- Fists of Stone base armour is 44; should exceed the evasion-only baseline + assert.is_true(transformedArmour > baseArmour, + ("expected transformed armour %d > base armour %d"):format(transformedArmour, baseArmour)) + assert.is_near(44, transformedArmour, 10) + end) + + it("GloveBaseTypeTransform: armour-only gloves take on Fists of Stone armour value (~44)", function() + -- Stocky Mitts base armour = 15; Fists of Stone base armour = 44 + build.itemsTab:CreateDisplayItemFromRaw([[ + New Item + Stocky Mitts + ]]) + build.itemsTab:AddDisplayItem() + runCallback("OnFrame") + + local baseArmour = build.calcsTab.mainOutput.Armour or 0 + + build.configTab.input.customMods = "\z + Gloves you equip have their base type transformed to fists of stone while equipped\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + local transformedArmour = build.calcsTab.mainOutput.Armour or 0 + -- Armour should change from Stocky Mitts base (~15) to Fists of Stone base (~44) + assert.are_not.equals(baseArmour, transformedArmour) + assert.is_near(44, transformedArmour, 10) + end) + + it("GloveBaseTypeTransform: Fists of Stone implicit injects Evasion per level into modDB", function() + build.itemsTab:CreateDisplayItemFromRaw([[ + New Item + Stocky Mitts + Armour: 10 + ]]) + build.itemsTab:AddDisplayItem() + runCallback("OnFrame") + + local baseEvasion = build.calcsTab.mainOutput.Evasion or 0 + + build.configTab.input.customMods = "\z + Gloves you equip have their base type transformed to fists of stone while equipped\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + -- Implicit: +2 Evasion per level; at level 1 that is +2, plus Fists of Stone base evasion (40) + local transformedEvasion = build.calcsTab.mainOutput.Evasion or 0 + assert.is_true(transformedEvasion > baseEvasion, + ("expected evasion %d > base evasion %d after Fists of Stone transform"):format(transformedEvasion, baseEvasion)) + end) + + -- CalcPerform: scoped attribute requirement ignore + + it("IgnoreAttributeRequirementsForGloves does not zero global attribute requirements", function() + -- The scoped flag should NOT zero requirements from non-glove sources + build.configTab.input.customMods = "\z + Ignore attribute requirements to equip gloves\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + -- Global flag should be nil; only the scoped flag should be set + local globalFlag = build.calcsTab.mainEnv.modDB:Flag(nil, "IgnoreAttributeRequirements") + assert.is_falsy(globalFlag) + assert.is_true(build.calcsTab.mainEnv.modDB:Flag(nil, "IgnoreAttributeRequirementsForGloves")) + end) +end) diff --git a/spec/System/TestWard_spec.lua b/spec/System/TestWard_spec.lua new file mode 100644 index 000000000..07b166c75 --- /dev/null +++ b/spec/System/TestWard_spec.lua @@ -0,0 +1,206 @@ +describe("TestWard", function() + before_each(function() + newBuild() + end) + + teardown(function() + -- newBuild() takes care of resetting everything in setup() + end) + + it("Ward stat from items", function() + build.configTab.input.customMods = "\z + +100 to Ward\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.are.equals(100, build.calcsTab.calcsOutput.Ward) + end) + + it("Ward increased modifier", function() + build.configTab.input.customMods = "\z + +100 to Ward\n\z + 50% increased Ward\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.are.equals(150, build.calcsTab.calcsOutput.Ward) + end) + + it("Ward regeneration", function() + build.configTab.input.customMods = "\z + +1000 to Ward\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + -- 5% per second = 300% per minute, so 1000 * 300/100/60 = 50 per second + assert.are.equals(50, build.calcsTab.calcsOutput.WardRegen) + end) + + it("Ward regeneration with increased", function() + build.configTab.input.customMods = "\z + +1000 to Ward\n\z + 100% increased Ward Regeneration\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + -- 50 base * (1 + 100/100) = 100 per second + assert.are.equals(100, build.calcsTab.calcsOutput.WardRegen) + end) + + it("Ward bypass", function() + build.configTab.input.customMods = "\z + +100 to Ward\n\z + 50% of Damage taken bypasses Ward\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.are.equals(50, build.calcsTab.calcsOutput.WardBypass) + end) + + it("Ward recharge delay", function() + build.configTab.input.customMods = "\z + +100 to Ward\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + -- Base ward recharge delay is 2 seconds + assert.are.equals(2, build.calcsTab.calcsOutput.WardRechargeDelay) + end) + + it("Ward recharge delay faster", function() + build.configTab.input.customMods = "\z + +100 to Ward\n\z + 50% faster Restoration of Ward\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + -- 2 / (1 + 50/100) = 2 / 1.5 = 1.33 + assert.is_near(1.33, build.calcsTab.calcsOutput.WardRechargeDelay, 0.01) + end) + + it("Runic Ward keyword maps to Ward (import alias)", function() + build.configTab.input.customMods = "\z + +100 to Runic Ward\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.are.equals(100, build.calcsTab.calcsOutput.Ward) + end) + + it("increased Runic Ward keyword maps to Ward INC", function() + build.configTab.input.customMods = "\z + +100 to Runic Ward\n\z + 50% increased Runic Ward\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.are.equals(150, build.calcsTab.calcsOutput.Ward) + end) + + it("faster Restoration of Runic Ward maps to ward recharge", function() + build.configTab.input.customMods = "\z + +100 to Runic Ward\n\z + 50% faster Restoration of Runic Ward\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + -- 2 / (1 + 50/100) = 1.33 + assert.is_near(1.33, build.calcsTab.calcsOutput.WardRechargeDelay, 0.01) + end) + + it("damage taken bypasses Runic Ward maps to WardBypass", function() + build.configTab.input.customMods = "\z + +100 to Runic Ward\n\z + 50% of Damage taken bypasses Runic Ward\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.are.equals(50, build.calcsTab.calcsOutput.WardBypass) + end) + + it("Ward config options", function() + build.configTab.input.customMods = "\z + +100 to Ward\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.are.equals(100, build.calcsTab.calcsOutput.Ward) + + build.configTab.input.conditionLowWard = true + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.is_true(build.calcsTab.calcsOutput.LowWard) + end) + + it("WardRegen is included in TotalNetRegen", function() + build.configTab.input.customMods = "\z + +1000 to Ward\n\z + 1 Physical Damage taken per second\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.are.equals(50, build.calcsTab.calcsOutput.WardRegen) + -- TotalNetRegen = WardRegen (50) - degen (1) = 49; tolerance of 2 for floor/rounding + assert.is_near(49, build.calcsTab.calcsOutput.TotalNetRegen, 2) + end) + + it("ward_rune_maximum_ward_+%_final maps as Ward MORE (not INC)", function() + -- Verify MORE stacks multiplicatively: 100 base * (1 + 0.5 INC) * (1 + 0.5 MORE) = 225 + build.configTab.input.customMods = "\z + +100 to Ward\n\z + 50% increased Ward\n\z + 50% more Ward\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.are.equals(225, build.calcsTab.calcsOutput.Ward) + end) + + it("Runeforged Ward from item with rune mods does not double-count (import path regression)", function() + -- Create an item through the build (paste a Runeforged item with rune-like Ward mod) + local item = new("Item", [[ + Rarity: Rare + Mock Runeforged Coat + Runeforged Serpentscale Coat + -------- + Runic Ward: 104 + -------- + Item Level: 67 + -------- + +65 to maximum Ward + ]]) + -- The game displays the FINAL ward value in the property line (post-quality, post-all-mods). + -- "Runic Ward: 104" means 104 is the game-computed final value. + -- PoB must NOT re-apply quality or add the +65 mod on top of the authoritative value. + -- Without fix: Ward = round((104+65) * 1.2) = 203 (double-counts flat mod and quality) + -- With fix (property line authoritative): Ward = 104 (no re-scaling) + item:BuildModList() + -- 104 = property line verbatim; the +65 mod is consumed (not re-applied) and quality is already baked in + assert.are.equals(104, item.armourData.Ward) + end) + + it("WardCoverOnMinionDeath stat ID parses correctly", function() + build.configTab.input.customMods = "\z + recover 10% of maximum ward on persistent minion death\n\z + " + build.configTab:BuildModList() + runCallback("OnFrame") + + assert.are.equals(10, build.calcsTab.calcsOutput.WardCoverOnMinionDeath) + end) +end) diff --git a/src/Classes/ImportTab.lua b/src/Classes/ImportTab.lua index c4c404f68..fa075e112 100644 --- a/src/Classes/ImportTab.lua +++ b/src/Classes/ImportTab.lua @@ -1032,7 +1032,7 @@ function ImportTabClass:ImportItem(itemData, slotName) item.rarity = rarityMap[itemData.frameType] if #itemData.name > 0 then item.title = sanitiseText(itemData.name) - item.baseName = sanitiseText(itemData.typeLine):gsub("Synthesised ","") + item.baseName = sanitiseText(itemData.typeLine):gsub("Synthesised ",""):gsub("Runeforged ","") item.name = item.title .. ", " .. item.baseName if item.baseName == "Two-Toned Boots" then -- Hack for Two-Toned Boots @@ -1101,23 +1101,26 @@ function ImportTabClass:ImportItem(itemData, slotName) item.jewelRadiusLabel = property.values[1][1] elseif property.name == "Limited to" then item.limit = tonumber(property.values[1][1]) - elseif property.name == "Evasion Rating" then + elseif escapeGGGString(property.name) == "Evasion Rating" then if item.baseName == "Two-Toned Boots (Armour/Energy Shield)" then -- Another hack for Two-Toned Boots item.baseName = "Two-Toned Boots (Armour/Evasion)" item.base = self.build.data.itemBases[item.baseName] end - elseif property.name == "Energy Shield" then + elseif escapeGGGString(property.name) == "Energy Shield" then if item.baseName == "Two-Toned Boots (Armour/Evasion)" then -- Yet another hack for Two-Toned Boots item.baseName = "Two-Toned Boots (Evasion/Energy Shield)" item.base = self.build.data.itemBases[item.baseName] end end - if property.name == "Energy Shield" or property.name == "Ward" or property.name == "Armour" or property.name == "Evasion Rating" then + local escapedPropertyName = escapeGGGString(property.name) + if escapedPropertyName == "Energy Shield" or escapedPropertyName == "Ward" or escapedPropertyName == "Runic Ward" or escapedPropertyName == "Armour" or escapedPropertyName == "Evasion Rating" then item.armourData = item.armourData or { } + local armourKey = escapedPropertyName:gsub(" Rating", ""):gsub(" ", "") + if armourKey == "RunicWard" then armourKey = "Ward" end for _, value in ipairs(property.values) do - item.armourData[property.name:gsub(" Rating", ""):gsub(" ", "")] = (item.armourData[property.name:gsub(" Rating", ""):gsub(" ", "")] or 0) + tonumber(value[1]) + item.armourData[armourKey] = (item.armourData[armourKey] or 0) + tonumber(value[1]) end end end @@ -1286,6 +1289,7 @@ function ImportTabClass:ImportItem(itemData, slotName) ConPrintf("Unrecognised slot name in imported item: %s", slotName) end end + return item end function ImportTabClass:ImportSocketedItems(item, socketedItems, slotName) diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index 846c19ceb..61d2078c2 100644 --- a/src/Classes/Item.lua +++ b/src/Classes/Item.lua @@ -579,7 +579,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) end elseif specName == "Talisman Tier" then self.talismanTier = specToNumber(specVal) - elseif specName == "Armour" or specName == "Evasion Rating" or specName == "Evasion" or specName == "Energy Shield" or specName == "Ward" then + elseif specName == "Armour" or specName == "Evasion Rating" or specName == "Evasion" or specName == "Energy Shield" or specName == "Ward" or specName == "Runic Ward" then if specName == "Evasion Rating" then specName = "Evasion" if self.baseName == "Two-Toned Boots (Armour/Energy Shield)" then @@ -594,6 +594,8 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) self.baseName = "Two-Toned Boots (Evasion/Energy Shield)" self.base = data.itemBases[self.baseName] end + elseif specName == "Runic Ward" then + specName = "Ward" end self.armourData = self.armourData or { } self.armourData[specName] = specToNumber(specVal) @@ -792,7 +794,7 @@ function ItemClass:ParseRaw(raw, rarity, highQuality) self.name = self.name:gsub(" %(.+%)","") end if not baseName then - baseName = line:gsub("^Superior ", "") + baseName = line:gsub("^Superior ", ""):gsub("^Runeforged ", "") end if baseName == "Two-Toned Boots" then baseName = "Two-Toned Boots (Armour/Energy Shield)" @@ -1776,13 +1778,26 @@ function ItemClass:BuildModListForSlotNum(baseList, slotNum) local evasionEnergyShieldBase = calcLocal(modList, "EvasionAndEnergyShield", "BASE", 0) local energyShieldBase = calcLocal(modList, "EnergyShield", "BASE", 0) + (self.base.armour.EnergyShield or 0) local armourEnergyShieldBase = calcLocal(modList, "ArmourAndEnergyShield", "BASE", 0) - local wardBase = calcLocal(modList, "Ward", "BASE", 0) + (self.base.armour.Ward or 0) + -- wardIsAuthoritative: true when armourData.Ward was pre-set from a game property line + -- (paste or API import). That value is the final game-computed ward (already includes flat + -- rune mods, INC rune mods, and quality). We must consume the local ward mods from modList + -- to prevent them from being double-applied in CalcDefence, but we must NOT re-scale the + -- already-final value by wardInc, defencesInc, or qualityScalar. + local wardIsAuthoritative = armourData.Ward ~= nil + local wardBase + if wardIsAuthoritative then + calcLocal(modList, "Ward", "BASE", 0) -- consume flat rune ward mods (discard result) + calcLocal(modList, "Ward", "INC", 0) -- consume INC rune ward mods (discard result) + wardBase = armourData.Ward + (self.base.armour.Ward or 0) + else + wardBase = calcLocal(modList, "Ward", "BASE", 0) + (self.base.armour.Ward or 0) + end local armourInc = calcLocal(modList, "Armour", "INC", 0) local armourEvasionInc = calcLocal(modList, "ArmourAndEvasion", "INC", 0) local evasionInc = calcLocal(modList, "Evasion", "INC", 0) local evasionEnergyShieldInc = calcLocal(modList, "EvasionAndEnergyShield", "INC", 0) local energyShieldInc = calcLocal(modList, "EnergyShield", "INC", 0) - local wardInc = calcLocal(modList, "Ward", "INC", 0) + local wardInc = wardIsAuthoritative and 0 or calcLocal(modList, "Ward", "INC", 0) local armourEnergyShieldInc = calcLocal(modList, "ArmourAndEnergyShield", "INC", 0) local defencesInc = calcLocal(modList, "Defences", "INC", 0) local qualityScalar = self.quality @@ -1793,7 +1808,9 @@ function ItemClass:BuildModListForSlotNum(baseList, slotNum) armourData.Armour = round((armourBase + armourEvasionBase + armourEnergyShieldBase) * (1 + (armourInc + armourEvasionInc + armourEnergyShieldInc + defencesInc) / 100) * (1 + (qualityScalar / 100))) armourData.Evasion = round((evasionBase + armourEvasionBase + evasionEnergyShieldBase) * (1 + (evasionInc + armourEvasionInc + evasionEnergyShieldInc + defencesInc) / 100) * (1 + (qualityScalar / 100))) armourData.EnergyShield = round((energyShieldBase + evasionEnergyShieldBase + armourEnergyShieldBase) * (1 + (energyShieldInc + armourEnergyShieldInc + evasionEnergyShieldInc + defencesInc) / 100) * (1 + (qualityScalar / 100))) - armourData.Ward = round((wardBase) * (1 + (wardInc + defencesInc) / 100) * (1 + (qualityScalar / 100))) + armourData.Ward = wardIsAuthoritative + and round(wardBase) -- property line is final (game already applied quality+INC); do not re-scale + or round((wardBase) * (1 + (wardInc + defencesInc) / 100) * (1 + (qualityScalar / 100))) if self.base.armour.BlockChance then armourData.BlockChance = m_floor((self.base.armour.BlockChance * (1 + calcLocal(modList, "BlockChance", "INC", 0) / 100) + calcLocal(modList, "BlockChance", "BASE", 0))) diff --git a/src/Data/Bases/gloves.lua b/src/Data/Bases/gloves.lua index 17f66889f..81a8a8a70 100644 --- a/src/Data/Bases/gloves.lua +++ b/src/Data/Bases/gloves.lua @@ -889,5 +889,15 @@ itemBases["Grand Manchettes"] = { armour = { Armour = 44, Evasion = 40, EnergyShield = 15, }, req = { level = 65, str = 32, dex = 32, int = 32, }, } +itemBases["Fists of Stone"] = { + type = "Gloves", + subType = "Armour/Evasion/Energy Shield", + quality = 20, + socketLimit = 3, + tags = { armour = true, default = true, gloves = true, str_dex_int_armour = true, }, + implicitModTypes = { }, + armour = { Armour = 44, Evasion = 40, EnergyShield = 15, }, + req = { }, +} diff --git a/src/Data/Misc.lua b/src/Data/Misc.lua index 59bd44539..814611a7b 100644 --- a/src/Data/Misc.lua +++ b/src/Data/Misc.lua @@ -118,6 +118,8 @@ data.gameConstants = { ["BriarpatchDurationSpeedWhenWet"] = -50, ["BriarpatchDamageTickSpeedWhenWet"] = 50, ["KeystoneAlternateESRecoveryPhysicalDamagePercentageRecoupedAsES"] = 5, + ["WardRegenRatePercentPerMinute"] = 300, -- 5% per second = 300% per minute + ["LowWardThreshold"] = 35, -- Low ward threshold at 35% of max } -- From Metadata/Characters/Character.ot data.characterConstants = { diff --git a/src/Data/ModCache.lua b/src/Data/ModCache.lua index 972478734..6246fe1d6 100644 --- a/src/Data/ModCache.lua +++ b/src/Data/ModCache.lua @@ -100,7 +100,7 @@ c["+1 to Level of all Fire Skills"]={{[1]={flags=0,keywordFlags=0,name="GemPrope c["+1 to Level of all Lightning Skills"]={{[1]={flags=0,keywordFlags=0,name="GemProperty",type="LIST",value={key="level",keyOfScaledMod="value",keyword="lightning",value=1}}},nil} c["+1 to Level of all Skills"]={{[1]={flags=0,keywordFlags=0,name="GemProperty",type="LIST",value={key="level",keyOfScaledMod="value",keyword="all",value=1}}},nil} c["+1 to Maximum Endurance Charges"]={{[1]={flags=0,keywordFlags=0,name="EnduranceChargesMax",type="BASE",value=1}},nil} -c["+1 to Maximum Energy Shield per 12 Item Evasion on Equipped Body Armour"]={{[1]={flags=0,keywordFlags=0,name="EnergyShield",type="BASE",value=1}}," per 12 Item Evasion on Equipped Body Armour "} +c["+1 to Maximum Energy Shield per 12 Item Evasion on Equipped Body Armour"]={{[1]={[1]={div=12,stat="EvasionOnBody Armour",type="PerStat"},flags=0,keywordFlags=0,name="EnergyShield",type="BASE",value=1}},nil} c["+1 to Maximum Energy Shield per 8 Item Armour on Equipped Helmet"]={{[1]={[1]={div=8,stat="ArmourOnHelmet",type="PerStat"},flags=0,keywordFlags=0,name="EnergyShield",type="BASE",value=1}},nil} c["+1 to Maximum Energy Shield per 8 Maximum Life"]={{[1]={[1]={div=8,stat="Life",type="PerStat"},flags=0,keywordFlags=0,name="EnergyShield",type="BASE",value=1}},nil} c["+1 to Maximum Frenzy Charges"]={{[1]={flags=0,keywordFlags=0,name="FrenzyChargesMax",type="BASE",value=1}},nil} @@ -955,8 +955,7 @@ c["1% increased Spirit Reservation Efficiency of Skills per 20 Tribute"]={{[1]={ c["1% increased damage taken per 10 Tribute"]={{[1]={[1]={actor="parent",div=10,stat="Tribute",type="PerStat"},flags=0,keywordFlags=0,name="DamageTaken",type="INC",value=1}},nil} c["1% increased maximum Darkness per 1% Chaos Resistance"]={{[1]={[1]={div=1,stat="ChaosResist",type="PerStat"},flags=0,keywordFlags=0,name="Darkness",type="INC",value=1}},nil} c["1% increased maximum Life"]={{[1]={flags=0,keywordFlags=0,name="Life",type="INC",value=1}},nil} -c["1% more Attack Speed per 75 Item Evasion on Equipped Armour Items"]={{[1]={flags=1,keywordFlags=0,name="Speed",type="MORE",value=1}}," per 75 Item Evasion on Equipped Armour Items "} -c["1% more Attack Speed per 75 Item Evasion on Equipped Armour Items +0.1% to Critical Hit Chance per 10 Item Energy Shield on Equipped Armour Items"]={{[1]={[1]={div=10,stat="EnergyShieldOnAllArmourItems",type="PerStat"},flags=1,keywordFlags=0,name="Speed",type="MORE",value=1}}," per 75 Item Evasion on Equipped Armour Items +0.1% to Critical Hit Chance "} +c["1% more Attack Speed per 75 Item Evasion on Equipped Armour Items"]={{[1]={[1]={div=75,stat="EvasionOnAllArmourItems",type="PerStat"},flags=1,keywordFlags=0,name="Speed",type="MORE",value=1}},nil} c["1% of Maximum Life Converted to Energy Shield per 20 Tribute"]={{[1]={[1]={actor="parent",div=20,stat="Tribute",type="PerStat"},flags=0,keywordFlags=0,name="LifeConvertToEnergyShield",type="BASE",value=1}},nil} c["1% of damage taken Recouped as Life per 10 Tribute"]={{[1]={[1]={actor="parent",div=10,stat="Tribute",type="PerStat"},flags=0,keywordFlags=0,name="LifeRecoup",type="BASE",value=1}},nil} c["1% of damage taken Recouped as Mana per 10 Tribute"]={{[1]={[1]={actor="parent",div=10,stat="Tribute",type="PerStat"},flags=0,keywordFlags=0,name="ManaRecoup",type="BASE",value=1}},nil} @@ -3722,9 +3721,7 @@ c["Base Critical Hit Chance for Attacks with Weapons is 7%"]={{[1]={flags=0,keyw c["Base Critical Hit Chance for Attacks with Weapons is 8%"]={{[1]={flags=0,keywordFlags=0,name="WeaponBaseCritChance",type="OVERRIDE",value=8}},nil} c["Base Critical Hit Chance for Spells is 15%"]={{[1]={[1]={skillType=2,type="SkillType"},flags=0,keywordFlags=0,name="CritChanceBase",type="OVERRIDE",value=15}},nil} c["Base Maximum Darkness is 100"]={{[1]={flags=0,keywordFlags=0,name="PlayerHasDarkness",type="FLAG",value=true},[2]={flags=0,keywordFlags=0,name="Darkness",type="BASE",value=100}},nil} -c["Base Unarmed Physical damage replaced with damage based on their Skill Level"]={nil,"Base Unarmed Physical damage replaced with damage based on their Skill Level "} -c["Base Unarmed Physical damage replaced with damage based on their Skill Level 1% more Attack Speed per 75 Item Evasion on Equipped Armour Items"]={nil,"Base Unarmed Physical damage replaced with damage based on their Skill Level 1% more Attack Speed per 75 Item Evasion on Equipped Armour Items "} -c["Base Unarmed Physical damage replaced with damage based on their Skill Level 1% more Attack Speed per 75 Item Evasion on Equipped Armour Items +0.1% to Critical Hit Chance per 10 Item Energy Shield on Equipped Armour Items"]={nil,"Base Unarmed Physical damage replaced with damage based on their Skill Level 1% more Attack Speed per 75 Item Evasion on Equipped Armour Items +0.1% to Critical Hit Chance per 10 Item Energy Shield on Equipped Armour Items "} +c["Base Unarmed Physical damage replaced with damage based on their Skill Level"]={{},nil} c["Bear Skills Convert 80% of Physical Damage to Fire Damage"]={nil,"Bear Skills Convert 80% of Physical Damage to Fire Damage "} c["Bear Skills Convert 80% of Physical Damage to Fire Damage Skills which require Glory generate 5 Glory every 2 seconds"]={nil,"Bear Skills Convert 80% of Physical Damage to Fire Damage Skills which require Glory generate 5 Glory every 2 seconds "} c["Bear Spirit gains Embrace of the Wild"]={nil,"Bear Spirit gains Embrace of the Wild "} @@ -3795,11 +3792,7 @@ c["Can Allocate Passive Skills from the Sorceress's starting point"]={nil,"Can A c["Can Allocate Passive Skills from the Sorceress's starting point Grants 4 Passive Skill Points"]={nil,"Can Allocate Passive Skills from the Sorceress's starting point Grants 4 Passive Skill Points "} c["Can Allocate Passive Skills from the Warrior's starting point"]={nil,"Can Allocate Passive Skills from the Warrior's starting point "} c["Can Allocate Passive Skills from the Warrior's starting point Grants 4 Passive Skill Points"]={nil,"Can Allocate Passive Skills from the Warrior's starting point Grants 4 Passive Skill Points "} -c["Can Attack as though using a Quarterstaff while both of your hand slots are empty"]={nil,"Can Attack as though using a Quarterstaff while both of your hand slots are empty "} -c["Can Attack as though using a Quarterstaff while both of your hand slots are empty Unarmed Attacks that would use an Equipped Quarterstaff's damage have:"]={nil,"Can Attack as though using a Quarterstaff while both of your hand slots are empty Unarmed Attacks that would use an Equipped Quarterstaff's damage have: "} -c["Can Attack as though using a Quarterstaff while both of your hand slots are empty Unarmed Attacks that would use an Equipped Quarterstaff's damage have: Base Unarmed Physical damage replaced with damage based on their Skill Level"]={nil,"Can Attack as though using a Quarterstaff while both of your hand slots are empty Unarmed Attacks that would use an Equipped Quarterstaff's damage have: Base Unarmed Physical damage replaced with damage based on their Skill Level "} -c["Can Attack as though using a Quarterstaff while both of your hand slots are empty Unarmed Attacks that would use an Equipped Quarterstaff's damage have: Base Unarmed Physical damage replaced with damage based on their Skill Level 1% more Attack Speed per 75 Item Evasion on Equipped Armour Items"]={nil,"Can Attack as though using a Quarterstaff while both of your hand slots are empty Unarmed Attacks that would use an Equipped Quarterstaff's damage have: Base Unarmed Physical damage replaced with damage based on their Skill Level 1% more Attack Speed per 75 Item Evasion on Equipped Armour Items "} -c["Can Attack as though using a Quarterstaff while both of your hand slots are empty Unarmed Attacks that would use an Equipped Quarterstaff's damage have: Base Unarmed Physical damage replaced with damage based on their Skill Level 1% more Attack Speed per 75 Item Evasion on Equipped Armour Items +0.1% to Critical Hit Chance per 10 Item Energy Shield on Equipped Armour Items"]={nil,"Can Attack as though using a Quarterstaff while both of your hand slots are empty Unarmed Attacks that would use an Equipped Quarterstaff's damage have: Base Unarmed Physical damage replaced with damage based on their Skill Level 1% more Attack Speed per 75 Item Evasion on Equipped Armour Items +0.1% to Critical Hit Chance per 10 Item Energy Shield on Equipped Armour Items "} +c["Can Attack as though using a Quarterstaff while both of your hand slots are empty"]={{},nil} c["Can Socket a non-Unique Basic Jewel into the Phylactery"]={{},nil} c["Can be modified while Corrupted"]={{},nil} c["Can have 2 additional Instilled Modifiers"]={{},nil} @@ -4485,9 +4478,9 @@ c["Glorifying the defilement of 8000 souls in tribute to Kulemak"]={{[1]={flags= c["Glorifying the defilement of 8000 souls in tribute to Kurgal"]={{[1]={flags=0,keywordFlags=0,name="JewelData",type="LIST",value={key="conqueredBy",value={conqueror={id=3,type="abyss"},id=8000}}}},nil} c["Glorifying the defilement of 8000 souls in tribute to Tecrod"]={{[1]={flags=0,keywordFlags=0,name="JewelData",type="LIST",value={key="conqueredBy",value={conqueror={id=4,type="abyss"},id=8000}}}},nil} c["Glorifying the defilement of 8000 souls in tribute to Ulaman"]={{[1]={flags=0,keywordFlags=0,name="JewelData",type="LIST",value={key="conqueredBy",value={conqueror={id=5,type="abyss"},id=8000}}}},nil} -c["Gloves you equip have their Base Type transformed to Fists of Stone while equipped, and"]={nil,"Gloves you equip have their Base Type transformed to Fists of Stone while equipped, and "} -c["Gloves you equip have their Base Type transformed to Fists of Stone while equipped, and their Explicit Modifiers are transformed into more powerful related Modifiers"]={nil,"Gloves you equip have their Base Type transformed to Fists of Stone while equipped, and their Explicit Modifiers are transformed into more powerful related Modifiers "} -c["Gloves you equip have their Base Type transformed to Fists of Stone while equipped, and their Explicit Modifiers are transformed into more powerful related Modifiers Ignore Attribute Requirements to equip Gloves"]={nil,"Gloves you equip have their Base Type transformed to Fists of Stone while equipped, and their Explicit Modifiers are transformed into more powerful related Modifiers Ignore Attribute Requirements to equip Gloves "} +c["Gloves you equip have their Base Type transformed to Fists of Stone while equipped, and"]={{[1]={flags=0,keywordFlags=0,name="GloveBaseTypeTransform",type="FLAG",value=true}},nil} +c["Gloves you equip have their Base Type transformed to Fists of Stone while equipped, and their Explicit Modifiers are transformed into more powerful related Modifiers"]={{[1]={flags=0,keywordFlags=0,name="GloveBaseTypeTransform",type="FLAG",value=true},[2]={flags=0,keywordFlags=0,name="GloveExplicitModTransform",type="FLAG",value=true}},nil} +c["Gloves you equip have their Base Type transformed to Fists of Stone while equipped, and their Explicit Modifiers are transformed into more powerful related Modifiers Ignore Attribute Requirements to equip Gloves"]={{[1]={flags=0,keywordFlags=0,name="GloveBaseTypeTransform",type="FLAG",value=true},[2]={flags=0,keywordFlags=0,name="GloveExplicitModTransform",type="FLAG",value=true},[3]={flags=0,keywordFlags=0,name="IgnoreAttributeRequirementsForGloves",type="FLAG",value=true}},nil} c["Grants 1 Passive Skill Point"]={{[1]={flags=0,keywordFlags=0,name="ExtraPoints",type="BASE",value=1}},nil} c["Grants 1 additional Skill Slot"]={{[1]={flags=0,keywordFlags=0,name="SkillSlots",type="BASE",value=1}},nil} c["Grants 4 Passive Skill Points"]={{[1]={flags=0,keywordFlags=0,name="ExtraPoints",type="BASE",value=4}},nil} @@ -5500,10 +5493,7 @@ c["Trusted Kinship"]={{[1]={flags=0,keywordFlags=0,name="Keystone",type="LIST",v c["Unaffected by Chill during Dodge Roll"]={nil,"Unaffected by Chill during Dodge Roll "} c["Unaffected by Chill while Leeching Mana"]={{[1]={[1]={type="Condition",var="LeechingMana"},flags=0,keywordFlags=0,name="SelfChillEffect",type="MORE",value=-100}},nil} c["Unaffected by Elemental Weakness"]={nil,"Unaffected by Elemental Weakness "} -c["Unarmed Attacks that would use an Equipped Quarterstaff's damage have:"]={nil,"Unarmed Attacks that would use an Equipped Quarterstaff's damage have: "} -c["Unarmed Attacks that would use an Equipped Quarterstaff's damage have: Base Unarmed Physical damage replaced with damage based on their Skill Level"]={nil,"Unarmed Attacks that would use an Equipped Quarterstaff's damage have: Base Unarmed Physical damage replaced with damage based on their Skill Level "} -c["Unarmed Attacks that would use an Equipped Quarterstaff's damage have: Base Unarmed Physical damage replaced with damage based on their Skill Level 1% more Attack Speed per 75 Item Evasion on Equipped Armour Items"]={nil,"Unarmed Attacks that would use an Equipped Quarterstaff's damage have: Base Unarmed Physical damage replaced with damage based on their Skill Level 1% more Attack Speed per 75 Item Evasion on Equipped Armour Items "} -c["Unarmed Attacks that would use an Equipped Quarterstaff's damage have: Base Unarmed Physical damage replaced with damage based on their Skill Level 1% more Attack Speed per 75 Item Evasion on Equipped Armour Items +0.1% to Critical Hit Chance per 10 Item Energy Shield on Equipped Armour Items"]={nil,"Unarmed Attacks that would use an Equipped Quarterstaff's damage have: Base Unarmed Physical damage replaced with damage based on their Skill Level 1% more Attack Speed per 75 Item Evasion on Equipped Armour Items +0.1% to Critical Hit Chance per 10 Item Energy Shield on Equipped Armour Items "} +c["Unarmed Attacks that would use an Equipped Quarterstaff's damage have:"]={{},nil} c["Undead Minions have 25% less maximum Life"]={{[1]={[1]={skillType=127,type="SkillType"},flags=0,keywordFlags=0,name="MinionModifier",type="LIST",value={mod={flags=0,keywordFlags=0,name="Life",type="MORE",value=-25}}}},nil} c["Unique Tamed Beasts are Possessed by random Azmeri Spirits, changing every 20 seconds"]={nil,"Unique Tamed Beasts are Possessed by random Azmeri Spirits, changing every 20 seconds "} c["Unique Tamed Beasts have 30% increased movement speed"]={nil,"Unique Tamed Beasts have 30% increased movement speed "} diff --git a/src/Data/SkillStatMap.lua b/src/Data/SkillStatMap.lua index 8aacda561..1d0020258 100644 --- a/src/Data/SkillStatMap.lua +++ b/src/Data/SkillStatMap.lua @@ -3070,4 +3070,35 @@ return { ["quality_display_freezing_mark_is_gem"] = { -- Display Only }, +-- Runic Ward stat mappings +["base_maximum_ward"] = { + mod("Ward", "BASE"), +}, +["maximum_ward_+%"] = { + mod("Ward", "INC"), +}, +["base_ward_regeneration_per_minute"] = { + mod("WardRegen", "BASE"), +}, +["base_ward_regeneration_per_minute_+%"] = { + mod("WardRegen", "INC"), +}, +["base_ward_cost_efficiency"] = { + mod("WardCostEfficiency", "BASE"), +}, +["base_ward_cost_efficiency_+%"] = { + mod("WardCostEfficiency", "INC"), +}, +["recover_x_ward_on_block"] = { + mod("WardRecoverOnBlock", "BASE"), +}, +["recover_x_ward_on_charm_use"] = { + mod("WardRecoverOnCharmUse", "BASE"), +}, +["ward_regeneration_rate_+%"] = { + mod("WardRegen", "INC"), +}, +["ward_rune_maximum_ward_+%_final"] = { + mod("Ward", "MORE"), +}, } diff --git a/src/Data/StatDescriptions/stat_descriptions.lua b/src/Data/StatDescriptions/stat_descriptions.lua index 844269fad..7c4ae2cc2 100644 --- a/src/Data/StatDescriptions/stat_descriptions.lua +++ b/src/Data/StatDescriptions/stat_descriptions.lua @@ -29057,13 +29057,22 @@ return { [2]="#" } }, - text="{0:+d} to Ward" + text="{0:+d} to Ward" + }, + [2]={ + limit={ + [1]={ + [1]="#", + [2]="#" + } + }, + text="{0:+d} to Runic Ward" + } + }, + stats={ + [1]="base_ward" } }, - stats={ - [1]="base_ward" - } - }, [1376]={ [1]={ [1]={ @@ -29074,6 +29083,15 @@ return { } }, text="{0:+d} to Ward" + }, + [2]={ + limit={ + [1]={ + [1]="#", + [2]="#" + } + }, + text="{0:+d} to Runic Ward" } }, stats={ @@ -29103,6 +29121,28 @@ return { } }, text="{0}% reduced Ward" + }, + [3]={ + limit={ + [1]={ + [1]=1, + [2]="#" + } + }, + text="{0}% increased Runic Ward" + }, + [4]={ + [1]={ + k="negate", + v=1 + }, + limit={ + [1]={ + [1]="#", + [2]=-1 + } + }, + text="{0}% reduced Runic Ward" } }, stats={ @@ -29132,6 +29172,28 @@ return { } }, text="{0}% reduced Ward" + }, + [3]={ + limit={ + [1]={ + [1]=1, + [2]="#" + } + }, + text="{0}% increased Runic Ward" + }, + [4]={ + [1]={ + k="negate", + v=1 + }, + limit={ + [1]={ + [1]="#", + [2]=-1 + } + }, + text="{0}% reduced Runic Ward" } }, stats={ @@ -29161,6 +29223,28 @@ return { } }, text="{0}% slower Restoration of Ward" + }, + [3]={ + limit={ + [1]={ + [1]=1, + [2]="#" + } + }, + text="{0}% faster Restoration of Runic Ward" + }, + [4]={ + [1]={ + k="negate", + v=1 + }, + limit={ + [1]={ + [1]="#", + [2]=-1 + } + }, + text="{0}% slower Restoration of Runic Ward" } }, stats={ @@ -37569,6 +37653,15 @@ return { } }, text="Gain Added Chaos Damage equal to {0}% of Ward" + }, + [2]={ + limit={ + [1]={ + [1]=1, + [2]="#" + } + }, + text="Gain Added Chaos Damage equal to {0}% of Runic Ward" } }, stats={ @@ -100934,6 +101027,15 @@ return { } }, text="{0}% of Damage taken bypasses Ward" + }, + [2]={ + limit={ + [1]={ + [1]="#", + [2]="#" + } + }, + text="{0}% of Damage taken bypasses Runic Ward" } }, stats={ diff --git a/src/Export/Scripts/modequivalencies.lua b/src/Export/Scripts/modequivalencies.lua new file mode 100644 index 000000000..e8365bb46 --- /dev/null +++ b/src/Export/Scripts/modequivalencies.lua @@ -0,0 +1,40 @@ +-- Export script: ModEquivalencies +-- Reads ModEquivalencies.datc64 and writes src/Data/ModEquivalencies.lua +-- Run via the PoB export tool (same way as other Scripts/*.lua files) +-- +-- ModEquivalencies maps each source mod ID to its upgraded equivalent. +-- Used by Way of the Stonefist (CalcPerform) to transform glove explicit mods. +-- +-- Expected datc64 schema (PoE2): +-- ModsKey : ref to Mods (the source mod being replaced) +-- EquivalentModsKey: ref to Mods (the upgraded replacement mod) + +local out = io.open("../Data/ModEquivalencies.lua", "w") +out:write("-- This file is automatically generated, do not edit!\n") +out:write("-- Item data (c) Grinding Gear Games\n\n") +out:write("-- Maps source mod ID -> upgraded equivalent mod ID\n") +out:write("-- Used by Way of the Stonefist to transform glove explicit modifiers.\n") +out:write("return {\n") + +local count = 0 +for row in dat("ModEquivalencies"):Rows() do + -- The source mod and its upgraded equivalent. + -- Field names reflect the PoE2 datc64 schema; if extraction fails, + -- check the schema in the export tool and update field names below. + local srcMod = row.ModsKey + local dstMod = row.EquivalentModsKey + + if srcMod and dstMod and srcMod.Id and dstMod.Id then + out:write('\t["', srcMod.Id, '"] = "', dstMod.Id, '",\n') + count = count + 1 + end +end + +out:write("}\n") +out:close() + +ConPrintf("ModEquivalencies: exported %d entries to ../Data/ModEquivalencies.lua", count) +if count == 0 then + ConPrintf("WARNING: 0 entries written. Check field names ModsKey/EquivalentModsKey match the datc64 schema.") + ConPrintf("Run: dat('ModEquivalencies'):Rows() and inspect row fields manually if needed.") +end diff --git a/src/Modules/BuildDisplayStats.lua b/src/Modules/BuildDisplayStats.lua index 538efd46c..074ddc5a9 100644 --- a/src/Modules/BuildDisplayStats.lua +++ b/src/Modules/BuildDisplayStats.lua @@ -148,6 +148,12 @@ local displayStats = { { stat = "EnergyShieldLeechGainRate", label = "ES Leech/On Hit Rate", color = colorCodes.ES, fmt = ".1f", compPercent = true }, { stat = "EnergyShieldLeechGainPerHit", label = "ES Leech/Gain per Hit", color = colorCodes.ES, fmt = ".1f", compPercent = true }, { }, + { stat = "Ward", label = "Runic Ward", fmt = "d", color = colorCodes.WARD, compPercent = true }, + { stat = "WardRegen", label = "Ward Regen", color = colorCodes.WARD, fmt = ".1f", condFunc = function(v,o) return v ~= 0 end }, + { stat = "WardBypass", label = "Ward Bypass", color = colorCodes.WARD, fmt = "d%%", lowerIsBetter = true, condFunc = function(v,o) return (v or 0) > 0 end }, + { stat = "WardRecoverOnBlock", label = "Ward on Block", color = colorCodes.WARD, fmt = "d", condFunc = function(v,o) return (v or 0) > 0 end }, + { stat = "WardCoverOnMinionDeath", label = "Ward on Minion Death", color = colorCodes.WARD, fmt = "d%%", condFunc = function(v,o) return (v or 0) > 0 end }, + { }, { stat = "Rage", label = "Rage", fmt = "d", color = colorCodes.RAGE, compPercent = true }, { stat = "RageRegenRecovery", label = "Rage Regen", fmt = ".1f", color = colorCodes.RAGE, compPercent = true }, { }, diff --git a/src/Modules/CalcActiveSkill.lua b/src/Modules/CalcActiveSkill.lua index cfea75c78..9a0d237e2 100644 --- a/src/Modules/CalcActiveSkill.lua +++ b/src/Modules/CalcActiveSkill.lua @@ -809,9 +809,9 @@ function calcs.buildActiveSkillModList(env, activeSkill) -- Hollow Palm Technique added phys for skills that would use Quarterstaff if activeSkill.actor.modDB.conditions.HollowPalm and ((activeEffect.grantedEffect.weaponTypes and activeEffect.grantedEffect.weaponTypes.Staff) or skillModList:Flag(activeSkill.skillCfg, "UseHollowPalmDamage")) then - local gemLevel = activeEffect.level - local physMin = data.hollowPalmAddedPhys[gemLevel and gemLevel or 1][1] - local physMax = data.hollowPalmAddedPhys[gemLevel and gemLevel or 1][2] + local gemLevel = math.min(activeEffect.level or 1, #data.hollowPalmAddedPhys) + local physMin = data.hollowPalmAddedPhys[gemLevel][1] + local physMax = data.hollowPalmAddedPhys[gemLevel][2] skillModList:NewMod("PhysicalMin", "BASE", physMin, "Hollow Palm Technique", ModFlag.Attack, nil, { type = "Condition", var = "HollowPalm" }) skillModList:NewMod("PhysicalMax", "BASE", physMax, "Hollow Palm Technique", ModFlag.Attack, nil, { type = "Condition", var = "HollowPalm" }) end diff --git a/src/Modules/CalcDefence.lua b/src/Modules/CalcDefence.lua index a48d96f92..ea27542e8 100644 --- a/src/Modules/CalcDefence.lua +++ b/src/Modules/CalcDefence.lua @@ -566,10 +566,13 @@ function calcs.reducePoolsByDamage(poolTable, damageTable, actor) resourcesLostToTypeDamage[damageType].sharedGuard = tempDamage >= 1 and tempDamage or nil end if ward > 0 then - local tempDamage = m_min(damageRemainder * (1 - (modDB:Sum("BASE", nil, "WardBypass") or 0) / 100), ward) - ward = ward - tempDamage - damageRemainder = damageRemainder - tempDamage - resourcesLostToTypeDamage[damageType].ward = tempDamage >= 1 and tempDamage or nil + local wardBypassFraction = (modDB:Sum("BASE", nil, "WardBypass") or 0) / 100 + local runeWardDrainMult = 1 + (output.RuneWardDamageTaken or 0) / 100 + -- Player is shielded for absorbedByPlayer; ward pool loses absorbedByPlayer * drainMult + local absorbedByPlayer = m_min(damageRemainder * (1 - wardBypassFraction), ward / runeWardDrainMult) + ward = ward - absorbedByPlayer * runeWardDrainMult + damageRemainder = damageRemainder - absorbedByPlayer + resourcesLostToTypeDamage[damageType].ward = absorbedByPlayer >= 1 and absorbedByPlayer or nil end damageRemaindersBeforeES[damageType] = damageRemainder > 0 and damageRemainder or nil end @@ -1855,6 +1858,88 @@ function calcs.defence(env, actor) end end + -- Ward regeneration + local wardRegenFlatPerMin = modDB:Sum("BASE", nil, "WardRegen") or 0 + local wardRegenBase = data.gameConstants["WardRegenRatePercentPerMinute"] / 100 / 60 * output.Ward + + wardRegenFlatPerMin / 60 -- flat bonus is in per-minute, convert to per-second + local wardRegenInc = modDB:Sum("INC", nil, "WardRegen") + local wardRegenMore = modDB:More(nil, "WardRegen") + output.WardRegen = m_max(m_floor(wardRegenBase * (1 + wardRegenInc / 100) * wardRegenMore), 0) + if breakdown and output.WardRegen > 0 then + breakdown.WardRegen = { + s_format("%.2f ^8(base per second)", wardRegenBase), + wardRegenFlatPerMin > 0 and s_format("+ %.2f ^8(flat per second)", wardRegenFlatPerMin / 60) or nil, + s_format("x %.2f ^8(increased)", 1 + wardRegenInc / 100), + wardRegenMore ~= 1 and s_format("x %.2f ^8(more)", wardRegenMore) or nil, + s_format("= %.2f ^8(per second)", output.WardRegen) + } + end + + -- Ward condition flags + if output.Ward == 0 or modDB:Flag(nil, "Condition:NoWard") then + output.NoWard = true + condList["NoWard"] = true + end + if modDB:Flag(nil, "Condition:LowWard") then + output.LowWard = true + end + if modDB:Flag(nil, "Condition:MissingWard") then + output.MissingWard = true + end + -- FullWard: auto-set when ward is present and no negative ward condition is forced + if output.Ward > 0 and not env.configInput["conditionLowWard"] and not env.configInput["conditionMissingWard"] and not env.configInput["conditionNoWard"] then + condList["FullWard"] = true + output.FullWard = true + elseif modDB:Flag(nil, "Condition:FullWard") then + output.FullWard = true + end + + -- Ward bypass + local wardBypass = modDB:Sum("BASE", nil, "WardBypass") or 0 + if wardBypass > 0 then + output.WardBypass = wardBypass + end + + -- Ward recovery stats + local wardRecoverOnBlock = modDB:Sum("BASE", nil, "WardRecoverOnBlock") + if wardRecoverOnBlock > 0 then + output.WardRecoverOnBlock = wardRecoverOnBlock + end + local wardRecoverOnCharmUse = modDB:Sum("BASE", nil, "WardRecoverOnCharmUse") + if wardRecoverOnCharmUse > 0 then + output.WardRecoverOnCharmUse = wardRecoverOnCharmUse + end + + -- Ward behavioural flags (mechanics not yet fully simulated) + output.ExcessWardToMana = modDB:Flag(nil, "ExcessWardToMana") or nil + output.WardRegenInsteadOfLife = modDB:Flag(nil, "WardRegenInsteadOfLife") or nil + output.WardOverflow = modDB:Flag(nil, "WardOverflow") or nil + output.WardBeforeLife = modDB:Flag(nil, "WardBeforeLife") or nil + + -- Rune Ward damage stats (displayed; full rune ward damage pipeline not yet simulated) + local runeWardBlockDamage = modDB:Sum("BASE", nil, "RuneWardBlockDamage") + if runeWardBlockDamage > 0 then + output.RuneWardBlockDamage = runeWardBlockDamage + end + local runeWardDamageTaken = modDB:Sum("INC", nil, "RuneWardDamageTaken") + if runeWardDamageTaken ~= 0 then + output.RuneWardDamageTaken = runeWardDamageTaken + end + + -- Ward cost stats (displayed; skill cost mechanic not yet implemented) + local wardCostEfficiency = modDB:Sum("BASE", nil, "WardCostEfficiency") + if wardCostEfficiency ~= 0 then + output.WardCostEfficiency = wardCostEfficiency + end + local wardAttackHitPercent = modDB:Sum("BASE", nil, "WardAttackHitPercent") + if wardAttackHitPercent > 0 then + output.WardAttackHitPercent = wardAttackHitPercent + end + local wardCoverOnMinionDeath = modDB:Sum("BASE", nil, "WardCoverOnMinionDeath") + if wardCoverOnMinionDeath > 0 then + output.WardCoverOnMinionDeath = wardCoverOnMinionDeath + end + -- Damage Reduction output.DamageReductionMax = modDB:Max(nil, "DamageReductionMax") or data.misc.DamageReductionCap modDB:NewMod("ArmourAppliesToPhysicalDamageTaken", "BASE", 100) @@ -3065,6 +3150,7 @@ function calcs.buildDefenceEstimations(env, actor) poolTable.Life = m_min(poolTable.Life + DamageIn.LifeWhenHit * (gainMult - 1), gainMult * (output.LifeRecoverable or 0)) poolTable.Mana = m_min(poolTable.Mana + DamageIn.ManaWhenHit * (gainMult - 1), gainMult * (output.ManaUnreserved or 0)) poolTable.EnergyShield = m_min(poolTable.EnergyShield + DamageIn.EnergyShieldWhenHit * (gainMult - 1), gainMult * output.EnergyShieldRecoveryCap) + poolTable.Ward = m_min((poolTable.Ward or 0) + (DamageIn.WardWhenHit or 0) * (gainMult - 1), gainMult * (output.Ward or 0)) end poolTable = calcs.reducePoolsByDamage(poolTable, Damage, actor) @@ -3076,6 +3162,7 @@ function calcs.buildDefenceEstimations(env, actor) poolTable.Life = m_min(poolTable.Life + DamageIn.LifeWhenHit, output.LifeRecoverable or 0) poolTable.Mana = m_min(poolTable.Mana + DamageIn.ManaWhenHit, output.ManaUnreserved or 0) poolTable.EnergyShield = m_min(poolTable.EnergyShield + DamageIn.EnergyShieldWhenHit, output.EnergyShieldRecoveryCap) + poolTable.Ward = m_min((poolTable.Ward or 0) + (DamageIn.WardWhenHit or 0), output.Ward or 0) end iterationMultiplier = 1 -- to speed it up, run recursively but accelerated @@ -3094,6 +3181,7 @@ function calcs.buildDefenceEstimations(env, actor) Damage.LifeWhenHit = DamageIn.LifeWhenHit Damage.ManaWhenHit = DamageIn.ManaWhenHit Damage.EnergyShieldWhenHit = DamageIn.EnergyShieldWhenHit + Damage.WardWhenHit = DamageIn.WardWhenHit end Damage["cycles"] = DamageIn["cycles"] * speedUp Damage["iterations"] = DamageIn["iterations"] @@ -3160,6 +3248,7 @@ function calcs.buildDefenceEstimations(env, actor) DamageIn.LifeWhenHit = output.LifeOnBlock * BlockChance DamageIn.ManaWhenHit = output.ManaOnBlock * BlockChance DamageIn.EnergyShieldWhenHit = output.EnergyShieldOnBlock * BlockChance + DamageIn.WardWhenHit = (output.WardRecoverOnBlock or 0) * BlockChance if damageCategoryConfig == "Spell" or damageCategoryConfig == "SpellProjectile" then DamageIn.EnergyShieldWhenHit = DamageIn.EnergyShieldWhenHit + output.EnergyShieldOnSpellBlock * BlockChance elseif damageCategoryConfig == "Average" then @@ -3191,13 +3280,15 @@ function calcs.buildDefenceEstimations(env, actor) end -- gain when hit (currently just gain on block/suppress) if not env.configInput.DisableEHPGainOnBlock then - if (DamageIn.LifeWhenHit or 0) ~= 0 or (DamageIn.ManaWhenHit or 0) ~= 0 or DamageIn.EnergyShieldWhenHit ~= 0 then + if (DamageIn.LifeWhenHit or 0) ~= 0 or (DamageIn.ManaWhenHit or 0) ~= 0 + or (DamageIn.EnergyShieldWhenHit or 0) ~= 0 or (DamageIn.WardWhenHit or 0) ~= 0 then DamageIn.GainWhenHit = true end else DamageIn.LifeWhenHit = 0 DamageIn.ManaWhenHit = 0 DamageIn.EnergyShieldWhenHit = 0 + DamageIn.WardWhenHit = 0 end for _, damageType in ipairs(dmgTypeList) do -- Emperor's Vigilance (this needs to fail with divine flesh as it can't override it, hence the check for high bypass) @@ -3540,7 +3631,7 @@ function calcs.buildDefenceEstimations(env, actor) sourcePool = m_max(sourcePool - poolProtected, 0) + m_min(sourcePool, poolProtected) / (wardBypass / 100) output[damageType.."TotalHitPool"] = sourcePool else - output[damageType.."TotalHitPool"] = output[damageType.."TotalHitPool"] + output.Ward or 0 + output[damageType.."TotalHitPool"] = output[damageType.."TotalHitPool"] + (output.Ward or 0) end -- aegis output[damageType.."TotalHitPool"] = output[damageType.."TotalHitPool"] + m_max(m_max(output[damageType.."Aegis"], output["sharedAegis"]), isElemental[damageType] and output[damageType.."AegisDisplay"] or 0) @@ -3893,7 +3984,7 @@ function calcs.buildDefenceEstimations(env, actor) output.NetLifeRegen = output.NetLifeRegen - totalLifeDegen output.NetManaRegen = output.NetManaRegen - totalManaDegen output.NetEnergyShieldRegen = output.NetEnergyShieldRegen - totalEnergyShieldDegen - output.TotalNetRegen = output.NetLifeRegen + output.NetManaRegen + output.NetEnergyShieldRegen + output.TotalNetRegen = output.NetLifeRegen + output.NetManaRegen + output.NetEnergyShieldRegen + (output.WardRegen or 0) if breakdown then t_insert(breakdown.NetLifeRegen, s_format("%.1f ^8(total life regen)", output.LifeRegenRecovery)) t_insert(breakdown.NetLifeRegen, s_format("- %.1f ^8(total life degen)", totalLifeDegen)) @@ -3908,6 +3999,7 @@ function calcs.buildDefenceEstimations(env, actor) s_format("Net Life Regen: %.1f", output.NetLifeRegen), s_format("+ Net Mana Regen: %.1f", output.NetManaRegen), s_format("+ Net Energy Shield Regen: %.1f", output.NetEnergyShieldRegen), + output.WardRegen and output.WardRegen > 0 and s_format("+ Ward Regen: %.1f", output.WardRegen) or nil, s_format("= Total Net Regen: %.1f", output.TotalNetRegen) } end @@ -4135,7 +4227,7 @@ function calcs.buildDefenceEstimations(env, actor) output.ComprehensiveNetLifeRegen = output.ComprehensiveNetLifeRegen + (output.LifeRecoupRecoveryAvg or 0) - totalLifeDegen - (output.LifeLossLostAvg or 0) output.ComprehensiveNetManaRegen = output.ComprehensiveNetManaRegen + (output.ManaRecoupRecoveryAvg or 0) - totalManaDegen output.ComprehensiveNetEnergyShieldRegen = output.ComprehensiveNetEnergyShieldRegen + (output.EnergyShieldRecoupRecoveryAvg or 0) - totalEnergyShieldDegen - output.ComprehensiveTotalNetRegen = output.ComprehensiveNetLifeRegen + output.ComprehensiveNetManaRegen + output.ComprehensiveNetEnergyShieldRegen + output.ComprehensiveTotalNetRegen = output.ComprehensiveNetLifeRegen + output.ComprehensiveNetManaRegen + output.ComprehensiveNetEnergyShieldRegen + (output.WardRegen or 0) if breakdown then t_insert(breakdown.ComprehensiveNetLifeRegen, s_format("%.1f ^8(total life regen)", output.LifeRegenRecovery)) if (output.LifeRecoupRecoveryAvg or 0) ~= 0 then @@ -4162,6 +4254,7 @@ function calcs.buildDefenceEstimations(env, actor) s_format("Net Life Regen: %.1f", output.ComprehensiveNetLifeRegen), s_format("+ Net Mana Regen: %.1f", output.ComprehensiveNetManaRegen), s_format("+ Net Energy Shield Regen: %.1f", output.ComprehensiveNetEnergyShieldRegen), + output.WardRegen and output.WardRegen > 0 and s_format("+ Ward Regen: %.1f", output.WardRegen) or nil, s_format("= Total Net Regen: %.1f", output.ComprehensiveTotalNetRegen) } end diff --git a/src/Modules/CalcOffence.lua b/src/Modules/CalcOffence.lua index 38e79e58b..b214c2705 100644 --- a/src/Modules/CalcOffence.lua +++ b/src/Modules/CalcOffence.lua @@ -400,6 +400,10 @@ function calcs.offence(env, actor, activeSkill) -- Calculate armour break output.ArmourBreakPerHit = calcLib.val(skillModList, "ArmourBreakPerHit", skillCfg) + -- Ward-sourced armour break: attack hits break X% of player's max ward as armour + if (actor.output.WardAttackHitPercent or 0) > 0 then + output.ArmourBreakPerHit = output.ArmourBreakPerHit + (actor.output.Ward or 0) * actor.output.WardAttackHitPercent / 100 + end local function getSkillNameFromFlag(skillModList, flag) local sourceMod = skillModList:Tabulate("FLAG", nil, flag) diff --git a/src/Modules/CalcPerform.lua b/src/Modules/CalcPerform.lua index 5e80d8abb..24f64b1b9 100644 --- a/src/Modules/CalcPerform.lua +++ b/src/Modules/CalcPerform.lua @@ -1701,6 +1701,7 @@ function calcs.perform(env, skipEHP) end end local ignoreAttrReq = modDB:Flag(nil, "IgnoreAttributeRequirements") + local ignoreGlovesAttrReq = modDB:Flag(nil, "IgnoreAttributeRequirementsForGloves") local strengthSatisfiesMeleeFlag = modDB:Flag(nil, "StrengthSatisfiesMeleeWeaponsAndSkills") for _, attr in ipairs(attrTable) do local breakdownAttr = attr @@ -1736,7 +1737,10 @@ function calcs.perform(env, skipEHP) and ((reqSource.source == "Item" and reqSource.sourceItem.base.weapon and env.data.weaponTypeInfo[reqSource.sourceItem.base.type].melee) or (reqSource.source == "Gem" and reqSource.sourceGem.gemData.tags.melee)) local satisfyingAttributeValue = gemAttributeRequirementsSatisfiedByHighestAttribute and reqSource.source == "Gem" and highestAttributeValue or out.val - if req > (strengthSatisfiesMelee and attr ~= "Str" and m_max(satisfyingAttributeValue, output["Str"]) or satisfyingAttributeValue) then + local ignoreThisReq = ignoreGlovesAttrReq + and reqSource.source == "Item" + and reqSource.sourceSlot == "Gloves" + if not ignoreThisReq and req > (strengthSatisfiesMelee and attr ~= "Str" and m_max(satisfyingAttributeValue, output["Str"]) or satisfyingAttributeValue) then out.val = req out.source = reqSource end @@ -3173,6 +3177,41 @@ function calcs.perform(env, skipEHP) enemyDB:NewMod("DamageTaken", "INC", enemyDB:Sum("INC", nil, "DamageTakenConsecratedGround") * effect, "Consecrated Ground") end + -- Way of the Stonefist: transform glove base type for this calc pass only + if modDB:Flag(nil, "GloveBaseTypeTransform") then + local gloveItem = env.player.itemList["Gloves"] + if gloveItem and gloveItem.armourData and gloveItem.baseName ~= "Fists of Stone" then + local fistsOfStone = env.data.itemBases["Fists of Stone"] + if fistsOfStone then + local qualityMult = 1 + (gloveItem.quality or 0) / 100 + local origBase = gloveItem.base + local origBaseName = gloveItem.baseName + local origArmour = gloveItem.armourData.Armour + local origEvasion = gloveItem.armourData.Evasion + local origES = gloveItem.armourData.EnergyShield + gloveItem.base = fistsOfStone + gloveItem.baseName = "Fists of Stone" + gloveItem.armourData.Armour = m_floor((fistsOfStone.armour.Armour or 0) * qualityMult) + gloveItem.armourData.Evasion = m_floor((fistsOfStone.armour.Evasion or 0) * qualityMult) + gloveItem.armourData.EnergyShield = m_floor((fistsOfStone.armour.EnergyShield or 0) * qualityMult) + -- Inject Fists of Stone implicit mods (per-level scaling); modDB is ephemeral per calc pass + modDB:NewMod("Evasion", "BASE", 2, "Fists of Stone Implicit", { type = "Multiplier", var = "Level" }) + modDB:NewMod("EnergyShield", "BASE", 1, "Fists of Stone Implicit", { type = "Multiplier", var = "Level" }) + modDB:NewMod("Ward", "BASE", 1, "Fists of Stone Implicit", { type = "Multiplier", var = "Level" }) + env.stonefistRestore = { + item = gloveItem, + base = origBase, baseName = origBaseName, + Armour = origArmour, Evasion = origEvasion, EnergyShield = origES + } + end + end + end + + -- Way of the Stonefist: explicit mod transformation (requires ModEquivalencies data) + -- GloveExplicitModTransform is not yet implemented; the mapping data must be generated + -- by running src/Export/Scripts/modequivalencies.lua via the PoB export tool first. + -- When src/Data/ModEquivalencies.lua exists, load it here and remap glove explicit mods. + -- Defence/offence calculations calcs.defence(env, env.player) local function getSkillExposureEffect(source, element) @@ -3422,4 +3461,15 @@ function calcs.perform(env, skipEHP) -- Cache skill data cacheData(cacheSkillUUID(env.player.mainSkill, env), env) + + -- Restore glove item mutated by GloveBaseTypeTransform + if env.stonefistRestore then + local r = env.stonefistRestore + r.item.base = r.base + r.item.baseName = r.baseName + r.item.armourData.Armour = r.Armour + r.item.armourData.Evasion = r.Evasion + r.item.armourData.EnergyShield = r.EnergyShield + env.stonefistRestore = nil + end end diff --git a/src/Modules/CalcSections.lua b/src/Modules/CalcSections.lua index 1792e3e71..d27eeb8e6 100644 --- a/src/Modules/CalcSections.lua +++ b/src/Modules/CalcSections.lua @@ -1690,6 +1690,43 @@ return { }, }, } } } }, +{ 1, "Ward", 2, colorCodes.WARD, {{ defaultCollapsed = false, label = "Runic Ward", data = { + extra = "{0:output:Ward}", + { label = "Base from Armours", { format = "{0:output:Gear:Ward}", { breakdown = "Ward", gearOnly = true }, }, }, + { label = "Global Base", { format = "{0:mod:1}", { modName = { "Ward", "WardTotal" }, modType = "BASE" }, }, }, + { label = "Inc. from Tree", { format = "{0:mod:1}%", { modName = "Ward", modType = "INC", modSource = "Tree" }, }, }, + { label = "Total Increased", { format = "{0:mod:1}%", { modName = { "Ward", "Defences" }, modType = "INC" }, }, }, + { label = "Total More", { format = "{0:mod:1}%", { modName = { "Ward", "Defences" }, modType = "MORE" }, }, }, + { label = "Total", { format = "{0:output:Ward}", { breakdown = "Ward" }, }, }, + { label = "Recharge Delay", { format = "{2:output:WardRechargeDelay}s", + { breakdown = "WardRechargeDelay" }, + { modName = "WardRechargeFaster" }, + }, }, + { label = "Regen", haveOutput = "WardRegen", { format = "{1:output:WardRegen}", + { breakdown = "WardRegen" }, + { label = "Sources", modName = "WardRegen", modType = "BASE" }, + { label = "Increased Ward Regen", modName = "WardRegen", modType = "INC" }, + }, }, + { label = "Bypass", haveOutput = "WardBypass", { format = "{0:output:WardBypass}%", + { modName = "WardBypass", modType = "BASE" }, + }, }, + { label = "Recover on Block", haveOutput = "WardRecoverOnBlock", { format = "{0:output:WardRecoverOnBlock}", + { modName = "WardRecoverOnBlock", modType = "BASE" }, + }, }, + { label = "Recover on Charm Use", haveOutput = "WardRecoverOnCharmUse", { format = "{0:output:WardRecoverOnCharmUse}", + { modName = "WardRecoverOnCharmUse", modType = "BASE" }, + }, }, + { label = "Recover on Minion Death", haveOutput = "WardCoverOnMinionDeath", { format = "{0:output:WardCoverOnMinionDeath}%", + { modName = "WardCoverOnMinionDeath", modType = "BASE" }, + }, }, + { label = "Rune: Block Damage Taken", haveOutput = "RuneWardBlockDamage", { format = "{0:output:RuneWardBlockDamage}%", + { modName = "RuneWardBlockDamage", modType = "BASE" }, + }, }, + { label = "Rune: INC Drain Rate", haveOutput = "RuneWardDamageTaken", { format = "{0:output:RuneWardDamageTaken}%", + { modName = "RuneWardDamageTaken", modType = "INC" }, + }, }, +} } +} }, -- secondary defenses { 1, "Resist", 3, colorCodes.DEFENCE, {{ defaultCollapsed = false, label = "Resists", data = { extra = colorCodes.FIRE.."{0:output:FireResist}+{0:output:FireResistOverCap}^7/"..colorCodes.COLD.."{0:output:ColdResist}+{0:output:ColdResistOverCap}^7/"..colorCodes.LIGHTNING.."{0:output:LightningResist}+{0:output:LightningResistOverCap}", diff --git a/src/Modules/ConfigOptions.lua b/src/Modules/ConfigOptions.lua index 120288fca..57930fdde 100644 --- a/src/Modules/ConfigOptions.lua +++ b/src/Modules/ConfigOptions.lua @@ -154,6 +154,18 @@ local configSettings = { { var = "conditionHaveEnergyShield", type = "check", label = "Do you always have ^x88FFFFEnergy Shield?", ifCond = "HaveEnergyShield", apply = function(val, modList, enemyModList) modList:NewMod("Condition:HaveEnergyShield", "FLAG", true, "Config") end }, + { var = "conditionFullWard", type = "check", label = "Are you always on Full ^xB0E0E0Runic Ward?", ifCond = "FullWard", apply = function(val, modList, enemyModList) + modList:NewMod("Condition:FullWard", "FLAG", true, "Config") + end }, + { var = "conditionLowWard", type = "check", label = "Are you always on Low ^xB0E0E0Runic Ward?", ifCond = "LowWard", tooltip = "Use this to force the Low Runic Ward condition for builds that are intentionally maintained below "..data.gameConstants["LowWardThreshold"].."% Ward.", apply = function(val, modList, enemyModList) + modList:NewMod("Condition:LowWard", "FLAG", true, "Config") + end }, + { var = "conditionMissingWard", type = "check", label = "Are you always missing ^xB0E0E0Runic Ward?", ifCond = "MissingWard", apply = function(val, modList, enemyModList) + modList:NewMod("Condition:MissingWard", "FLAG", true, "Config") + end }, + { var = "conditionNoWard", type = "check", label = "Do you always have no ^xB0E0E0Runic Ward?", ifCond = "NoWard", apply = function(val, modList, enemyModList) + modList:NewMod("Condition:NoWard", "FLAG", true, "Config") + end }, { var = "multiplierCurrentEnergyShield", type = "count", label = "Current ^x88FFFFEnergy Shield^7 percentage:", ifCond = "UseCurrentEnergyShield", defaultPlaceholderState = 100, tooltip = "Used in calculations for ^xAF6025Silks of Veneration^7 and ^xAF6025The Mutable Star^7.\nOverflowed ^x88FFFFEnergy Shield^7 is allowed, up to 150%.", apply = function(val, modList, enemyModList) modList:NewMod("Condition:UseCurrentEnergyShield", "FLAG", true, "Config") modList:NewMod("Multiplier:CurrentEnergyShield", "BASE", val, "Config") diff --git a/src/Modules/ModParser.lua b/src/Modules/ModParser.lua index afbeb68a6..697bfb89f 100644 --- a/src/Modules/ModParser.lua +++ b/src/Modules/ModParser.lua @@ -234,11 +234,15 @@ local modNameList = { ["energy shield recharge rate"] = "EnergyShieldRecharge", ["start of energy shield recharge"] = "EnergyShieldRechargeFaster", ["restoration of ward"] = "WardRechargeFaster", + ["restoration of runic ward"] = "WardRechargeFaster", ["armour"] = "Armour", ["evasion"] = "Evasion", ["evasion rating"] = "Evasion", ["energy shield"] = "EnergyShield", ["ward"] = "Ward", + ["runic ward"] = "Ward", + ["maximum ward"] = "Ward", + ["maximum runic ward"] = "Ward", ["armour and evasion"] = "ArmourAndEvasion", ["armour and evasion rating"] = "ArmourAndEvasion", ["evasion rating and armour"] = "ArmourAndEvasion", @@ -1197,6 +1201,7 @@ local preFlagList = { ["^melee weapon damage"] = { flags = ModFlag.WeaponMelee }, ["^deal "] = { }, ["^causes "] = { }, + ["^has "] = { }, ["^arrows deal "] = { keywordFlags = KeywordFlag.Arrow }, ["^critical hits deal "] = { tag = { type = "Condition", var = "CriticalStrike" } }, ["^poisons you inflict with critical hits have "] = { keywordFlags = bor(KeywordFlag.Poison, KeywordFlag.MatchAll), tag = { type = "Condition", var = "CriticalStrike" } }, @@ -1454,6 +1459,7 @@ local modTagList = { ["per mana burn, up to a maximum of (%d+)%%"] = function(num) return { tag = { type = "Multiplier", var = "ManaBurnStacks", limit = tonumber(num), limitTotal = true } } end, ["per level"] = { tag = { type = "Multiplier", var = "Level" } }, ["per (%d+) player levels"] = function(num) return { tag = { type = "Multiplier", var = "Level", div = num } } end, + ["per player level"] = { tag = { type = "Multiplier", var = "Level" } }, ["per defiance"] = { tag = { type = "Multiplier", var = "Defiance" } }, ["per (%d+)%% (%a+) effect on enemy"] = function(num, _, effectName) return { tag = { type = "Multiplier", var = firstToUpper(effectName) .. "Effect", div = num, actor = "enemy" } } end, ["per socketed rune or soul core"] = { tag = { type = "Multiplier", var = "RunesSocketedIn{SlotName}" } }, @@ -1597,7 +1603,8 @@ local modTagList = { ["per (%d+) (i?t?e?m? ?)evasion rating on body armour"] = function(num) return { tag = { type = "PerStat", stat = "EvasionOnBody Armour", div = num } } end, ["per (%d+) (i?t?e?m? ?)evasion rating on equipped body armour"] = function(num) return { tag = { type = "PerStat", stat = "EvasionOnBody Armour", div = num } } end, ["for every (%d+) (i?t?e?m? ?)evasion rating on equipped body armour"] = function(num) return { tag = { type = "PerStat", stat = "EvasionOnBody Armour", div = num } } end, - ["per (%d+) (i?t?e?m? ?)evasion rating on equipped armour items"] = function(num) return { tag = { type = "PerStat", stat = "EvasionOnAllArmourItems", div = num } } end, + ["per (%d+) (i?t?e?m? ?)evasion r?a?t?i?n?g? ?on equipped body armour"] = function(num) return { tag = { type = "PerStat", stat = "EvasionOnBody Armour", div = num } } end, + ["per (%d+) (i?t?e?m? ?)evasion r?a?t?i?n?g? ?on equipped armour items"] = function(num) return { tag = { type = "PerStat", stat = "EvasionOnAllArmourItems", div = num } } end, ["per (%d+) t?o?t?a?l? ?(i?t?e?m? ?)armour on equipped armour items"] = function(num) return { tag = { type = "PerStat", stat = "ArmourOnAllArmourItems", div = num } } end, ["per (%d+) (i?t?e?m? ?)energy shield on equipped armour items"] = function(num) return { tag = { type = "PerStat", stat = "EnergyShieldOnAllArmourItems", div = num } } end, ["for every (%d+) (i?t?e?m? ?)energy shield on equipped body armour"] = function(num) return { tag = { type = "PerStat", stat = "EnergyShieldOnBody Armour", div = num } } end, @@ -2477,6 +2484,9 @@ local specialModList = { ["critical hits inflict scorch, brittle and sapped"] = { flag("CritAlwaysAltAilments") }, ["you take (%d+)%% of damage from blocked hits"] = function(num) return { mod("BlockEffect", "BASE", num) } end, ["ignore attribute requirements"] = { flag("IgnoreAttributeRequirements") }, + ["ignore attribute requirements to equip gloves"] = { flag("IgnoreAttributeRequirementsForGloves") }, + ["gloves you equip have their base type transformed to fists of stone while equipped"] = { flag("GloveBaseTypeTransform") }, + ["their explicit modifiers are transformed into more powerful related modifiers"] = { flag("GloveExplicitModTransform") }, ["gain no inherent bonuses from attributes"] = { flag("NoAttributeBonuses") }, ["gain no inherent bonuses from strength"] = { flag("NoStrengthAttributeBonuses") }, ["gain no inherent bonuses from dexterity"] = { flag("NoDexterityAttributeBonuses") }, @@ -2504,6 +2514,31 @@ local specialModList = { ["increases and reductions to mana regeneration rate instead apply to rage regeneration rate"] = { flag("ManaRegenToRageRegen") }, ["increases and reductions to maximum energy shield instead apply to ward"] = { flag("EnergyShieldToWard") }, ["(%d+)%% of damage taken bypasses ward"] = function(num) return { mod("WardBypass", "BASE", num) } end, + ["(%d+)%% of damage taken bypasses runic ward"] = function(num) return { mod("WardBypass", "BASE", num) } end, + ["(%d+)%% increased ward regeneration"] = function(num) return { mod("WardRegen", "INC", num) } end, + ["(%d+)%% increased runic ward regeneration"] = function(num) return { mod("WardRegen", "INC", num) } end, + -- Runic Ward - new stat IDs + ["base_maximum_ward"] = mod("Ward", "BASE"), + ["maximum_ward_+%"] = mod("Ward", "INC"), + ["base_ward_regeneration_per_minute"] = mod("WardRegen", "BASE"), + ["base_ward_regeneration_per_minute_+%"] = mod("WardRegen", "INC"), + ["base_ward_cost_efficiency"] = mod("WardCostEfficiency", "BASE"), + ["base_ward_cost_efficiency_+%"] = mod("WardCostEfficiency", "INC"), + ["total_base_ward_cost"] = mod("WardCost", "BASE"), + ["base_ward_cost_%"] = mod("WardCostPercent", "BASE"), + ["recover_x_ward_on_block"] = mod("WardRecoverOnBlock", "BASE"), + ["recover_x_ward_on_charm_use"] = mod("WardRecoverOnCharmUse", "BASE"), + ["excess_ward_regeneration_is_applied_to_mana"] = flag("ExcessWardToMana"), + ["regenerate_ward_instead_of_life"] = flag("WardRegenInsteadOfLife"), + ["runic_ward_overflow"] = flag("WardOverflow"), + ["no_current_ward"] = flag("NoCurrentWard"), + ["attack_hit_%_of_max_ward"] = mod("WardAttackHitPercent", "BASE"), + ["ward_rune_maximum_ward_+%_final"] = mod("Ward", "MORE"), + ["rune_ward_block_%_damage_taken"] = mod("RuneWardBlockDamage", "BASE"), + ["rune_ward_damage_taken_"] = mod("RuneWardDamageTaken", "INC"), + ["is_taken_from_ward_before_life"] = flag("WardBeforeLife"), + ["recover_x%_of_maximum_ward_on_persistent_minion_death"] = mod("WardCoverOnMinionDeath", "BASE"), + ["recover (%d+)%% of maximum ward on persistent minion death"] = function(num) return { mod("WardCoverOnMinionDeath", "BASE", num) } end, ["maximum energy shield is (%d+)"] = function(num) return { mod("EnergyShield", "OVERRIDE", num ) } end, ["cannot have energy shield"] = { flag("CannotHaveES") }, ["regenerate ([%d%.]+) life per second per maximum energy shield"] = function(num) return { @@ -5863,11 +5898,10 @@ local specialModList = { flag("Condition:OnConsecratedGround", { type = "Condition", var = "StrHighestAttribute" }, { type = "Condition", var = "Stationary" }), }, ["you count as dual wielding while you are unencumbered"] = { flag("Condition:DualWielding", { type = "Condition", var = "Unencumbered" }) }, - ["can attack as though using a quarterstaff while both of your hand slots are empty unarmed attacks that would use your quarterstaff's damage gain: physical damage based on their skill level (%d+)%% more attack speed per (%d+) item evasion rating on equipped armour items %+(%d+%.?%d*)%% to critical hit chance per (%d+) item energy shield on equipped armour items"] = function(asNum, _, evNum, critNum, esNum) return - { -- New Hollow Palm Technique - mod("Speed", "MORE", tonumber(asNum), nil, ModFlag.Attack, { type = "Condition", var = "HollowPalm" }, { type = "PerStat", stat = "EvasionOnAllArmourItems", div = tonumber(evNum) }), - mod("CritChance", "BASE", tonumber(critNum), nil, ModFlag.Attack, { type = "Condition", var = "HollowPalm" }, { type = "PerStat", stat = "EnergyShieldOnAllArmourItems", div = (esNum) }), - } end, + -- Hollow Palm Technique descriptive lines (no mod effect, but should be recognized) + ["can attack as though using a quarterstaff while both of your hand slots are empty"] = { }, + ["unarmed attacks that would use an equipped quarterstaff's damage have:"] = { }, + ["base unarmed physical damage replaced with damage based on their skill level"] = { }, ["storm and plant spells: deal (%d+)%% more damage cost (%d+)%% less have (%d+)%% less duration"] = function(damageNum, _, costNum, durationNum) return { -- Wildsurge Incantation mod("Damage", "MORE", damageNum, nil, 0, KeywordFlag.Spell, { type = "SkillType", skillTypeList = { SkillType.Storm, SkillType.Plant } } ), mod("Cost", "MORE", -tonumber(costNum), nil, 0, KeywordFlag.Spell, { type = "SkillType", skillTypeList = { SkillType.Storm, SkillType.Plant } } ),