diff --git a/.busted b/.busted index 7c7b78d94f..b79a03a020 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 0000000000..ce63040e1b --- /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 0000000000..1b6861136b --- /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 0000000000..7371c6f20e --- /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 0000000000..4719e3cf66 --- /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 0000000000..5e4c4bb963 --- /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 c6c8cbe421..580c646e32 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 e707d9bac5..2f4b7f16bb 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 0000000000..494fc9ff6e --- /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 0000000000..0192ead7b6 --- /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 0000000000..114af39895 --- /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 0000000000..9645daa7d2 --- /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 d1abe3a90a..a234bfcda0 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 8e4cc222fd..417ccb793d 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 b01cfb360a..9f1ee09851 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 0000000000..ffc15b64a2 --- /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 0000000000..74843d3a81 --- /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/TestItemParse_spec.lua b/spec/System/TestItemParse_spec.lua index 2de3505d6e..97793ecd5e 100644 --- a/spec/System/TestItemParse_spec.lua +++ b/spec/System/TestItemParse_spec.lua @@ -460,6 +460,20 @@ describe("TestItemParse", function() assert.are.equals(6, item.jewelSocketCount) end) + + it("Runeforged prefix is stripped from base name", 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) + end) end) describe("TestAdvancedItemParse #item", function() diff --git a/spec/System/TestWard_spec.lua b/spec/System/TestWard_spec.lua new file mode 100644 index 0000000000..fe1e3afdbb --- /dev/null +++ b/spec/System/TestWard_spec.lua @@ -0,0 +1,183 @@ +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("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 c4c404f68a..531dd0f63e 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 @@ -1114,10 +1114,12 @@ function ImportTabClass:ImportItem(itemData, slotName) 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 + if property.name == "Energy Shield" or property.name == "Ward" or property.name == "Runic Ward" or property.name == "Armour" or property.name == "Evasion Rating" then item.armourData = item.armourData or { } + local armourKey = property.name: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 diff --git a/src/Classes/Item.lua b/src/Classes/Item.lua index 846c19ceb7..a12d259867 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)" diff --git a/src/Data/Misc.lua b/src/Data/Misc.lua index 59bd44539a..814611a7b8 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 9724787345..3f2eb03ffb 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} @@ -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 8aacda5619..1d0020258e 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 844269fad3..7c4ae2cc2e 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/Modules/BuildDisplayStats.lua b/src/Modules/BuildDisplayStats.lua index 538efd46ca..074ddc5a92 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 cfea75c786..9a0d237e25 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 a48d96f92a..72b8ed5c74 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) @@ -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 38e79e58bd..b214c27058 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/CalcSections.lua b/src/Modules/CalcSections.lua index 1792e3e71b..d27eeb8e67 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 120288fca1..57930fdded 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 afbeb68a6b..3a7fcf9792 100644 --- a/src/Modules/ModParser.lua +++ b/src/Modules/ModParser.lua @@ -234,11 +234,13 @@ 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", ["armour and evasion"] = "ArmourAndEvasion", ["armour and evasion rating"] = "ArmourAndEvasion", ["evasion rating and armour"] = "ArmourAndEvasion", @@ -1597,7 +1599,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, @@ -2504,6 +2507,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 +5891,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 } } ),