mirror of
https://github.com/bombsquad-community/plugin-manager.git
synced 2026-08-27 01:12:38 +00:00
Compare commits
105 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9c6392b5c | ||
|
|
2b1763a9d1 | ||
|
|
0bd564785e | ||
|
|
85d77e61d9 | ||
|
|
1047903957 | ||
|
|
b03ab3a88b | ||
|
|
0cf28c6290 | ||
|
|
1981a2b90a | ||
|
|
461f2c360f | ||
|
|
3e0dfccea6 | ||
|
|
44cffc5cb5 | ||
|
|
bc5c5aafc8 | ||
|
|
06886ff232 | ||
|
|
b0cca24114 | ||
|
|
a74246d6a6 | ||
|
|
454eb35194 | ||
|
|
587f0c65d4 | ||
|
|
fad2e493e3 | ||
|
|
dfa13959fd | ||
|
|
d442c44ea8 | ||
|
|
7acd7a87ab | ||
|
|
a1baa5fca2 | ||
|
|
c2c2a04891 | ||
|
|
b6ccdb12ef | ||
|
|
f1c02ae85a | ||
|
|
5d7d22245b | ||
|
|
b8dee8f224 | ||
|
|
710dbebee4 | ||
|
|
643ac51a08 | ||
|
|
d0df37d764 | ||
|
|
3adfe38e55 | ||
|
|
2660a8d973 | ||
|
|
767fbe3a28 | ||
|
|
18dbcbbe3f | ||
|
|
0749053077 | ||
|
|
4804a55ca0 | ||
|
|
2e2ff3656a | ||
|
|
55c3fe5af4 | ||
|
|
7fcb9b4468 | ||
|
|
edaed70235 | ||
|
|
e091fb6983 | ||
|
|
d88d0e149d | ||
|
|
7e249c1181 | ||
|
|
99cf837498 | ||
|
|
1662980eea | ||
|
|
929ce3f26f | ||
|
|
d35b78326b | ||
|
|
a6d11fd8ce | ||
|
|
d517d47f3e | ||
|
|
811e151c81 | ||
|
|
a1b809bc80 | ||
|
|
7c718cf4a3 | ||
|
|
8fba8c8ab5 | ||
|
|
f6c0fd5fbe | ||
|
|
e9e1a29fea | ||
|
|
576a9c0304 | ||
|
|
9a74e81ec7 | ||
|
|
d0e936c582 | ||
|
|
e6d9c5d71c | ||
|
|
3a622edb51 | ||
|
|
23fbdf5e9d | ||
|
|
6a494a44fc | ||
|
|
7c5038263c | ||
|
|
bbf36dc4a9 | ||
|
|
6212108315 | ||
|
|
05c3f1d023 | ||
|
|
e131859fe6 | ||
|
|
023b733c3b | ||
|
|
6c8e898913 | ||
|
|
3876b25f47 | ||
|
|
06d11887a4 | ||
|
|
120a09f380 | ||
|
|
bbd1fe6632 | ||
|
|
1c728633ea | ||
|
|
de9ca9ddb2 | ||
|
|
e6c5130186 | ||
|
|
864e4efabf | ||
|
|
0417dc09e0 | ||
|
|
c0527f3635 | ||
|
|
a8999bd8e1 | ||
|
|
7a6d6e301f | ||
|
|
6312b16f49 | ||
|
|
3aa666ff65 | ||
|
|
8864fc650e | ||
|
|
564357affd | ||
|
|
f74738487f | ||
|
|
5ebbadb946 | ||
|
|
8fcb89492a | ||
|
|
0288823e6c | ||
|
|
16cb91a80b | ||
|
|
4f0cdaceff | ||
|
|
7a6fa42e0a | ||
|
|
56ad1aa843 | ||
|
|
38f9e35e53 | ||
|
|
0c22ef23b0 | ||
|
|
4baad2b813 | ||
|
|
272bc0cb5c | ||
|
|
ea78bf01db | ||
|
|
822208149c | ||
|
|
963f7dfa87 | ||
|
|
e67d891716 | ||
|
|
ebbd198628 | ||
|
|
3e5873b5c8 | ||
|
|
d07385de2d | ||
|
|
455967d4fd |
25 changed files with 9887 additions and 3337 deletions
294
.github/workflows/ci-apply.yml
vendored
Normal file
294
.github/workflows/ci-apply.yml
vendored
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
name: PR Apply
|
||||||
|
|
||||||
|
# Trusted counterpart to ci-check.yml (PR Check). Runs with this repo's
|
||||||
|
# write-scoped GITHUB_TOKEN via `workflow_run`, AFTER PR Check has finished
|
||||||
|
# safely (no secrets, fork content fully executed there instead of here).
|
||||||
|
#
|
||||||
|
# TWO HARD RULES, both load-bearing. Breaking either one reintroduces the
|
||||||
|
# pwn-request hole this split exists to close:
|
||||||
|
#
|
||||||
|
# 1. NEVER execute, import, or `unittest discover` anything from the fork's
|
||||||
|
# checkout. This job only applies a text patch (`git apply`), commits,
|
||||||
|
# pushes, and runs auto_apply_version_metadata.py from a SEPARATE checkout
|
||||||
|
# of this repo's own main branch - never the fork's copy of it.
|
||||||
|
#
|
||||||
|
# 2. NEVER interpolate a `${{ }}` expression into a `run:` block unless the
|
||||||
|
# value is fixed and trusted. `${{ }}` is substituted textually BEFORE the
|
||||||
|
# shell parses the script, so surrounding quotes do NOT contain it - a PR
|
||||||
|
# branch named `a";id;"` becomes live shell. Pass values via `env:` and
|
||||||
|
# reference them as "$VAR", which the shell treats as data.
|
||||||
|
#
|
||||||
|
# 3. NEVER identify the target PR by commit sha alone. A sha is a value, not an
|
||||||
|
# identity: forks share object storage, so anyone can push ANOTHER PR's head
|
||||||
|
# commit onto a branch of their own, open a PR at it, and then close that PR
|
||||||
|
# mid-run so the commit -> PR lookup below resolves to the victim's PR - at
|
||||||
|
# which point this job would push the attacker's artifact to the victim's
|
||||||
|
# branch. The resolved PR must be pinned to workflow_run.head_repository AND
|
||||||
|
# head_branch AND head_sha, so a run can only ever write to its own branch.
|
||||||
|
#
|
||||||
|
# On trust: a fork PR fully controls ci-check.yml itself (GitHub runs the
|
||||||
|
# workflow file from the PR's own merge ref for `pull_request` events - that
|
||||||
|
# is why that job gets a read-only, secret-less token). So EVERYTHING in the
|
||||||
|
# pr-fixups artifact is attacker-authored. PR identity is therefore resolved
|
||||||
|
# from the workflow_run payload + the API, never from the artifact; the
|
||||||
|
# artifact supplies only the patch, which is allowlist-validated below and
|
||||||
|
# only ever lands on the fork's own branch.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["PR Check"]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
actions: read # required to download an artifact from another workflow run
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pr-apply-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
apply:
|
||||||
|
if: >-
|
||||||
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
github.event.workflow_run.event == 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# Resolves which PR this run belongs to using ONLY trusted inputs: the
|
||||||
|
# workflow_run payload (set by GitHub, not forgeable by the PR author)
|
||||||
|
# and the REST API. workflow_run.pull_requests is empty for fork PRs,
|
||||||
|
# hence the commit -> PR association lookup. That lookup ANSWERS with a
|
||||||
|
# PR but does not PROVE it is this run's PR - a sha can be adopted by any
|
||||||
|
# fork even though it cannot be forged - so the result is pinned to the
|
||||||
|
# payload's head repo/branch/sha below. See HARD RULE 3 in the header.
|
||||||
|
- name: Resolve and validate PR (trusted sources only)
|
||||||
|
id: pr
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
RUN_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||||
|
RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
skip() { echo "$1"; echo "proceed=0" >> "$GITHUB_OUTPUT"; exit 0; }
|
||||||
|
|
||||||
|
[[ "$RUN_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || {
|
||||||
|
echo "Unexpected workflow_run head sha shape; refusing." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
gh api "repos/${REPO}/commits/${RUN_HEAD_SHA}/pulls" > pulls.json
|
||||||
|
[ "$(jq 'length' pulls.json)" = "1" ] \
|
||||||
|
|| skip "Not exactly one PR associated with ${RUN_HEAD_SHA}; skipping."
|
||||||
|
|
||||||
|
PR_NUMBER="$(jq -r '.[0].number' pulls.json)"
|
||||||
|
[[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || skip "Bad PR number; skipping."
|
||||||
|
|
||||||
|
gh pr view "$PR_NUMBER" --repo "$REPO" \
|
||||||
|
--json state,baseRefName,headRefName,headRefOid,maintainerCanModify,headRepository,headRepositoryOwner \
|
||||||
|
> pr.json
|
||||||
|
|
||||||
|
STATE="$(jq -r .state pr.json)"
|
||||||
|
BASE_REF="$(jq -r .baseRefName pr.json)"
|
||||||
|
HEAD_REF="$(jq -r .headRefName pr.json)"
|
||||||
|
HEAD_SHA="$(jq -r .headRefOid pr.json)"
|
||||||
|
CAN_MODIFY="$(jq -r .maintainerCanModify pr.json)"
|
||||||
|
FORK="$(jq -r '.headRepositoryOwner.login + "/" + .headRepository.name' pr.json)"
|
||||||
|
|
||||||
|
# Branch/repo names are attacker-chosen strings, and git happily
|
||||||
|
# accepts refnames containing ` $( ) ; | ' " - so anything outside
|
||||||
|
# this conservative set is refused rather than carried forward.
|
||||||
|
# Bash =~ anchors to the whole string, so embedded newlines (which
|
||||||
|
# would otherwise inject extra $GITHUB_OUTPUT keys) are rejected too.
|
||||||
|
[[ "$HEAD_REF" =~ ^[A-Za-z0-9._/-]{1,255}$ ]] || skip "Unsafe branch name; skipping."
|
||||||
|
[[ "$FORK" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || skip "Unsafe repo name; skipping."
|
||||||
|
[[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || skip "Unsafe head sha; skipping."
|
||||||
|
|
||||||
|
[ "$STATE" = "OPEN" ] || skip "PR not open; skipping."
|
||||||
|
[ "$BASE_REF" = "main" ] || skip "PR not targeting main; skipping."
|
||||||
|
|
||||||
|
# HARD RULE 3: pin the resolved PR to the run that produced the
|
||||||
|
# artifact. The sha check alone is not enough - a sha is adoptable by
|
||||||
|
# any fork, so on its own it would let a run push to somebody else's
|
||||||
|
# PR branch. These are compared only against FORK/HEAD_REF, which the
|
||||||
|
# regexes above already validated, so no shape check is needed here:
|
||||||
|
# a null head_repository yields "" and simply fails to match, which is
|
||||||
|
# the fail-closed direction. Do not "simplify" these away.
|
||||||
|
[ "$FORK" = "$RUN_HEAD_REPO" ] \
|
||||||
|
|| skip "Resolved PR head repo != the run's head repo; skipping."
|
||||||
|
[ "$HEAD_REF" = "$RUN_HEAD_BRANCH" ] \
|
||||||
|
|| skip "Resolved PR head branch != the run's head branch; skipping."
|
||||||
|
[ "$HEAD_SHA" = "$RUN_HEAD_SHA" ] \
|
||||||
|
|| skip "PR head moved since PR Check ran; skipping."
|
||||||
|
|
||||||
|
# maintainerCanModify only has meaning for cross-fork PRs; GitHub
|
||||||
|
# reports false for a PR opened from a branch in this same repo,
|
||||||
|
# where we can always push because we own the branch.
|
||||||
|
if [ "$FORK" != "$REPO" ] && [ "$CAN_MODIFY" != "true" ]; then
|
||||||
|
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "needs_comment=1" >> "$GITHUB_OUTPUT"
|
||||||
|
skip "Maintainer edits disabled; cannot push."
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "pr_number=$PR_NUMBER"
|
||||||
|
echo "head_ref=$HEAD_REF"
|
||||||
|
echo "head_sha=$HEAD_SHA"
|
||||||
|
echo "fork=$FORK"
|
||||||
|
echo "proceed=1"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Comment if maintainer edits are disabled (actionable, not self-resolving)
|
||||||
|
if: steps.pr.outputs.needs_comment == '1'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
gh pr comment "$PR_NUMBER" --repo "$REPO" \
|
||||||
|
--body "I can't push the automatic formatting/metadata commits to this PR because \"Allow edits from maintainers\" is disabled. Please enable it, or apply \`autopep8\` and \`test/auto_apply_*_metadata.py\` locally."
|
||||||
|
|
||||||
|
- name: Download PR fixups artifact (untrusted data)
|
||||||
|
if: steps.pr.outputs.proceed == '1'
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: pr-fixups
|
||||||
|
path: pr-data
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run-id: ${{ github.event.workflow_run.id }}
|
||||||
|
|
||||||
|
# Checking out fork PR content is what actions/checkout >=v6 refuses by
|
||||||
|
# default; the opt-in is reviewed and intentional here because nothing
|
||||||
|
# below ever EXECUTES this checkout - only git plumbing touches it.
|
||||||
|
# persist-credentials:false keeps the write token out of the untrusted
|
||||||
|
# working tree's .git/config; the push below authenticates explicitly.
|
||||||
|
- name: Checkout PR branch (fork) - allow-unsafe-pr-checkout is reviewed & intentional
|
||||||
|
if: steps.pr.outputs.proceed == '1'
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
repository: ${{ steps.pr.outputs.fork }}
|
||||||
|
ref: ${{ steps.pr.outputs.head_sha }}
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
allow-unsafe-pr-checkout: true
|
||||||
|
path: pr
|
||||||
|
|
||||||
|
- name: Checkout TRUSTED scripts from our own main branch
|
||||||
|
if: steps.pr.outputs.proceed == '1'
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
sparse-checkout: |
|
||||||
|
test/auto_apply_version_metadata.py
|
||||||
|
sparse-checkout-cone-mode: false
|
||||||
|
persist-credentials: false
|
||||||
|
path: trusted
|
||||||
|
|
||||||
|
- name: Re-verify checked-out head matches the resolved head
|
||||||
|
if: steps.pr.outputs.proceed == '1'
|
||||||
|
working-directory: pr
|
||||||
|
env:
|
||||||
|
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test "$(git rev-parse HEAD)" = "$HEAD_SHA"
|
||||||
|
|
||||||
|
- name: Validate patch (allowlisted paths only, no symlinks/binaries)
|
||||||
|
if: steps.pr.outputs.proceed == '1'
|
||||||
|
working-directory: pr
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PATCH="${GITHUB_WORKSPACE}/pr-data/fixups.patch"
|
||||||
|
if [ ! -s "$PATCH" ]; then
|
||||||
|
echo "Empty patch, nothing to validate"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if grep -qE '^(deleted file mode 120000|new mode 120000|new file mode 120000|Binary files)' "$PATCH"; then
|
||||||
|
echo "Patch contains symlinks or binary content - refusing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Deliberately excludes .github/** and test/** - a "[ci]"-authored
|
||||||
|
# commit touching CI config or the test suite is exactly what a
|
||||||
|
# reviewer would wave through, so those fail closed and are left to
|
||||||
|
# the contributor to format locally.
|
||||||
|
ALLOW='^(plugins/(minigames|utilities|maps)/[^/]+\.py|plugin_manager\.py|index\.json|plugins/(minigames|utilities|maps)\.json|CHANGELOG\.md)$'
|
||||||
|
# Redirect from a file rather than piping into `while`: a pipeline
|
||||||
|
# would run the loop in a subshell, where `exit 1` would not reliably
|
||||||
|
# fail the step.
|
||||||
|
git apply --numstat "$PATCH" | cut -f3 > "${RUNNER_TEMP}/patch_paths.txt"
|
||||||
|
while IFS= read -r f; do
|
||||||
|
[ -n "$f" ] || continue
|
||||||
|
if [[ ! "$f" =~ $ALLOW ]]; then
|
||||||
|
echo "Patch touches disallowed path: $f" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done < "${RUNNER_TEMP}/patch_paths.txt"
|
||||||
|
git apply --check "$PATCH"
|
||||||
|
|
||||||
|
- name: Apply fixups patch and commit
|
||||||
|
if: steps.pr.outputs.proceed == '1'
|
||||||
|
working-directory: pr
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
HEAD_REF: ${{ steps.pr.outputs.head_ref }}
|
||||||
|
FORK: ${{ steps.pr.outputs.fork }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PATCH="${GITHUB_WORKSPACE}/pr-data/fixups.patch"
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
if [ ! -s "$PATCH" ]; then
|
||||||
|
echo "Empty patch, nothing to apply"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git apply "$PATCH"
|
||||||
|
# Stage exactly the paths the validation step allow-listed, so the
|
||||||
|
# commit scope can never exceed the scope that was validated. A
|
||||||
|
# broader pathspec (e.g. all of plugins/) would silently widen the
|
||||||
|
# blast radius if the allowlist ever regressed.
|
||||||
|
PATHS="${RUNNER_TEMP}/patch_paths.txt"
|
||||||
|
test -s "$PATHS" || {
|
||||||
|
echo "Validated path list missing or empty - refusing to stage" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
git add -A --pathspec-from-file="$PATHS"
|
||||||
|
if ! git diff --cached --quiet; then
|
||||||
|
git commit -m "[ci] apply-plugin-metadata-and-formatting"
|
||||||
|
git push "https://x-access-token:${GH_TOKEN}@github.com/${FORK}.git" "HEAD:${HEAD_REF}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Apply Version Metadata using the TRUSTED script only (never the fork's copy)
|
||||||
|
if: steps.pr.outputs.proceed == '1'
|
||||||
|
working-directory: pr
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
HEAD_REF: ${{ steps.pr.outputs.head_ref }}
|
||||||
|
FORK: ${{ steps.pr.outputs.fork }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python "${GITHUB_WORKSPACE}/trusted/test/auto_apply_version_metadata.py" "$(git rev-parse HEAD)"
|
||||||
|
# That script only ever writes index.json and the category manifests
|
||||||
|
# (it opens every .py read-only), so stage precisely those rather
|
||||||
|
# than the whole plugins/ tree.
|
||||||
|
git add -A -- index.json \
|
||||||
|
plugins/minigames.json plugins/utilities.json plugins/maps.json
|
||||||
|
if ! git diff --cached --quiet; then
|
||||||
|
git commit -m "[ci] apply-version-metadata"
|
||||||
|
git push "https://x-access-token:${GH_TOKEN}@github.com/${FORK}.git" "HEAD:${HEAD_REF}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: On mechanical failure, notify the contributor
|
||||||
|
if: failure() && steps.pr.outputs.pr_number != ''
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
gh pr comment "$PR_NUMBER" --repo "$REPO" \
|
||||||
|
--body "Automatic formatting/metadata could not be applied to this PR (patch touched a disallowed path, or a conflict occurred). A maintainer will need to look at this manually."
|
||||||
119
.github/workflows/ci-check.yml
vendored
Normal file
119
.github/workflows/ci-check.yml
vendored
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
name: PR Check
|
||||||
|
|
||||||
|
# Runs entirely inside GitHub's hardcoded read-only, no-secrets sandbox for
|
||||||
|
# `pull_request` events from forks. Fully executes fork-supplied code
|
||||||
|
# (autopep8, metadata scripts, unittest discover) - that's safe here ONLY
|
||||||
|
# because this token can't push anywhere and has no secrets.
|
||||||
|
#
|
||||||
|
# This workflow NEVER pushes anywhere. It only uploads a plain-text patch as
|
||||||
|
# a build artifact for ci-apply.yml (a separate, trusted workflow) to apply.
|
||||||
|
#
|
||||||
|
# Note that a fork PR can modify THIS FILE and have its version run (GitHub
|
||||||
|
# uses the workflow from the PR's own merge ref for `pull_request`). So
|
||||||
|
# nothing produced here is trustworthy, and ci-apply.yml is written on that
|
||||||
|
# assumption: it resolves PR identity from the workflow_run payload and the
|
||||||
|
# API, and treats the uploaded patch as untrusted input to be validated.
|
||||||
|
#
|
||||||
|
# DO NOT switch this to `pull_request_target` and do not add `permissions:
|
||||||
|
# contents: write` here "to save a round trip" - that reintroduces the
|
||||||
|
# pwn-request hole this split exists to close.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pr-check-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# Credentials are deliberately persisted here (unlike ci-apply.yml): the
|
||||||
|
# `git fetch` below needs them on a private repo, and the workflow-level
|
||||||
|
# `permissions: contents: read` caps this token to read-only in every
|
||||||
|
# case - fork PRs get a read-only token from GitHub regardless.
|
||||||
|
- name: Checkout PR head
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: refs/pull/${{ github.event.pull_request.number }}/head
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Fetch PR base commit (for diffing)
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
run: git fetch --no-tags --depth=1 origin "$BASE_SHA"
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v7
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install -U pip
|
||||||
|
python -m pip install -U pycodestyle==2.12.1 autopep8
|
||||||
|
python -m pip install -U -r test/pip_reqs.txt
|
||||||
|
|
||||||
|
# Filenames are attacker-controlled, so they are kept in files and
|
||||||
|
# passed as shell variables - never interpolated into a script or into
|
||||||
|
# $GITHUB_OUTPUT (where a file named after the heredoc delimiter, or one
|
||||||
|
# containing a newline, could inject extra output keys).
|
||||||
|
- name: Compute changed files
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git diff --name-only "$BASE_SHA" HEAD > "${RUNNER_TEMP}/changed_files.txt"
|
||||||
|
git diff --name-only -z "$BASE_SHA" HEAD -- '*.py' > "${RUNNER_TEMP}/changed_py.z"
|
||||||
|
cat "${RUNNER_TEMP}/changed_files.txt"
|
||||||
|
|
||||||
|
- name: Apply AutoPEP8 (changed .py files only)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -s "${RUNNER_TEMP}/changed_py.z" ]; then
|
||||||
|
xargs -0 -r autopep8 --in-place --max-line-length=100 \
|
||||||
|
< "${RUNNER_TEMP}/changed_py.z"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# PLUGMAN_BASE_REF tells the script which tree counts as "already
|
||||||
|
# published". Without it the script compares against the PR's own
|
||||||
|
# manifest, which ci-apply.yml has already written the new version into -
|
||||||
|
# so every re-run of this workflow (including the one ci-apply.yml's push
|
||||||
|
# triggers) would fail with "Version cant be lower or equal".
|
||||||
|
- name: Apply Plugin Metadata (writes null version placeholders)
|
||||||
|
env:
|
||||||
|
PLUGMAN_BASE_REF: ${{ github.event.pull_request.base.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python test/auto_apply_plugin_metadata.py "$(cat "${RUNNER_TEMP}/changed_files.txt")"
|
||||||
|
|
||||||
|
# IMPORTANT: capture the patch BEFORE the version-metadata preview step
|
||||||
|
# below. commit_sha values computed there are fictitious (no real
|
||||||
|
# commit exists yet) and must never be part of what ci-apply.yml applies.
|
||||||
|
- name: Snapshot fixups patch
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "${RUNNER_TEMP}/pr-data"
|
||||||
|
git diff > "${RUNNER_TEMP}/pr-data/fixups.patch"
|
||||||
|
wc -l "${RUNNER_TEMP}/pr-data/fixups.patch"
|
||||||
|
|
||||||
|
- name: Apply Version Metadata (local preview only - not uploaded)
|
||||||
|
run: |
|
||||||
|
python test/auto_apply_version_metadata.py "$(git rev-parse HEAD)"
|
||||||
|
|
||||||
|
- name: "Execute Tests (lenient: new-entry history checks deferred to ci-apply/push-to-main)"
|
||||||
|
env:
|
||||||
|
PLUGMAN_CI_LENIENT_HISTORY: "1"
|
||||||
|
run: |
|
||||||
|
python -m unittest discover -v
|
||||||
|
|
||||||
|
- name: Upload patch for ci-apply.yml
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: pr-fixups
|
||||||
|
path: ${{ runner.temp }}/pr-data
|
||||||
|
retention-days: 5
|
||||||
43
.github/workflows/ci.yml
vendored
43
.github/workflows/ci.yml
vendored
|
|
@ -1,34 +1,31 @@
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
# WORD OF CAUTION:
|
# Runs only on pushes to main - i.e. after a PR has been merged, or on a
|
||||||
# TO anyone modifying this
|
# direct maintainer push. This is fully trusted, same-repo content, so it's
|
||||||
# Things will break if you modify this
|
# safe for it to execute the tree and push directly. This is also the
|
||||||
# without understanding how it works
|
# AUTHORITATIVE integrity check: test/test_checks.py's test_versions runs
|
||||||
|
# here unmodified/strict against real, permanent git history (unlike
|
||||||
# A simple flow of this file:
|
# ci-check.yml, which can't yet resolve a commit sha for a brand-new plugin
|
||||||
# Apply AutoPEP8 → Apply Plugin Metadata → CRITICAL COMMIT (format + plugin meta)
|
# version and runs leniently instead).
|
||||||
# ← ← ← ← ← ↵
|
|
||||||
# ↪ Apply Version Metadata → Commit (version meta) → Tests
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
pull_request_target:
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
|
||||||
ref: ${{ github.head_ref }}
|
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
|
|
@ -42,21 +39,10 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
autopep8 --in-place --recursive --max-line-length=100 .
|
autopep8 --in-place --recursive --max-line-length=100 .
|
||||||
|
|
||||||
- name: Apply Plugin Metadata
|
- name: Commit AutoPEP8 formatting
|
||||||
if: github.event_name == 'pull_request_target'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
CHANGED_FILES=$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --jq '.[].filename')
|
|
||||||
python test/auto_apply_plugin_metadata.py "$CHANGED_FILES"
|
|
||||||
|
|
||||||
# This is a CRITICAL COMMIT for the next step
|
|
||||||
# which bases this as the commit to get the sha to store in index.json or plugin.json
|
|
||||||
- name: Commit Plugin Metadata and AutoPEP8
|
|
||||||
uses: stefanzweifel/git-auto-commit-action@v7
|
uses: stefanzweifel/git-auto-commit-action@v7
|
||||||
with:
|
with:
|
||||||
commit_message: "[ci] apply-plugin-metadata-and-formatting"
|
commit_message: "[ci] apply-formatting"
|
||||||
branch: ${{ github.head_ref }}
|
|
||||||
|
|
||||||
- name: Apply Version Metadata
|
- name: Apply Version Metadata
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -66,7 +52,6 @@ jobs:
|
||||||
uses: stefanzweifel/git-auto-commit-action@v7
|
uses: stefanzweifel/git-auto-commit-action@v7
|
||||||
with:
|
with:
|
||||||
commit_message: "[ci] apply-version-metadata"
|
commit_message: "[ci] apply-version-metadata"
|
||||||
branch: ${{ github.head_ref }}
|
|
||||||
|
|
||||||
- name: Execute Tests
|
- name: Execute Tests
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -12,14 +12,14 @@ jobs:
|
||||||
name: Create Release
|
name: Create Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
|
|
||||||
- name: 'Get Previous tag'
|
- name: 'Get Previous tag'
|
||||||
uses: oprypin/find-latest-tag@v1.1.2
|
uses: oprypin/find-latest-tag@v1.1.3
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.repository }}
|
repository: ${{ github.repository }}
|
||||||
releases-only: true # We know that all relevant tags have a GitHub release for them.
|
releases-only: true # We know that all relevant tags have a GitHub release for them.
|
||||||
|
|
|
||||||
40
CHANGELOG.md
40
CHANGELOG.md
|
|
@ -1,5 +1,45 @@
|
||||||
## Plugin Manager (dd-mm-yyyy)
|
## Plugin Manager (dd-mm-yyyy)
|
||||||
|
|
||||||
|
### 1.1.13 (23-08-2026)
|
||||||
|
|
||||||
|
- Changelog window hard-wraps its bullets across several source lines
|
||||||
|
|
||||||
|
### 1.1.12 (23-08-2026)
|
||||||
|
|
||||||
|
- Fixed request timeouts being reported as an unhandled error instead of
|
||||||
|
a connection problem, which left the plugin manager stuck on a spinner
|
||||||
|
- Response bodies are now read on the network threadpool rather than on
|
||||||
|
the main thread, so a slow server no longer freezes the game
|
||||||
|
- Index and changelog setup no longer deadlock after a failed fetch
|
||||||
|
- Fixed the plugin manager failing to load or refresh on networks where
|
||||||
|
one of GitHub's edge servers is unreachable; all of the published
|
||||||
|
addresses are now tried instead of a single one
|
||||||
|
- The DNS block workaround now corrects name resolution rather than
|
||||||
|
replacing the HTTP and TLS stack, so ordinary requests take the
|
||||||
|
standard code path
|
||||||
|
- Fixed the changelog window erroring out after the settings window had
|
||||||
|
been opened
|
||||||
|
|
||||||
|
### 1.1.11 (09-08-2026)
|
||||||
|
|
||||||
|
- Switched to babase.app.asyncio_loop and babase.app.threadpool
|
||||||
|
for concurrent execution
|
||||||
|
- A new threadpool to avoid blocking the babase.app.threadpool
|
||||||
|
which is for short parallel tasks
|
||||||
|
- Added a shutdown task to shutdown our threadpool
|
||||||
|
|
||||||
|
### 1.1.10 (12-06-2026)
|
||||||
|
|
||||||
|
- Fix for older bs versions using `EXPORT_CLASS_NAME_SHORTCUTS` import
|
||||||
|
|
||||||
|
### 1.1.9 (11-06-2026)
|
||||||
|
|
||||||
|
- Fix for bs 1.7.63
|
||||||
|
|
||||||
|
### 1.1.8 (23-02-2026)
|
||||||
|
|
||||||
|
- Fix for bs 1.7.61 build no 22714
|
||||||
|
|
||||||
### 1.1.7 (15-02-2026)
|
### 1.1.7 (15-02-2026)
|
||||||
|
|
||||||
- Added function to fill form details for plugin bug report button
|
- Added function to fill form details for plugin bug report button
|
||||||
|
|
|
||||||
2
LICENSE
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2022 Members of bombsquad-community organization, and contributors
|
Copyright (c) 2022-2026 Members of bombsquad-community organization, and contributors
|
||||||
|
|
||||||
All source code in this repository is licensed under MIT, unless otherwise
|
All source code in this repository is licensed under MIT, unless otherwise
|
||||||
explicitly stated in source code.
|
explicitly stated in source code.
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,13 @@ which makes further modding of your game more convenient by providing easier acc
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
There are two different ways the plugin manager can be installed:
|
There are three different ways the plugin manager can be installed:
|
||||||
|
|
||||||
|
|
||||||
1. From dev console
|
1. From dev console
|
||||||
|
|
||||||
- Enable "Show Dev Console Button" from advance BombSquad settings
|
- Enable "Show Dev Console Button" from advance BombSquad settings
|
||||||
|
- Make sure you're connected to the internet
|
||||||
- Paste the following code in dev console
|
- Paste the following code in dev console
|
||||||
```py
|
```py
|
||||||
import urllib.request;import _babase;import os;url="https://github.com/bombsquad-community/plugin-manager/releases/latest/download/plugin_manager.py";plugin_path=os.path.join(_babase.env()["python_directory_user"],"plugin_manager.py");file=urllib.request.urlretrieve(url)[0];fl = open(file,'r');f=open(plugin_path, 'w+');f.write(fl.read());fl.close();f.close();print("SUCCESS")
|
import urllib.request;import _babase;import os;url="https://github.com/bombsquad-community/plugin-manager/releases/latest/download/plugin_manager.py";plugin_path=os.path.join(_babase.env()["python_directory_user"],"plugin_manager.py");file=urllib.request.urlretrieve(url)[0];fl = open(file,'r');f=open(plugin_path, 'w+');f.write(fl.read());fl.close();f.close();print("SUCCESS")
|
||||||
|
|
@ -57,7 +58,7 @@ There are two different ways the plugin manager can be installed:
|
||||||
manually apply updates by copying the latest plugin manager's source code again to your workspace when using this method.
|
manually apply updates by copying the latest plugin manager's source code again to your workspace when using this method.
|
||||||
|
|
||||||
3. [Download plugin_manager.py][DownloadLink] to your mods directory (check it out by going into your game's
|
3. [Download plugin_manager.py][DownloadLink] to your mods directory (check it out by going into your game's
|
||||||
Settings -> Advanced -> Show Mods Folder). This is the recommended way (read next method to know why).
|
Settings -> Advanced -> Show Mods Folder).
|
||||||
If you're on a newer version of Android (11 or above) and not rooted, it probably won't be possible to copy
|
If you're on a newer version of Android (11 or above) and not rooted, it probably won't be possible to copy
|
||||||
mods to game's mods folder. In this case, you can connect your Android phone to a computer and push `plugin_manager.py`
|
mods to game's mods folder. In this case, you can connect your Android phone to a computer and push `plugin_manager.py`
|
||||||
[using `adb`](https://www.xda-developers.com/install-adb-windows-macos-linux/):
|
[using `adb`](https://www.xda-developers.com/install-adb-windows-macos-linux/):
|
||||||
|
|
|
||||||
36
index.json
36
index.json
|
|
@ -1,6 +1,42 @@
|
||||||
{
|
{
|
||||||
"plugin_manager_url": "https://github.com/bombsquad-community/plugin-manager/{content_type}/{tag}/plugin_manager.py",
|
"plugin_manager_url": "https://github.com/bombsquad-community/plugin-manager/{content_type}/{tag}/plugin_manager.py",
|
||||||
"versions": {
|
"versions": {
|
||||||
|
"1.1.13": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "0bd5647",
|
||||||
|
"released_on": "23-08-2026",
|
||||||
|
"md5sum": "6f4f6e7003180491c35d6cfd5ce2f713"
|
||||||
|
},
|
||||||
|
"1.1.12": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "b03ab3a",
|
||||||
|
"released_on": "23-08-2026",
|
||||||
|
"md5sum": "ae9724579bcf54c29d36b6c65cbccb06"
|
||||||
|
},
|
||||||
|
"1.1.11": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "a74246d",
|
||||||
|
"released_on": "09-08-2026",
|
||||||
|
"md5sum": "1dbf57b11602a22196711bdd3ad18fdc"
|
||||||
|
},
|
||||||
|
"1.1.10": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "a1baa5f",
|
||||||
|
"released_on": "12-06-2026",
|
||||||
|
"md5sum": "ee4f7e3e88c2982bc8290e204b10b050"
|
||||||
|
},
|
||||||
|
"1.1.9": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "3adfe38",
|
||||||
|
"released_on": "11-06-2026",
|
||||||
|
"md5sum": "d7e990ea4e5c530f780e617c9bdafb42"
|
||||||
|
},
|
||||||
|
"1.1.8": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "8222081",
|
||||||
|
"released_on": "23-02-2026",
|
||||||
|
"md5sum": "8e8e9fc5b818883102c6081619404318"
|
||||||
|
},
|
||||||
"1.1.7": {
|
"1.1.7": {
|
||||||
"api_version": 9,
|
"api_version": 9,
|
||||||
"commit_sha": "af881af",
|
"commit_sha": "af881af",
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,24 @@
|
||||||
import babase
|
import babase
|
||||||
import bauiv1 as bui
|
import bauiv1 as bui
|
||||||
from bauiv1lib import popup, confirm
|
from bauiv1lib import popup, confirm
|
||||||
from babase._meta import EXPORT_CLASS_NAME_SHORTCUTS
|
|
||||||
from bauiv1lib.settings.allsettings import AllSettingsWindow
|
from bauiv1lib.settings.allsettings import AllSettingsWindow
|
||||||
|
|
||||||
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import http.client
|
import http.client
|
||||||
import socket
|
import socket
|
||||||
import json
|
import json
|
||||||
import ssl
|
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import copy
|
import copy
|
||||||
import asyncio
|
import asyncio
|
||||||
import pathlib
|
import pathlib
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import weakref
|
||||||
|
import threading
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import concurrent.futures
|
||||||
|
|
||||||
from typing import override
|
from typing import override
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
@ -26,7 +27,7 @@ from datetime import datetime
|
||||||
# Modules used for overriding AllSettingsWindow
|
# Modules used for overriding AllSettingsWindow
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
PLUGIN_MANAGER_VERSION = "1.1.7"
|
PLUGIN_MANAGER_VERSION = "1.1.13"
|
||||||
REPOSITORY_URL = "https://github.com/bombsquad-community/plugin-manager"
|
REPOSITORY_URL = "https://github.com/bombsquad-community/plugin-manager"
|
||||||
# Current tag can be changed to "staging" or any other branch in
|
# Current tag can be changed to "staging" or any other branch in
|
||||||
# plugin manager repo for testing purpose.
|
# plugin manager repo for testing purpose.
|
||||||
|
|
@ -43,7 +44,40 @@ HEADERS = {
|
||||||
"User-Agent": _env["legacy_user_agent_string"],
|
"User-Agent": _env["legacy_user_agent_string"],
|
||||||
}
|
}
|
||||||
PLUGIN_DIRECTORY = _env["python_directory_user"]
|
PLUGIN_DIRECTORY = _env["python_directory_user"]
|
||||||
loop = babase._asyncio._asyncio_event_loop
|
|
||||||
|
NETWORK_REQUEST_TIMEOUT = 10 # seconds
|
||||||
|
|
||||||
|
loop = babase.app.asyncio_loop
|
||||||
|
pool = babase.app.threadpool
|
||||||
|
|
||||||
|
# babase.app.threadpool (aka `pool`, also wired up as the asyncio loop's
|
||||||
|
# default executor) is reserved for short parallel work and warns/starves
|
||||||
|
# on long-running tasks. Network requests routinely run past that, so they
|
||||||
|
# get their own small dedicated pool instead.
|
||||||
|
_network_pool = concurrent.futures.ThreadPoolExecutor(
|
||||||
|
max_workers=4,
|
||||||
|
thread_name_prefix="PluginManagerNetwork",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _shutdown_network_pool() -> None:
|
||||||
|
"""Drain _network_pool's workers so none outlive app shutdown.
|
||||||
|
|
||||||
|
ThreadPoolExecutor.shutdown(wait=True) blocks, so it's run on a
|
||||||
|
throwaway thread and awaited from here rather than blocking the
|
||||||
|
event loop (other shutdown tasks run concurrently with this one).
|
||||||
|
In-flight requests are bounded by NETWORK_REQUEST_TIMEOUT, so this
|
||||||
|
settles quickly.
|
||||||
|
"""
|
||||||
|
done = asyncio.Event()
|
||||||
|
|
||||||
|
def _join() -> None:
|
||||||
|
_network_pool.shutdown(wait=True, cancel_futures=True)
|
||||||
|
loop.call_soon_threadsafe(done.set)
|
||||||
|
|
||||||
|
threading.Thread(target=_join, daemon=True).start()
|
||||||
|
await done.wait()
|
||||||
|
|
||||||
|
|
||||||
open_popups = []
|
open_popups = []
|
||||||
|
|
||||||
|
|
@ -72,12 +106,48 @@ def _by_scale(a, b, c):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_markdown_bullets(source_lines, max_width, scale):
|
||||||
|
"""Reflow markdown bullets so every rendered line fits `max_width`.
|
||||||
|
|
||||||
|
CHANGELOG.md hard-wraps its bullets across several source lines, so the
|
||||||
|
lines are first stitched back into whole bullets and then re-wrapped to
|
||||||
|
the width we actually have. Drawing the source lines as-is left each one
|
||||||
|
a different length, and since a text widget shrinks itself to its own
|
||||||
|
maxwidth, long lines rendered visibly smaller than short ones.
|
||||||
|
"""
|
||||||
|
bullets = []
|
||||||
|
for line in source_lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
if stripped.startswith(("-", "*")) or not bullets:
|
||||||
|
bullets.append(stripped.lstrip("-*").strip())
|
||||||
|
else:
|
||||||
|
# A continuation of the bullet above it.
|
||||||
|
bullets[-1] += " " + stripped
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for bullet in bullets:
|
||||||
|
prefix, current = "- ", ""
|
||||||
|
for word in bullet.split():
|
||||||
|
candidate = f"{current} {word}" if current else word
|
||||||
|
width = bui.get_string_width(prefix + candidate, suppress_warning=True)
|
||||||
|
if current and width * scale > max_width:
|
||||||
|
lines.append(prefix + current)
|
||||||
|
# Indent wrapped remainders under the bullet's text.
|
||||||
|
prefix, current = " ", word
|
||||||
|
else:
|
||||||
|
current = candidate
|
||||||
|
lines.append(prefix + current)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
REGEXP = {
|
REGEXP = {
|
||||||
"plugin_api_version": re.compile(b"(?<=ba_meta require api )(.*)"),
|
"plugin_api_version": re.compile(b"(?<=ba_meta require api )(.*)"),
|
||||||
"plugin_entry_points": re.compile(
|
"plugin_entry_points": re.compile(
|
||||||
bytes(
|
bytes(
|
||||||
"(ba_meta export (plugin|{})\n+class )(.*)\\(".format(
|
"(ba_meta export (plugin|{})\n+class )(.*)\\(".format(
|
||||||
_regexp_friendly_class_name_shortcut(EXPORT_CLASS_NAME_SHORTCUTS["plugin"]),
|
_regexp_friendly_class_name_shortcut("babase.Plugin"),
|
||||||
),
|
),
|
||||||
"utf-8"
|
"utf-8"
|
||||||
),
|
),
|
||||||
|
|
@ -121,26 +191,57 @@ class CategoryMetadataParseError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def network_errors_as_urlerror():
|
||||||
|
"""Normalize network failures into urllib.error.URLError.
|
||||||
|
|
||||||
|
Every caller in here reports connectivity problems by catching
|
||||||
|
URLError, but urllib only guarantees that shape while it is building
|
||||||
|
the request. Once urlopen() has returned, a socket timeout surfaces
|
||||||
|
as a bare TimeoutError and a truncated/dropped response as an
|
||||||
|
http.client.HTTPException, both of which sail past those handlers.
|
||||||
|
Since NETWORK_REQUEST_TIMEOUT made timeouts reachable at all, wrap
|
||||||
|
the whole fetch so callers only ever have one exception to catch.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
except urllib.error.URLError:
|
||||||
|
# Includes HTTPError; already the shape callers expect.
|
||||||
|
raise
|
||||||
|
except (TimeoutError, http.client.HTTPException) as e:
|
||||||
|
raise urllib.error.URLError(e) from e
|
||||||
|
|
||||||
|
|
||||||
def send_network_request(request):
|
def send_network_request(request):
|
||||||
return urllib.request.urlopen(request)
|
"""Fetch `request` and return its full body as bytes.
|
||||||
|
|
||||||
|
The body is read here rather than handed back unread, because reading
|
||||||
|
it is itself a blocking call that can time out, and callers await this
|
||||||
|
from the event loop thread, where doing so would both stall the game
|
||||||
|
and raise outside network_errors_as_urlerror()'s reach.
|
||||||
|
"""
|
||||||
|
with network_errors_as_urlerror():
|
||||||
|
with urllib.request.urlopen(request, timeout=NETWORK_REQUEST_TIMEOUT) as response:
|
||||||
|
return response.read()
|
||||||
|
|
||||||
|
|
||||||
async def async_send_network_request(request):
|
async def async_send_network_request(request):
|
||||||
response = await loop.run_in_executor(None, send_network_request, request)
|
content = await loop.run_in_executor(_network_pool, send_network_request, request)
|
||||||
return response
|
return content
|
||||||
|
|
||||||
|
|
||||||
def stream_network_response_to_file(request, file, md5sum=None, retries=3):
|
def stream_network_response_to_file(request, file, md5sum=None, retries=3):
|
||||||
response = urllib.request.urlopen(request)
|
|
||||||
chunk_size = 16 * 1024
|
chunk_size = 16 * 1024
|
||||||
content = b""
|
content = b""
|
||||||
with open(file, "wb") as fout:
|
with network_errors_as_urlerror():
|
||||||
while True:
|
with urllib.request.urlopen(request, timeout=NETWORK_REQUEST_TIMEOUT) as response:
|
||||||
chunk = response.read(chunk_size)
|
with open(file, "wb") as fout:
|
||||||
if not chunk:
|
while True:
|
||||||
break
|
chunk = response.read(chunk_size)
|
||||||
fout.write(chunk)
|
if not chunk:
|
||||||
content += chunk
|
break
|
||||||
|
fout.write(chunk)
|
||||||
|
content += chunk
|
||||||
if md5sum and hashlib.md5(content).hexdigest() != md5sum:
|
if md5sum and hashlib.md5(content).hexdigest() != md5sum:
|
||||||
if retries <= 0:
|
if retries <= 0:
|
||||||
raise MD5CheckSumFailed("MD5 checksum match failed.")
|
raise MD5CheckSumFailed("MD5 checksum match failed.")
|
||||||
|
|
@ -156,7 +257,7 @@ def stream_network_response_to_file(request, file, md5sum=None, retries=3):
|
||||||
async def async_stream_network_response_to_file(request, file, md5sum=None, retries=3):
|
async def async_stream_network_response_to_file(request, file, md5sum=None, retries=3):
|
||||||
|
|
||||||
content = await loop.run_in_executor(
|
content = await loop.run_in_executor(
|
||||||
None,
|
_network_pool,
|
||||||
stream_network_response_to_file,
|
stream_network_response_to_file,
|
||||||
request,
|
request,
|
||||||
file,
|
file,
|
||||||
|
|
@ -187,50 +288,108 @@ class DNSBlockWorkaround:
|
||||||
Usage:
|
Usage:
|
||||||
-----
|
-----
|
||||||
>>> import urllib.request
|
>>> import urllib.request
|
||||||
>>> import http.client
|
|
||||||
>>> import socket
|
>>> import socket
|
||||||
>>> import ssl
|
|
||||||
>>> import json
|
>>> import json
|
||||||
>>> DNSBlockWorkaround.apply()
|
>>> DNSBlockWorkaround.apply()
|
||||||
>>> response = urllib.request.urlopen("https://dnsblockeddomain.com/path/to/resource/")
|
>>> response = urllib.request.urlopen("https://dnsblockeddomain.com/path/to/resource/")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_google_dns_cache = {}
|
# Hostnames worth second-guessing the system resolver on. Keeping this
|
||||||
|
# explicit is what lets apply() patch a process-global function safely:
|
||||||
|
# every other name the game looks up takes a set membership test and is
|
||||||
|
# then handed straight to the original resolver.
|
||||||
|
_blockable_hosts = frozenset(("raw.githubusercontent.com",))
|
||||||
|
|
||||||
def apply():
|
# Maps a hostname to the addresses to dial for it, or to None when the
|
||||||
opener = urllib.request.build_opener(
|
# host resolves normally and needs no workaround at all. Only populated
|
||||||
DNSBlockWorkaround._HTTPHandler,
|
# for hosts we've already checked, so the check happens once per session.
|
||||||
DNSBlockWorkaround._HTTPSHandler,
|
_resolution_cache = {}
|
||||||
|
|
||||||
|
_original_getaddrinfo = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def apply(cls):
|
||||||
|
"""Correct socket.getaddrinfo() instead of rebuilding the HTTP stack.
|
||||||
|
|
||||||
|
The block only ever corrupts one thing, the answer the resolver
|
||||||
|
hands back, so that is the only thing worth replacing. Fixing it
|
||||||
|
here leaves urllib, http.client and ssl completely stock:
|
||||||
|
socket.create_connection() still does its own address walking,
|
||||||
|
IPv6 handling and error aggregation, and TLS still verifies
|
||||||
|
against the hostname rather than whatever address we dialed.
|
||||||
|
"""
|
||||||
|
if cls._original_getaddrinfo is not None:
|
||||||
|
# Already applied. Patching again would nest the wrapper.
|
||||||
|
return
|
||||||
|
cls._original_getaddrinfo = staticmethod(socket.getaddrinfo)
|
||||||
|
socket.getaddrinfo = cls._getaddrinfo
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _getaddrinfo(cls, host, port, family=0, type=0, proto=0, flags=0):
|
||||||
|
# Signature and argument names mirror socket.getaddrinfo(), which
|
||||||
|
# callers pass both positionally and by keyword.
|
||||||
|
if host in cls._blockable_hosts:
|
||||||
|
addresses = cls._addresses_to_dial(host)
|
||||||
|
if addresses is not None:
|
||||||
|
# Re-resolving each literal address is just parsing; it
|
||||||
|
# builds the 5-tuples callers expect without a lookup.
|
||||||
|
return [
|
||||||
|
addrinfo
|
||||||
|
for address in addresses
|
||||||
|
for addrinfo in cls._original_getaddrinfo(
|
||||||
|
address, port, family, type, proto, flags)
|
||||||
|
]
|
||||||
|
return cls._original_getaddrinfo(host, port, family, type, proto, flags)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _resolve_using_google_dns(cls, hostname):
|
||||||
|
response = urllib.request.urlopen(
|
||||||
|
f"https://dns.google/resolve?name={hostname}",
|
||||||
|
timeout=NETWORK_REQUEST_TIMEOUT,
|
||||||
)
|
)
|
||||||
urllib.request.install_opener(opener)
|
|
||||||
|
|
||||||
def _resolve_using_google_dns(hostname):
|
|
||||||
response = urllib.request.urlopen(f"https://dns.google/resolve?name={hostname}")
|
|
||||||
response = response.read()
|
response = response.read()
|
||||||
response = json.loads(response)
|
response = json.loads(response)
|
||||||
resolved_host = response["Answer"][0]["data"]
|
# Answers can include CNAME records (type 5) alongside the A (1) and
|
||||||
return resolved_host
|
# AAAA (28) records; only the latter are dialable.
|
||||||
|
return [answer["data"] for answer in response.get("Answer", ())
|
||||||
|
if answer.get("type") in (1, 28)]
|
||||||
|
|
||||||
def _resolve_using_system_dns(hostname):
|
@classmethod
|
||||||
|
def _resolve_using_system_dns(cls, hostname):
|
||||||
resolved_host = socket.gethostbyname(hostname)
|
resolved_host = socket.gethostbyname(hostname)
|
||||||
return resolved_host
|
return resolved_host
|
||||||
|
|
||||||
def _resolve_with_workaround(hostname):
|
@classmethod
|
||||||
resolved_host_from_cache = DNSBlockWorkaround._google_dns_cache.get(hostname)
|
def _addresses_to_dial(cls, hostname):
|
||||||
if resolved_host_from_cache:
|
"""Addresses to substitute for `hostname`, or None to leave it alone.
|
||||||
return resolved_host_from_cache
|
|
||||||
|
|
||||||
resolved_host_by_system_dns = DNSBlockWorkaround._resolve_using_system_dns(hostname)
|
Returning None is the common case and means the system resolver's
|
||||||
|
answer stands. Note that both branches yield *every* usable address
|
||||||
|
rather than one: socket.create_connection() walks the list until
|
||||||
|
something answers, and raw.githubusercontent.com publishes four A
|
||||||
|
records whose edge nodes are not all reachable from every network,
|
||||||
|
so collapsing to a single address would fail a share of requests
|
||||||
|
outright.
|
||||||
|
"""
|
||||||
|
if hostname in cls._resolution_cache:
|
||||||
|
return cls._resolution_cache[hostname]
|
||||||
|
|
||||||
if DNSBlockWorkaround._is_blocked(hostname, resolved_host_by_system_dns):
|
try:
|
||||||
resolved_host = DNSBlockWorkaround._resolve_using_google_dns(hostname)
|
resolved_host_by_system_dns = cls._resolve_using_system_dns(hostname)
|
||||||
DNSBlockWorkaround._google_dns_cache[hostname] = resolved_host
|
except socket.gaierror:
|
||||||
|
# A block that answers with NXDOMAIN rather than a bogus address.
|
||||||
|
addresses = cls._resolve_using_google_dns(hostname) or None
|
||||||
else:
|
else:
|
||||||
resolved_host = resolved_host_by_system_dns
|
if cls._is_blocked(hostname, resolved_host_by_system_dns):
|
||||||
|
addresses = cls._resolve_using_google_dns(hostname) or None
|
||||||
|
else:
|
||||||
|
addresses = None
|
||||||
|
|
||||||
return resolved_host
|
cls._resolution_cache[hostname] = addresses
|
||||||
|
return addresses
|
||||||
|
|
||||||
def _is_blocked(hostname, address):
|
@classmethod
|
||||||
|
def _is_blocked(cls, hostname, address):
|
||||||
is_blocked = False
|
is_blocked = False
|
||||||
if hostname == "raw.githubusercontent.com":
|
if hostname == "raw.githubusercontent.com":
|
||||||
# Jio's DNS server may be blocking it.
|
# Jio's DNS server may be blocking it.
|
||||||
|
|
@ -238,36 +397,6 @@ class DNSBlockWorkaround:
|
||||||
|
|
||||||
return is_blocked
|
return is_blocked
|
||||||
|
|
||||||
class _HTTPConnection(http.client.HTTPConnection):
|
|
||||||
def connect(self):
|
|
||||||
host = DNSBlockWorkaround._resolve_with_workaround(self.host)
|
|
||||||
self.sock = socket.create_connection(
|
|
||||||
(host, self.port),
|
|
||||||
self.timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
class _HTTPSConnection(http.client.HTTPSConnection):
|
|
||||||
def connect(self):
|
|
||||||
host = DNSBlockWorkaround._resolve_with_workaround(self.host)
|
|
||||||
sock = socket.create_connection(
|
|
||||||
(host, self.port),
|
|
||||||
self.timeout,
|
|
||||||
)
|
|
||||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
||||||
context.verify_mode = ssl.CERT_REQUIRED
|
|
||||||
context.check_hostname = True
|
|
||||||
context.load_default_certs()
|
|
||||||
sock = context.wrap_socket(sock, server_hostname=self.host)
|
|
||||||
self.sock = sock
|
|
||||||
|
|
||||||
class _HTTPHandler(urllib.request.HTTPHandler):
|
|
||||||
def http_open(self, req):
|
|
||||||
return self.do_open(DNSBlockWorkaround._HTTPConnection, req)
|
|
||||||
|
|
||||||
class _HTTPSHandler(urllib.request.HTTPSHandler):
|
|
||||||
def https_open(self, req):
|
|
||||||
return self.do_open(DNSBlockWorkaround._HTTPSConnection, req)
|
|
||||||
|
|
||||||
|
|
||||||
class StartupTasks:
|
class StartupTasks:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|
@ -414,8 +543,8 @@ class Category:
|
||||||
self.meta_url.format(content_type="raw", tag=self.tag),
|
self.meta_url.format(content_type="raw", tag=self.tag),
|
||||||
headers=self.request_headers,
|
headers=self.request_headers,
|
||||||
)
|
)
|
||||||
response = await async_send_network_request(request)
|
content = await async_send_network_request(request)
|
||||||
self._metadata = json.loads(response.read())
|
self._metadata = json.loads(content)
|
||||||
self.set_category_global_cache("metadata", self._metadata)
|
self.set_category_global_cache("metadata", self._metadata)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
@ -574,7 +703,7 @@ class PluginLocal:
|
||||||
if not self.is_installed:
|
if not self.is_installed:
|
||||||
raise PluginNotInstalled("Plugin is not available locally.")
|
raise PluginNotInstalled("Plugin is not available locally.")
|
||||||
|
|
||||||
self._content = await loop.run_in_executor(None, self._get_content)
|
self._content = await loop.run_in_executor(pool, self._get_content)
|
||||||
return self._content
|
return self._content
|
||||||
|
|
||||||
async def get_api_version(self):
|
async def get_api_version(self):
|
||||||
|
|
@ -655,7 +784,7 @@ class PluginLocal:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def load_plugin(self, entry_point):
|
def load_plugin(self, entry_point):
|
||||||
plugin_class = babase._general.getclass(entry_point, babase.Plugin)
|
plugin_class = babase.getclass(entry_point, babase.Plugin)
|
||||||
loaded_plugin_instance = plugin_class()
|
loaded_plugin_instance = plugin_class()
|
||||||
loaded_plugin_instance.on_app_running()
|
loaded_plugin_instance.on_app_running()
|
||||||
|
|
||||||
|
|
@ -679,7 +808,7 @@ class PluginLocal:
|
||||||
async def set_content(self, content):
|
async def set_content(self, content):
|
||||||
if not self._content:
|
if not self._content:
|
||||||
|
|
||||||
await loop.run_in_executor(None, self._set_content, content)
|
await loop.run_in_executor(pool, self._set_content, content)
|
||||||
self._content = content
|
self._content = content
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
@ -701,14 +830,23 @@ class PluginLocal:
|
||||||
class PluginVersion:
|
class PluginVersion:
|
||||||
def __init__(self, plugin, version, tag=CURRENT_TAG):
|
def __init__(self, plugin, version, tag=CURRENT_TAG):
|
||||||
self.number, info = version
|
self.number, info = version
|
||||||
self.plugin = plugin
|
# Plugin already owns its PluginVersions (via `versions`,
|
||||||
|
# `latest_version`, `latest_compatible_version`); holding a strong
|
||||||
|
# back-reference here would form a Plugin<->PluginVersion cycle
|
||||||
|
# that only the cyclic GC can free. A weakref avoids that so
|
||||||
|
# they're freed by refcounting alone.
|
||||||
|
self._plugin_ref = weakref.ref(plugin)
|
||||||
self.api_version = info["api_version"]
|
self.api_version = info["api_version"]
|
||||||
self.released_on = info["released_on"]
|
self.released_on = info["released_on"]
|
||||||
self.commit_sha = info["commit_sha"]
|
self.commit_sha = info["commit_sha"]
|
||||||
self.md5sum = info["md5sum"]
|
self.md5sum = info["md5sum"]
|
||||||
|
|
||||||
self.download_url = self.plugin.url.format(content_type="raw", tag=tag)
|
self.download_url = plugin.url.format(content_type="raw", tag=tag)
|
||||||
self.view_url = self.plugin.url.format(content_type="blob", tag=tag)
|
self.view_url = plugin.url.format(content_type="blob", tag=tag)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def plugin(self):
|
||||||
|
return self._plugin_ref()
|
||||||
|
|
||||||
def __eq__(self, plugin_version):
|
def __eq__(self, plugin_version):
|
||||||
return (self.number, self.plugin.name) == (plugin_version.number,
|
return (self.number, self.plugin.name) == (plugin_version.number,
|
||||||
|
|
@ -863,9 +1001,11 @@ class PluginManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.request_headers = HEADERS
|
self.request_headers = HEADERS
|
||||||
self._index = _CACHE.get("index", {})
|
self._index = _CACHE.get("index", {})
|
||||||
self._changelog = _CACHE.get("changelog", {})
|
# The raw changelog text, kept separate from the parsed entry in
|
||||||
|
# _CACHE["changelog"]; see setup_changelog().
|
||||||
|
self._changelog = _CACHE.get("changelog_source")
|
||||||
self.categories = {}
|
self.categories = {}
|
||||||
self.module_path = sys.modules[__name__].__file__
|
self.module_path = __file__
|
||||||
self._index_setup_in_progress = False
|
self._index_setup_in_progress = False
|
||||||
self._changelog_setup_in_progress = False
|
self._changelog_setup_in_progress = False
|
||||||
|
|
||||||
|
|
@ -879,8 +1019,8 @@ class PluginManager:
|
||||||
),
|
),
|
||||||
headers=self.request_headers,
|
headers=self.request_headers,
|
||||||
)
|
)
|
||||||
response = await async_send_network_request(request)
|
content = await async_send_network_request(request)
|
||||||
index = json.loads(response.read())
|
index = json.loads(content)
|
||||||
self.set_index_global_cache(index)
|
self.set_index_global_cache(index)
|
||||||
self._index = index
|
self._index = index
|
||||||
return self._index
|
return self._index
|
||||||
|
|
@ -891,12 +1031,17 @@ class PluginManager:
|
||||||
# Rather wait for the previous network call to complete.
|
# Rather wait for the previous network call to complete.
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
self._index_setup_in_progress = not bool(self._index)
|
self._index_setup_in_progress = not bool(self._index)
|
||||||
index = await self.get_index()
|
try:
|
||||||
await self.setup_plugin_categories(index)
|
index = await self.get_index()
|
||||||
self._index_setup_in_progress = False
|
await self.setup_plugin_categories(index)
|
||||||
|
finally:
|
||||||
|
# Must clear even when the setup raised, or every later call
|
||||||
|
# spins in the loop above forever waiting on a call that has
|
||||||
|
# already given up.
|
||||||
|
self._index_setup_in_progress = False
|
||||||
|
|
||||||
async def get_changelog(self) -> list[str, bool]:
|
async def get_changelog(self) -> str:
|
||||||
requested = False
|
"""The full CHANGELOG.md text, fetched once per session."""
|
||||||
if not self._changelog:
|
if not self._changelog:
|
||||||
request = urllib.request.Request(CHANGELOG_META.format(
|
request = urllib.request.Request(CHANGELOG_META.format(
|
||||||
repository_url=REPOSITORY_URL,
|
repository_url=REPOSITORY_URL,
|
||||||
|
|
@ -904,10 +1049,10 @@ class PluginManager:
|
||||||
tag=CURRENT_TAG
|
tag=CURRENT_TAG
|
||||||
),
|
),
|
||||||
headers=self.request_headers)
|
headers=self.request_headers)
|
||||||
response = await async_send_network_request(request)
|
content = await async_send_network_request(request)
|
||||||
self._changelog = response.read().decode()
|
self._changelog = content.decode()
|
||||||
requested = True
|
self.set_changelog_source_global_cache(self._changelog)
|
||||||
return [self._changelog, requested]
|
return self._changelog
|
||||||
|
|
||||||
async def setup_changelog(self, version=None) -> None:
|
async def setup_changelog(self, version=None) -> None:
|
||||||
if version is None:
|
if version is None:
|
||||||
|
|
@ -918,14 +1063,18 @@ class PluginManager:
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
self._changelog_setup_in_progress = not bool(self._changelog)
|
self._changelog_setup_in_progress = not bool(self._changelog)
|
||||||
try:
|
try:
|
||||||
full_changelog = await self.get_changelog()
|
try:
|
||||||
# check if the changelog was requested
|
# Parsing is pure string work on text we already hold, so it
|
||||||
if full_changelog[1]:
|
# runs every time rather than only on the call that fetched.
|
||||||
|
# Skipping it was what let the raw text reach the cache in
|
||||||
|
# place of the parsed entry ChangelogWindow reads.
|
||||||
|
full_changelog = await self.get_changelog()
|
||||||
pattern = rf"### {version} \(\d\d-\d\d-\d{{4}}\)\n(.*?)(?=### \d+\.\d+\.\d+|\Z)"
|
pattern = rf"### {version} \(\d\d-\d\d-\d{{4}}\)\n(.*?)(?=### \d+\.\d+\.\d+|\Z)"
|
||||||
if (len(full_changelog[0].split(version)) > 1):
|
if (len(full_changelog.split(version)) > 1):
|
||||||
released_on = full_changelog[0].split(version)[1].split('\n')[0]
|
released_on = full_changelog.split(version)[1].split('\n')[0]
|
||||||
matches = re.findall(pattern, full_changelog[0], re.DOTALL)
|
matches = re.findall(pattern, full_changelog, re.DOTALL)
|
||||||
else:
|
else:
|
||||||
|
released_on = ' (Not Provided)'
|
||||||
matches = None
|
matches = None
|
||||||
|
|
||||||
if matches:
|
if matches:
|
||||||
|
|
@ -934,15 +1083,17 @@ class PluginManager:
|
||||||
'info': matches[0].strip()
|
'info': matches[0].strip()
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
changelog = {'released_on': ' (Not Provided)',
|
changelog = {'released_on': released_on,
|
||||||
'info': f"Changelog entry for version {version} not found."}
|
'info': f"Changelog entry for version {version} not found."}
|
||||||
else:
|
except urllib.error.URLError:
|
||||||
changelog = full_changelog[0]
|
changelog = {'released_on': ' (Not Provided)',
|
||||||
except urllib.error.URLError:
|
'info': 'Could not get ChangeLog due to Internet Issues.'}
|
||||||
changelog = {'released_on': ' (Not Provided)',
|
self.set_changelog_global_cache(changelog)
|
||||||
'info': 'Could not get ChangeLog due to Internet Issues.'}
|
finally:
|
||||||
self.set_changelog_global_cache(changelog)
|
# Must clear even when the setup raised, or every later call
|
||||||
self._changelog_setup_in_progress = False
|
# spins in the loop above forever waiting on a call that has
|
||||||
|
# already given up.
|
||||||
|
self._changelog_setup_in_progress = False
|
||||||
|
|
||||||
async def setup_plugin_categories(self, plugin_index):
|
async def setup_plugin_categories(self, plugin_index):
|
||||||
# A hack to have the "All" category show at the top.
|
# A hack to have the "All" category show at the top.
|
||||||
|
|
@ -994,12 +1145,12 @@ class PluginManager:
|
||||||
def set_changelog_global_cache(self, changelog):
|
def set_changelog_global_cache(self, changelog):
|
||||||
_CACHE["changelog"] = changelog
|
_CACHE["changelog"] = changelog
|
||||||
|
|
||||||
|
def set_changelog_source_global_cache(self, changelog_source):
|
||||||
|
_CACHE["changelog_source"] = changelog_source
|
||||||
|
|
||||||
def unset_index_global_cache(self):
|
def unset_index_global_cache(self):
|
||||||
try:
|
for key in ("index", "changelog", "changelog_source"):
|
||||||
del _CACHE["index"]
|
_CACHE.pop(key, None)
|
||||||
del _CACHE["changelog"]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def get_update_details(self):
|
async def get_update_details(self):
|
||||||
index = await self.get_index()
|
index = await self.get_index()
|
||||||
|
|
@ -1029,8 +1180,7 @@ class PluginManager:
|
||||||
content_type="raw",
|
content_type="raw",
|
||||||
tag=tag,
|
tag=tag,
|
||||||
)
|
)
|
||||||
response = await async_send_network_request(download_url)
|
content = await async_send_network_request(download_url)
|
||||||
content = response.read()
|
|
||||||
if hashlib.md5(content).hexdigest() != to_version_info["md5sum"]:
|
if hashlib.md5(content).hexdigest() != to_version_info["md5sum"]:
|
||||||
raise MD5CheckSumFailed("MD5 checksum failed during plugin manager update.")
|
raise MD5CheckSumFailed("MD5 checksum failed during plugin manager update.")
|
||||||
with open(self.module_path, "wb") as fout:
|
with open(self.module_path, "wb") as fout:
|
||||||
|
|
@ -1046,7 +1196,7 @@ class ChangelogWindow(popup.PopupWindow):
|
||||||
self.scale_origin = origin_widget.get_screen_space_center()
|
self.scale_origin = origin_widget.get_screen_space_center()
|
||||||
s = 1.65 if _uiscale() is babase.UIScale.SMALL else 1.39 if _uiscale() is babase.UIScale.MEDIUM else 1.67
|
s = 1.65 if _uiscale() is babase.UIScale.SMALL else 1.39 if _uiscale() is babase.UIScale.MEDIUM else 1.67
|
||||||
width = 400 * s
|
width = 400 * s
|
||||||
height = width * 0.5
|
height = width * 0.6
|
||||||
color = (1, 1, 1)
|
color = (1, 1, 1)
|
||||||
text_scale = 0.7 * s
|
text_scale = 0.7 * s
|
||||||
self._transition_out = 'out_scale'
|
self._transition_out = 'out_scale'
|
||||||
|
|
@ -1092,12 +1242,10 @@ class ChangelogWindow(popup.PopupWindow):
|
||||||
released_on = _CACHE['changelog']['released_on']
|
released_on = _CACHE['changelog']['released_on']
|
||||||
logs = _CACHE['changelog']['info'].split('\n')
|
logs = _CACHE['changelog']['info'].split('\n')
|
||||||
h_align = 'left'
|
h_align = 'left'
|
||||||
extra = 0.1
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
released_on = ''
|
released_on = ''
|
||||||
logs = ["Could not load ChangeLog"]
|
logs = ["Could not load ChangeLog"]
|
||||||
h_align = 'center'
|
h_align = 'center'
|
||||||
extra = 1
|
|
||||||
|
|
||||||
bui.textwidget(
|
bui.textwidget(
|
||||||
parent=self._root_widget,
|
parent=self._root_widget,
|
||||||
|
|
@ -1125,20 +1273,48 @@ class ChangelogWindow(popup.PopupWindow):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
loop_height = height * 0.62
|
# The entry can be arbitrarily long, so it scrolls rather than
|
||||||
|
# spilling out of the bottom of the window.
|
||||||
|
scroll_width = width * 0.88
|
||||||
|
scroll_height = height * 0.62
|
||||||
|
self._scrollwidget = bui.scrollwidget(
|
||||||
|
parent=self._root_widget,
|
||||||
|
size=(scroll_width, scroll_height),
|
||||||
|
position=(width * 0.06, height * 0.04),
|
||||||
|
capture_arrows=True
|
||||||
|
)
|
||||||
|
|
||||||
|
body_scale = text_scale * 0.7
|
||||||
|
line_height = 36 * body_scale
|
||||||
|
text_width = scroll_width - 30
|
||||||
|
if h_align == 'left':
|
||||||
|
# Leave headroom below `maxwidth` so no line ends up shrunk
|
||||||
|
# (and thus smaller than its neighbours) by a rounding hair.
|
||||||
|
logs = _wrap_markdown_bullets(logs, text_width * 0.95, body_scale)
|
||||||
|
|
||||||
|
content_height = max(scroll_height, line_height * len(logs) + 20)
|
||||||
|
content = bui.containerwidget(
|
||||||
|
parent=self._scrollwidget,
|
||||||
|
size=(text_width, content_height),
|
||||||
|
background=False,
|
||||||
|
claims_left_right=False
|
||||||
|
)
|
||||||
|
|
||||||
|
loop_height = content_height - line_height * 0.5 - 10
|
||||||
for log in logs:
|
for log in logs:
|
||||||
bui.textwidget(
|
bui.textwidget(
|
||||||
parent=self._root_widget,
|
parent=content,
|
||||||
position=(width * 0.5 * extra, loop_height),
|
position=(text_width * 0.5 if h_align == 'center' else 0,
|
||||||
|
loop_height),
|
||||||
size=(0, 0),
|
size=(0, 0),
|
||||||
h_align=h_align,
|
h_align=h_align,
|
||||||
v_align='center',
|
v_align='center',
|
||||||
text=log,
|
text=log,
|
||||||
scale=text_scale,
|
scale=body_scale,
|
||||||
color=color,
|
color=color,
|
||||||
maxwidth=width * 0.9
|
maxwidth=text_width
|
||||||
)
|
)
|
||||||
loop_height -= 30
|
loop_height -= line_height
|
||||||
|
|
||||||
def _back(self) -> None:
|
def _back(self) -> None:
|
||||||
bui.getsound('swish').play()
|
bui.getsound('swish').play()
|
||||||
|
|
@ -1731,6 +1907,7 @@ class PluginWindow(popup.PopupWindow):
|
||||||
_remove_popup(self)
|
_remove_popup(self)
|
||||||
bui.containerwidget(edit=self._root_widget, transition='out_scale')
|
bui.containerwidget(edit=self._root_widget, transition='out_scale')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def button(fn):
|
def button(fn):
|
||||||
async def asyncio_handler(fn, self, *args, **kwargs):
|
async def asyncio_handler(fn, self, *args, **kwargs):
|
||||||
await fn(self, *args, **kwargs)
|
await fn(self, *args, **kwargs)
|
||||||
|
|
@ -2256,8 +2433,8 @@ class PluginManagerWindow(bui.MainWindow):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
transition: str = "in_right",
|
transition: str | None = "in_right",
|
||||||
origin_widget: bui.Widget = None
|
origin_widget: bui.Widget | None = None
|
||||||
):
|
):
|
||||||
self.plugin_manager = PluginManager()
|
self.plugin_manager = PluginManager()
|
||||||
self.category_selection_button = None
|
self.category_selection_button = None
|
||||||
|
|
@ -3415,7 +3592,7 @@ class EntryPoint(babase.Plugin):
|
||||||
from bauiv1lib.settings import allsettings
|
from bauiv1lib.settings import allsettings
|
||||||
allsettings.AllSettingsWindow = NewAllSettingsWindow
|
allsettings.AllSettingsWindow = NewAllSettingsWindow
|
||||||
DNSBlockWorkaround.apply()
|
DNSBlockWorkaround.apply()
|
||||||
asyncio.set_event_loop(babase._asyncio._asyncio_event_loop)
|
babase.app.add_shutdown_task(_shutdown_network_pool())
|
||||||
startup_tasks = StartupTasks()
|
startup_tasks = StartupTasks()
|
||||||
|
|
||||||
loop.create_task(startup_tasks.execute())
|
loop.create_task(startup_tasks.execute())
|
||||||
|
|
|
||||||
|
|
@ -1663,6 +1663,61 @@
|
||||||
"md5sum": "33e6b93420cf6115e992aca534d76bfd"
|
"md5sum": "33e6b93420cf6115e992aca534d76bfd"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"volley_punch": {
|
||||||
|
"description": "Have fun with your friends in this new volley game !",
|
||||||
|
"external_url": "https://github.com/Scriptz1/Learn.py-Installer/blob/main/volleypunch.py",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Learn.py",
|
||||||
|
"email": "phillipians36@gmail.com",
|
||||||
|
"discord": "Learning .py"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versions": {
|
||||||
|
"1.1.0": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "a6d11fd",
|
||||||
|
"released_on": "02-06-2026",
|
||||||
|
"md5sum": "a2d73ebe4cb84b723242c187ad5b5fbb"
|
||||||
|
},
|
||||||
|
"1.0.0": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "576a9c0",
|
||||||
|
"released_on": "01-06-2026",
|
||||||
|
"md5sum": "be998b599896ed0e8dd5f2545fae6173"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vanishing_tiles": {
|
||||||
|
"description": "Tiles disappear gradually. Survive each round to continue. Last player standing wins!",
|
||||||
|
"external_url": "https://discord.gg/sQGDsztQcy",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mr.Paradox",
|
||||||
|
"email": "",
|
||||||
|
"discord": "gaurangbroyo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "senchx",
|
||||||
|
"email": "",
|
||||||
|
"discord": "senchx0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versions": {
|
||||||
|
"2.1.1": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "5d7d222",
|
||||||
|
"released_on": "11-06-2026",
|
||||||
|
"md5sum": "d71b9615872ba3655a83166dd211ecd2"
|
||||||
|
},
|
||||||
|
"2.1.0": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "0749053",
|
||||||
|
"released_on": "10-06-2026",
|
||||||
|
"md5sum": "152e2e149b2686619c44aa7880a7e460"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
414
plugins/minigames/vanishing_tiles.py
Normal file
414
plugins/minigames/vanishing_tiles.py
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
# ba_meta require api 9
|
||||||
|
from __future__ import annotations
|
||||||
|
from bascenev1lib.gameutils import SharedObjects
|
||||||
|
import bascenev1 as bs
|
||||||
|
import babase
|
||||||
|
import random
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
|
# Vanishing Tiles - BombSquad Minigame
|
||||||
|
# Tiles disappear one by one. Survive all rounds to win.
|
||||||
|
# Last player/team standing takes the crown.
|
||||||
|
|
||||||
|
plugman = dict(
|
||||||
|
plugin_name="vanishing_tiles",
|
||||||
|
description="Tiles disappear gradually. Survive each round to continue. Last player standing wins!",
|
||||||
|
external_url="https://discord.gg/sQGDsztQcy",
|
||||||
|
authors=[
|
||||||
|
{"name": "Mr.Paradox", "email": "", "discord": "gaurangbroyo"},
|
||||||
|
{"name": "senchx", "email": "", "discord": "senchx0"},
|
||||||
|
],
|
||||||
|
version="2.1.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Player(bs.Player['Team']):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.death_time: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Team(bs.Team[Player]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ba_meta export bascenev1.GameActivity
|
||||||
|
class VanishingTilesGame(bs.TeamGameActivity[Player, Team]):
|
||||||
|
|
||||||
|
name = 'Vanishing Tiles'
|
||||||
|
description = 'Tiles disappear one by one. Be the last one standing!'
|
||||||
|
scoreconfig = bs.ScoreConfig(label='Survived', scoretype=bs.ScoreType.MILLISECONDS)
|
||||||
|
announce_player_deaths = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_available_settings(cls, sessiontype: type[bs.Session]) -> list[babase.Setting]:
|
||||||
|
return [
|
||||||
|
bs.BoolSetting('Epic Mode', default=False),
|
||||||
|
bs.BoolSetting('Show Credits', default=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool:
|
||||||
|
return issubclass(sessiontype, (bs.FreeForAllSession, bs.DualTeamSession))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]:
|
||||||
|
return ['Vanishing Tiles Arena']
|
||||||
|
|
||||||
|
def __init__(self, settings: dict) -> None:
|
||||||
|
super().__init__(settings)
|
||||||
|
self._epic_mode: bool = bool(settings.get('Epic Mode', False))
|
||||||
|
self._show_credits: bool = bool(settings.get('Show Credits', True))
|
||||||
|
self.default_music = bs.MusicType.EPIC if self._epic_mode else bs.MusicType.SURVIVAL
|
||||||
|
|
||||||
|
self._round: int = 1
|
||||||
|
self._removing: bool = False
|
||||||
|
self._game_start_time: Optional[float] = None
|
||||||
|
|
||||||
|
self._tile_nodes: Dict[int, bs.Node] = {}
|
||||||
|
self._region_nodes: Dict[int, bs.Node] = {}
|
||||||
|
self._present_tile_ids: set[int] = set()
|
||||||
|
|
||||||
|
self._remove_speed: float = 1.8
|
||||||
|
|
||||||
|
self._collide_mat = bs.Material()
|
||||||
|
self._collide_mat.add_actions(actions=(('modify_part_collision', 'collide', True),))
|
||||||
|
self._no_collide_mat = bs.Material()
|
||||||
|
self._no_collide_mat.add_actions(actions=(('modify_part_collision', 'collide', False),))
|
||||||
|
|
||||||
|
self._default_tex = bs.gettexture('powerupHealth')
|
||||||
|
self._warn_tex = bs.gettexture('powerupCurse')
|
||||||
|
self._final_tex = bs.gettexture('powerupPunch')
|
||||||
|
|
||||||
|
self._hud_round: Optional[bs.Node] = None
|
||||||
|
self._hud_players: Optional[bs.Node] = None
|
||||||
|
self._hud_tiles: Optional[bs.Node] = None
|
||||||
|
self._credit_node: Optional[bs.Node] = None
|
||||||
|
|
||||||
|
if self._epic_mode:
|
||||||
|
self.slow_motion = True
|
||||||
|
|
||||||
|
def spawn_player(self, player: Player) -> Any:
|
||||||
|
if isinstance(self.session, bs.FreeForAllSession):
|
||||||
|
spaz = self.spawn_player_spaz(player, position=VanishingTilesMapDefs.points['spawn1'])
|
||||||
|
else:
|
||||||
|
spaz = self.spawn_player_spaz(player)
|
||||||
|
spaz.connect_controls_to_player(enable_punch=False, enable_pickup=False, enable_bomb=False)
|
||||||
|
spaz.set_bomb_count(0)
|
||||||
|
return spaz
|
||||||
|
|
||||||
|
def on_begin(self) -> None:
|
||||||
|
super().on_begin()
|
||||||
|
self._game_start_time = bs.time()
|
||||||
|
|
||||||
|
if self._show_credits:
|
||||||
|
self._credit_node = bs.newnode('text', attrs={
|
||||||
|
'text': 'Made by Mr.Paradox',
|
||||||
|
'scale': 0.7, 'position': (0, 8), 'shadow': 0.8, 'flatness': 1.0,
|
||||||
|
'color': (1.0, 0.3, 0.8, 1.0), 'h_align': 'center', 'v_attach': 'bottom',
|
||||||
|
})
|
||||||
|
bs.animate_array(self._credit_node, 'color', 4, {
|
||||||
|
0.0: [1.0, 0.3, 0.8, 1.0], 1.0: [0.3, 0.8, 1.0, 1.0],
|
||||||
|
2.0: [0.4, 1.0, 0.4, 1.0], 3.0: [1.0, 1.0, 0.2, 1.0],
|
||||||
|
4.0: [1.0, 0.3, 0.8, 1.0],
|
||||||
|
}, loop=True)
|
||||||
|
|
||||||
|
self._build_hud()
|
||||||
|
self._cleanup_tiles()
|
||||||
|
self._spawn_all_tiles()
|
||||||
|
self._show_round_banner()
|
||||||
|
|
||||||
|
if not isinstance(self.session, bs.FreeForAllSession):
|
||||||
|
if not all(len(t.players) >= 1 for t in self.teams):
|
||||||
|
bs.broadcastmessage('Not enough players - draw!', color=(1, 1, 0))
|
||||||
|
bs.timer(1.0, lambda: self.end(bs.GameResults()))
|
||||||
|
return
|
||||||
|
|
||||||
|
bs.timer(1.0, self._check_initial_state)
|
||||||
|
bs.timer(3.0, self._start_removal)
|
||||||
|
|
||||||
|
def _build_hud(self) -> None:
|
||||||
|
for attr in ('_hud_round', '_hud_players', '_hud_tiles'):
|
||||||
|
node = getattr(self, attr, None)
|
||||||
|
if node is not None and node.exists():
|
||||||
|
node.delete()
|
||||||
|
setattr(self, attr, None)
|
||||||
|
|
||||||
|
self._hud_round = bs.newnode('text', attrs={
|
||||||
|
'text': '', 'scale': 0.85, 'position': (0, -40), 'maxwidth': 300,
|
||||||
|
'h_align': 'center', 'v_align': 'center', 'v_attach': 'top', 'h_attach': 'center',
|
||||||
|
'shadow': 1.0, 'flatness': 1.0, 'color': (1, 1, 1, 1), 'in_world': False,
|
||||||
|
})
|
||||||
|
self._hud_players = bs.newnode('text', attrs={
|
||||||
|
'text': '', 'scale': 0.8, 'position': (0, -58), 'maxwidth': 300,
|
||||||
|
'h_align': 'center', 'v_align': 'center', 'v_attach': 'top', 'h_attach': 'center',
|
||||||
|
'shadow': 1.0, 'flatness': 1.0, 'color': (1, 0.8, 0.2, 1), 'in_world': False,
|
||||||
|
})
|
||||||
|
self._hud_tiles = bs.newnode('text', attrs={
|
||||||
|
'text': '', 'scale': 0.8, 'position': (0, -74), 'maxwidth': 300,
|
||||||
|
'h_align': 'center', 'v_align': 'center', 'v_attach': 'top', 'h_attach': 'center',
|
||||||
|
'shadow': 1.0, 'flatness': 1.0, 'color': (0.5, 1.0, 0.5, 1), 'in_world': False,
|
||||||
|
})
|
||||||
|
self._refresh_hud()
|
||||||
|
|
||||||
|
def _refresh_hud(self) -> None:
|
||||||
|
alive = len([p for p in self.players if p.is_alive()])
|
||||||
|
tiles = len(self._present_tile_ids)
|
||||||
|
urgent = tiles <= 4
|
||||||
|
|
||||||
|
if self._hud_round:
|
||||||
|
self._hud_round.text = f'Round {self._round}'
|
||||||
|
if self._hud_players:
|
||||||
|
self._hud_players.text = f'Players alive: {alive}'
|
||||||
|
if self._hud_tiles:
|
||||||
|
self._hud_tiles.color = (1.0, 0.3, 0.3, 1) if urgent else (0.5, 1.0, 0.5, 1)
|
||||||
|
self._hud_tiles.text = f'Tiles left: {tiles}'
|
||||||
|
|
||||||
|
def _show_round_banner(self) -> None:
|
||||||
|
node = bs.newnode('text', attrs={
|
||||||
|
'text': f'Round {self._round}', 'scale': 1.3, 'position': (0, 60),
|
||||||
|
'shadow': 1.2, 'flatness': 0.7, 'color': (1, 1, 0, 1),
|
||||||
|
'h_align': 'center', 'v_attach': 'center', 'in_world': False,
|
||||||
|
})
|
||||||
|
bs.animate(node, 'scale', {0: 0.0, 0.15: 1.3, 2.0: 1.3, 2.5: 0.0})
|
||||||
|
bs.timer(2.6, node.delete)
|
||||||
|
|
||||||
|
def on_player_join(self, player: Player) -> None:
|
||||||
|
if self.has_begun():
|
||||||
|
player.death_time = bs.time()
|
||||||
|
bs.broadcastmessage(
|
||||||
|
f'{player.getname()} joined! Round {self._round} - {len(self._present_tile_ids)} tiles left.',
|
||||||
|
color=(0.5, 1.0, 1.0), transient=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.spawn_player(player)
|
||||||
|
|
||||||
|
def _check_initial_state(self) -> None:
|
||||||
|
if not [p for p in self.players if p.is_alive()]:
|
||||||
|
bs.timer(1.0, self._check_end_game)
|
||||||
|
|
||||||
|
def _spawn_all_tiles(self) -> None:
|
||||||
|
positions = [
|
||||||
|
(4.5, 2, -9), (4.5, 2, -6), (4.5, 2, -3), (4.5, 2, 0),
|
||||||
|
(1.5, 2, -9), (1.5, 2, -6), (1.5, 2, -3), (1.5, 2, 0),
|
||||||
|
(-1.5, 2, -9), (-1.5, 2, -6), (-1.5, 2, -3), (-1.5, 2, 0),
|
||||||
|
(-4.5, 2, -9), (-4.5, 2, -6), (-4.5, 2, -3), (-4.5, 2, 0),
|
||||||
|
]
|
||||||
|
model = bs.getmesh('buttonSquareOpaque')
|
||||||
|
shared = SharedObjects.get()
|
||||||
|
for i, pos in enumerate(positions):
|
||||||
|
tile = bs.newnode('prop', attrs={
|
||||||
|
'body': 'puck', 'position': pos, 'mesh': model,
|
||||||
|
'mesh_scale': 3.73, 'body_scale': 3.73, 'gravity_scale': 0.0,
|
||||||
|
'color_texture': self._default_tex, 'reflection': 'soft',
|
||||||
|
'materials': [self._no_collide_mat],
|
||||||
|
})
|
||||||
|
region = bs.newnode('region', attrs={
|
||||||
|
'position': pos, 'scale': (3.5, 0.1, 3.5), 'type': 'box',
|
||||||
|
'materials': [self._collide_mat, shared.footing_material],
|
||||||
|
})
|
||||||
|
self._tile_nodes[i] = tile
|
||||||
|
self._region_nodes[i] = region
|
||||||
|
self._present_tile_ids.add(i)
|
||||||
|
self._refresh_hud()
|
||||||
|
|
||||||
|
def _start_removal(self) -> None:
|
||||||
|
if not self._removing:
|
||||||
|
self._removing = True
|
||||||
|
self._remove_next_tile()
|
||||||
|
|
||||||
|
def _make_final_tile(self) -> None:
|
||||||
|
try:
|
||||||
|
tile_id = list(self._present_tile_ids)[0]
|
||||||
|
tile = self._tile_nodes.get(tile_id)
|
||||||
|
if tile and tile.exists():
|
||||||
|
tile.color_texture = self._final_tex
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.slow_motion = True
|
||||||
|
bs.broadcastmessage('LAST TILE!', color=(1, 0.4, 0.1))
|
||||||
|
for p in self.players:
|
||||||
|
if p.is_alive() and p.actor and p.actor.exists():
|
||||||
|
p.actor.node.handlemessage(bs.CelebrateMessage(3.0))
|
||||||
|
self._refresh_hud()
|
||||||
|
bs.timer(3.0, self._round_end_check)
|
||||||
|
|
||||||
|
def _remove_next_tile(self) -> None:
|
||||||
|
if len(self._present_tile_ids) <= 1:
|
||||||
|
self._make_final_tile()
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(self._present_tile_ids) <= 4:
|
||||||
|
bs.getsound('shieldDown').play(0.4)
|
||||||
|
|
||||||
|
tile_id = random.choice(list(self._present_tile_ids))
|
||||||
|
self._remove_tile(tile_id)
|
||||||
|
bs.timer(self._remove_speed, self._remove_next_tile)
|
||||||
|
|
||||||
|
def _remove_tile(self, tile_id: int) -> None:
|
||||||
|
tile = self._tile_nodes.get(tile_id)
|
||||||
|
region = self._region_nodes.get(tile_id)
|
||||||
|
if tile is None or not tile.exists():
|
||||||
|
self._present_tile_ids.discard(tile_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
tile.color_texture = self._warn_tex
|
||||||
|
|
||||||
|
def vanish() -> None:
|
||||||
|
if tile.exists():
|
||||||
|
bs.emitfx(position=tile.position, count=20, scale=0.9,
|
||||||
|
spread=0.5, chunk_type='spark')
|
||||||
|
tile.delete()
|
||||||
|
if region and region.exists():
|
||||||
|
region.delete()
|
||||||
|
self._present_tile_ids.discard(tile_id)
|
||||||
|
self._refresh_hud()
|
||||||
|
|
||||||
|
bs.timer(1.0, vanish)
|
||||||
|
|
||||||
|
def _round_end_check(self) -> None:
|
||||||
|
self.slow_motion = self._epic_mode
|
||||||
|
|
||||||
|
if not isinstance(self.session, bs.FreeForAllSession):
|
||||||
|
living_teams = [t for t in self.teams if any(p.is_alive() for p in t.players)]
|
||||||
|
if len(living_teams) <= 1:
|
||||||
|
self._removing = False
|
||||||
|
self.end_game()
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
if len([p for p in self.players if p.is_alive()]) <= 1:
|
||||||
|
self._removing = False
|
||||||
|
self.end_game()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._remove_speed = max(0.3, self._remove_speed * 0.75)
|
||||||
|
self._round += 1
|
||||||
|
survivors = [p for p in self.players if p.is_alive()]
|
||||||
|
bs.broadcastmessage(f'Round {self._round}! Speed increasing!', color=(0.5, 1.0, 1.0))
|
||||||
|
bs.timer(1.5, lambda s=survivors: self._next_round(s))
|
||||||
|
|
||||||
|
def _next_round(self, survivors: List[Player]) -> None:
|
||||||
|
safety = bs.newnode('region', attrs={
|
||||||
|
'position': (0, 1.5, -5), 'scale': (20, 0.2, 20), 'type': 'box',
|
||||||
|
'materials': [SharedObjects.get().footing_material],
|
||||||
|
})
|
||||||
|
self._cleanup_tiles()
|
||||||
|
self._spawn_all_tiles()
|
||||||
|
self._show_round_banner()
|
||||||
|
self._refresh_hud()
|
||||||
|
|
||||||
|
for p in survivors:
|
||||||
|
if p.actor and p.actor.exists():
|
||||||
|
p.actor.handlemessage(bs.StandMessage(VanishingTilesMapDefs.points['spawn1']))
|
||||||
|
|
||||||
|
bs.timer(2.5, safety.delete)
|
||||||
|
self._removing = False
|
||||||
|
bs.timer(3.0, self._start_removal)
|
||||||
|
|
||||||
|
def handlemessage(self, msg: Any) -> Any:
|
||||||
|
if isinstance(msg, bs.PlayerDiedMessage):
|
||||||
|
super().handlemessage(msg)
|
||||||
|
msg.getplayer(Player).death_time = bs.time()
|
||||||
|
self._refresh_hud()
|
||||||
|
bs.timer(0.5, self._check_end_game)
|
||||||
|
return None
|
||||||
|
return super().handlemessage(msg)
|
||||||
|
|
||||||
|
def on_player_leave(self, player: Player) -> None:
|
||||||
|
super().on_player_leave(player)
|
||||||
|
bs.timer(0.2, self._check_team_empty)
|
||||||
|
|
||||||
|
def _check_team_empty(self) -> None:
|
||||||
|
if self.has_ended():
|
||||||
|
return
|
||||||
|
if isinstance(self.session, bs.FreeForAllSession):
|
||||||
|
return
|
||||||
|
for team in self.teams:
|
||||||
|
if len(team.players) == 0:
|
||||||
|
bs.broadcastmessage('A team has no players - game over!', color=(1, 1, 0))
|
||||||
|
bs.timer(1.0, self.end_game)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _check_end_game(self) -> None:
|
||||||
|
if not self.has_begun():
|
||||||
|
return
|
||||||
|
if not isinstance(self.session, bs.FreeForAllSession):
|
||||||
|
if len([t for t in self.teams if any(p.is_alive() for p in t.players)]) <= 1:
|
||||||
|
self.end_game()
|
||||||
|
else:
|
||||||
|
if len([p for p in self.players if p.is_alive()]) <= 1:
|
||||||
|
self.end_game()
|
||||||
|
|
||||||
|
def end_game(self) -> None:
|
||||||
|
if self.has_ended():
|
||||||
|
return
|
||||||
|
cur_time = bs.time()
|
||||||
|
start = self._game_start_time or cur_time
|
||||||
|
results = bs.GameResults()
|
||||||
|
for team in self.teams:
|
||||||
|
longest = 0.0
|
||||||
|
for p in team.players:
|
||||||
|
death = p.death_time or (cur_time + 1)
|
||||||
|
longest = max(longest, death - start)
|
||||||
|
results.set_team_score(team, int(longest * 1000))
|
||||||
|
|
||||||
|
for attr in ('_hud_round', '_hud_players', '_hud_tiles'):
|
||||||
|
node = getattr(self, attr, None)
|
||||||
|
if node is not None and node.exists():
|
||||||
|
node.delete()
|
||||||
|
|
||||||
|
if self._credit_node and self._credit_node.exists():
|
||||||
|
self._credit_node.delete()
|
||||||
|
|
||||||
|
self.end(results=results)
|
||||||
|
|
||||||
|
def _cleanup_tiles(self) -> None:
|
||||||
|
for n in list(self._tile_nodes.values()):
|
||||||
|
if n.exists():
|
||||||
|
n.delete()
|
||||||
|
for n in list(self._region_nodes.values()):
|
||||||
|
if n.exists():
|
||||||
|
n.delete()
|
||||||
|
self._tile_nodes.clear()
|
||||||
|
self._region_nodes.clear()
|
||||||
|
self._present_tile_ids.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class VanishingTilesMapDefs:
|
||||||
|
points = {'spawn1': (0, 3, -5)}
|
||||||
|
boxes = {
|
||||||
|
'area_of_interest_bounds': (0, 4, -5, 0, 0, 0, 16, 8, 16),
|
||||||
|
'map_bounds': (0, 4, -5, 0, 0, 0, 30, 14, 30),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class VanishingTilesMap(bs.Map):
|
||||||
|
defs = VanishingTilesMapDefs()
|
||||||
|
name = 'Vanishing Tiles Arena'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_preview_texture_name(cls) -> str:
|
||||||
|
return 'powerupHealth'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def on_preload(cls) -> Any:
|
||||||
|
return {'bgtex': bs.gettexture('menuBG'), 'bgmesh': bs.getmesh('thePadBG')}
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.node = bs.newnode('terrain', attrs={
|
||||||
|
'mesh': self.preloaddata['bgmesh'], 'lighting': False,
|
||||||
|
'background': True, 'color_texture': self.preloaddata['bgtex'],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
bs._map.register_map(VanishingTilesMap)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ba_meta export babase.Plugin
|
||||||
|
class Main(babase.Plugin):
|
||||||
|
pass
|
||||||
1293
plugins/minigames/volley_punch.py
Normal file
1293
plugins/minigames/volley_punch.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1054,6 +1054,18 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"versions": {
|
"versions": {
|
||||||
|
"2.1.7": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "c0527f3",
|
||||||
|
"released_on": "05-04-2026",
|
||||||
|
"md5sum": "442889926c17a0ffc70b924a808d8132"
|
||||||
|
},
|
||||||
|
"2.1.6": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "3aa666f",
|
||||||
|
"released_on": "05-04-2026",
|
||||||
|
"md5sum": "d2b66f98b5a24aa14eb6b1ed9de2d5dc"
|
||||||
|
},
|
||||||
"2.1.5": {
|
"2.1.5": {
|
||||||
"api_version": 9,
|
"api_version": 9,
|
||||||
"commit_sha": "a36e3c2",
|
"commit_sha": "a36e3c2",
|
||||||
|
|
@ -2375,6 +2387,12 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"versions": {
|
"versions": {
|
||||||
|
"1.0.1": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "023b733",
|
||||||
|
"released_on": "01-05-2026",
|
||||||
|
"md5sum": "045eb589b2969398f7fcb171216303d1"
|
||||||
|
},
|
||||||
"1.0.0": {
|
"1.0.0": {
|
||||||
"api_version": 9,
|
"api_version": 9,
|
||||||
"commit_sha": "f38a7ac",
|
"commit_sha": "f38a7ac",
|
||||||
|
|
@ -2384,7 +2402,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"custom_hits": {
|
"custom_hits": {
|
||||||
"description": "when someone hited rainbow text whit effect will appear",
|
"description": "when someone hited text whit random effect will appear",
|
||||||
"external_url": "",
|
"external_url": "",
|
||||||
"authors": [
|
"authors": [
|
||||||
{
|
{
|
||||||
|
|
@ -2394,6 +2412,12 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"versions": {
|
"versions": {
|
||||||
|
"1.1.0": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "023b733",
|
||||||
|
"released_on": "01-05-2026",
|
||||||
|
"md5sum": "bc39a552ab7f40a71ce2813f7860a62f"
|
||||||
|
},
|
||||||
"1.0.1": {
|
"1.0.1": {
|
||||||
"api_version": 9,
|
"api_version": 9,
|
||||||
"commit_sha": "b48a4bd",
|
"commit_sha": "b48a4bd",
|
||||||
|
|
@ -2426,6 +2450,139 @@
|
||||||
"md5sum": "d8002dfc93be74d71f8e6e4aacfe6fd1"
|
"md5sum": "d8002dfc93be74d71f8e6e4aacfe6fd1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"better_camera_shake": {
|
||||||
|
"description": "This plugin makes the camera only shake when an explosion hits a player character, begone excessive camera shaking!",
|
||||||
|
"external_url": "",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "DinoWattz",
|
||||||
|
"email": "",
|
||||||
|
"discord": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versions": {
|
||||||
|
"1.0.0": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "e67d891",
|
||||||
|
"released_on": "23-02-2026",
|
||||||
|
"md5sum": "552b0ff0492bdab6cf69c1bc2d679e0a"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"chat_bubbles": {
|
||||||
|
"description": "Adds whatever the players say above their character, includes a few other features too!",
|
||||||
|
"external_url": "",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "DinoWattz",
|
||||||
|
"email": "",
|
||||||
|
"discord": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versions": {
|
||||||
|
"1.0.0": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "e67d891",
|
||||||
|
"released_on": "23-02-2026",
|
||||||
|
"md5sum": "307175b18b80671a7984862bcf09f1ce"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enhanced_effects": {
|
||||||
|
"description": "Explosions affect character colors, slow-mo tnt, fair colored shields, a trippy background screen and maybe more in the future!",
|
||||||
|
"external_url": "",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "DinoWattz",
|
||||||
|
"email": "",
|
||||||
|
"discord": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versions": {
|
||||||
|
"1.0.0": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "e67d891",
|
||||||
|
"released_on": "23-02-2026",
|
||||||
|
"md5sum": "63c665905dd4619d3f99ca8977f8fbf3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sleep_on_afk": {
|
||||||
|
"description": "Staying idle for 40 seconds will make your character fall asleep, they need rest too..",
|
||||||
|
"external_url": "",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "DinoWattz",
|
||||||
|
"email": "",
|
||||||
|
"discord": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versions": {
|
||||||
|
"1.0.0": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "e67d891",
|
||||||
|
"released_on": "23-02-2026",
|
||||||
|
"md5sum": "5de7a474c3c6b21e8ea1e1abff3c07a1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rank_system": {
|
||||||
|
"description": "ranks system for servers or for local players",
|
||||||
|
"external_url": "",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "ATD",
|
||||||
|
"email": "anasdhaoidi001@gmail.com",
|
||||||
|
"discord": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versions": {
|
||||||
|
"1.0.0": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "023b733",
|
||||||
|
"released_on": "01-05-2026",
|
||||||
|
"md5sum": "487c0520dc833f15942941e0dbc82f1e"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"glowing_profiles": {
|
||||||
|
"description": "This plugin gives your profile glowlight, just like on some servers, but only offline",
|
||||||
|
"external_url": "https://m.youtube.com/watch?v=Jb_dKz99rhY",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "andrejkuroglo8",
|
||||||
|
"email": "andrejkuroglo8@gmail.com",
|
||||||
|
"discord": "andrewku"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versions": {
|
||||||
|
"1.0.0": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "6a494a4",
|
||||||
|
"released_on": "10-05-2026",
|
||||||
|
"md5sum": "6346cd7146baf41eef8926dced23fdfe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fluffyplaylisteditor": {
|
||||||
|
"description": "A simple not-so advanced Playlist Editor",
|
||||||
|
"external_url": "https://discord.com/channels/1001896771347304639/1483463979450896445",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "FluffyPal",
|
||||||
|
"email": "",
|
||||||
|
"discord": "fluffypal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"versions": {
|
||||||
|
"1.1.0": {
|
||||||
|
"api_version": 9,
|
||||||
|
"commit_sha": "99cf837",
|
||||||
|
"released_on": "08-06-2026",
|
||||||
|
"md5sum": "3ead338c768fbdffe022aadcbdc8c1d8"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -19,41 +19,51 @@ https://bombsquad-community.web.app/mods
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from _thread import start_new_thread
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
import _babase
|
||||||
|
import bascenev1 as bs
|
||||||
|
import bauiv1 as bui
|
||||||
|
import babase
|
||||||
|
import time
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from bauiv1lib.confirm import ConfirmWindow
|
||||||
|
from bauiv1lib.colorpicker import ColorPickerExact
|
||||||
|
from typing import List, Sequence, Optional, Dict, Any, Union
|
||||||
|
import bauiv1lib.party as bascenev1lib_party
|
||||||
|
import ssl
|
||||||
|
import datetime
|
||||||
|
import base64
|
||||||
|
from babase._general import Call
|
||||||
|
from bauiv1lib.popup import PopupMenuWindow, PopupWindow
|
||||||
|
from bauiv1lib.account import viewer
|
||||||
|
import os
|
||||||
|
import urllib
|
||||||
|
import copy
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import codecs
|
||||||
|
import traceback
|
||||||
|
plugman = dict(
|
||||||
|
plugin_name="advanced_party_window",
|
||||||
|
description="Advanced your party window with lots of feature",
|
||||||
|
external_url="https://www.youtube.com/watch?v=QrES1jQGXF0",
|
||||||
|
authors=[
|
||||||
|
{"name": "Mr.Smoothy", "email": "", "discord": "mr.smoothy"},
|
||||||
|
],
|
||||||
|
version="2.1.7",
|
||||||
|
)
|
||||||
|
|
||||||
# added advanced ID revealer
|
# added advanced ID revealer
|
||||||
# live ping
|
# live ping
|
||||||
|
|
||||||
# Made by Mr.Smoothy - Plasma Boson
|
# Made by Mr.Smoothy - Plasma Boson
|
||||||
import traceback
|
|
||||||
import codecs
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import shutil
|
|
||||||
import copy
|
|
||||||
import urllib
|
|
||||||
import os
|
|
||||||
from bauiv1lib.account import viewer
|
|
||||||
from bauiv1lib.popup import PopupMenuWindow, PopupWindow
|
|
||||||
from babase._general import Call
|
|
||||||
import base64
|
|
||||||
import datetime
|
|
||||||
import ssl
|
|
||||||
import bauiv1lib.party as bascenev1lib_party
|
|
||||||
from typing import List, Sequence, Optional, Dict, Any, Union
|
|
||||||
from bauiv1lib.colorpicker import ColorPickerExact
|
|
||||||
from bauiv1lib.confirm import ConfirmWindow
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import math
|
|
||||||
import time
|
|
||||||
import babase
|
|
||||||
import bauiv1 as bui
|
|
||||||
import bascenev1 as bs
|
|
||||||
import _babase
|
|
||||||
from typing import TYPE_CHECKING, cast
|
|
||||||
import urllib.request
|
|
||||||
import urllib.parse
|
|
||||||
from _thread import start_new_thread
|
|
||||||
import threading
|
|
||||||
version_str = "7"
|
version_str = "7"
|
||||||
BCSSERVER = 'mods.ballistica.workers.dev'
|
BCSSERVER = 'mods.ballistica.workers.dev'
|
||||||
|
|
||||||
|
|
@ -461,6 +471,7 @@ class ModifiedPartyWindow(bascenev1lib_party.PartyWindow):
|
||||||
color=(0.55, 0.73, 0.25),
|
color=(0.55, 0.73, 0.25),
|
||||||
icon=bui.gettexture('menuButton'),
|
icon=bui.gettexture('menuButton'),
|
||||||
iconscale=1.2)
|
iconscale=1.2)
|
||||||
|
self._menu_popup: PopupMenuWindow | None = None
|
||||||
|
|
||||||
info = bs.get_connection_to_host_info_2()
|
info = bs.get_connection_to_host_info_2()
|
||||||
if info != None:
|
if info != None:
|
||||||
|
|
@ -864,7 +875,7 @@ class ModifiedPartyWindow(bascenev1lib_party.PartyWindow):
|
||||||
choices.append("hostInfo_Debug")
|
choices.append("hostInfo_Debug")
|
||||||
DisChoices.append(_getTransText("Debug_for_Host_Info", isBaLstr=True))
|
DisChoices.append(_getTransText("Debug_for_Host_Info", isBaLstr=True))
|
||||||
|
|
||||||
PopupMenuWindow(
|
self._menu_popup = PopupMenuWindow(
|
||||||
position=self._menu_button.get_screen_space_center(),
|
position=self._menu_button.get_screen_space_center(),
|
||||||
scale=_get_popup_window_scale(),
|
scale=_get_popup_window_scale(),
|
||||||
choices=choices,
|
choices=choices,
|
||||||
|
|
@ -891,12 +902,12 @@ class ModifiedPartyWindow(bascenev1lib_party.PartyWindow):
|
||||||
except:
|
except:
|
||||||
babase.print_exception()
|
babase.print_exception()
|
||||||
|
|
||||||
PopupMenuWindow(position=widget.get_screen_space_center(),
|
self._menu_popup = PopupMenuWindow(position=widget.get_screen_space_center(),
|
||||||
scale=_get_popup_window_scale(),
|
scale=_get_popup_window_scale(),
|
||||||
choices=choices,
|
choices=choices,
|
||||||
choices_display=choices_display,
|
choices_display=choices_display,
|
||||||
current_choice="@ this guy",
|
current_choice="@ this guy",
|
||||||
delegate=self)
|
delegate=self)
|
||||||
self._popup_party_member_client_id = client_id
|
self._popup_party_member_client_id = client_id
|
||||||
self._popup_party_member_is_host = is_host
|
self._popup_party_member_is_host = is_host
|
||||||
self._popup_type = "partyMemberPress"
|
self._popup_type = "partyMemberPress"
|
||||||
|
|
|
||||||
388
plugins/utilities/better_camera_shake.py
Normal file
388
plugins/utilities/better_camera_shake.py
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
# ba_meta require api 9
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import babase
|
||||||
|
import bascenev1 as bs
|
||||||
|
import bascenev1lib.actor.bomb as bomb
|
||||||
|
|
||||||
|
import random
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from bascenev1lib.gameutils import SharedObjects
|
||||||
|
from bascenev1lib.actor.bomb import BombFactory, ExplodeHitMessage
|
||||||
|
from bascenev1lib.actor.playerspaz import PlayerSpaz
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
plugman = dict(
|
||||||
|
plugin_name="better_camera_shake",
|
||||||
|
description="This plugin makes the camera only shake when an explosion hits a player character, begone excessive camera shaking!",
|
||||||
|
external_url="",
|
||||||
|
authors=[
|
||||||
|
{"name": "DinoWattz", "email": "", "discord": ""}
|
||||||
|
],
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Camera shake only on player impact
|
||||||
|
|
||||||
|
|
||||||
|
def new__init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
position: Sequence[float] = (0.0, 1.0, 0.0),
|
||||||
|
velocity: Sequence[float] = (0.0, 0.0, 0.0),
|
||||||
|
blast_radius: float = 2.0,
|
||||||
|
blast_type: str = 'normal',
|
||||||
|
source_player: bs.Player | None = None,
|
||||||
|
hit_type: str = 'explosion',
|
||||||
|
hit_subtype: str = 'normal',
|
||||||
|
):
|
||||||
|
"""Instantiate with given values."""
|
||||||
|
|
||||||
|
# bah; get off my lawn!
|
||||||
|
# pylint: disable=too-many-locals
|
||||||
|
# pylint: disable=too-many-statements
|
||||||
|
|
||||||
|
super(type(self), self).__init__()
|
||||||
|
|
||||||
|
shared = SharedObjects.get()
|
||||||
|
factory = BombFactory.get()
|
||||||
|
|
||||||
|
self.blast_type = blast_type
|
||||||
|
self._source_player = source_player
|
||||||
|
self.hit_type = hit_type
|
||||||
|
self.hit_subtype = hit_subtype
|
||||||
|
self.radius = blast_radius
|
||||||
|
|
||||||
|
# Set our position a bit lower so we throw more things upward.
|
||||||
|
rmats = (factory.blast_material, shared.attack_material)
|
||||||
|
self.node = bs.newnode(
|
||||||
|
'region',
|
||||||
|
delegate=self,
|
||||||
|
attrs={
|
||||||
|
'position': (position[0], position[1] - 0.1, position[2]),
|
||||||
|
'scale': (self.radius, self.radius, self.radius),
|
||||||
|
'type': 'sphere',
|
||||||
|
'materials': rmats,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
bs.timer(0.05, self.node.delete)
|
||||||
|
|
||||||
|
# Throw in an explosion and flash.
|
||||||
|
evel = (velocity[0], max(-1.0, velocity[1]), velocity[2])
|
||||||
|
explosion = bs.newnode(
|
||||||
|
'explosion',
|
||||||
|
attrs={
|
||||||
|
'position': position,
|
||||||
|
'velocity': evel,
|
||||||
|
'radius': self.radius,
|
||||||
|
'big': (self.blast_type == 'tnt'),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if self.blast_type == 'ice':
|
||||||
|
explosion.color = (0, 0.05, 0.4)
|
||||||
|
|
||||||
|
bs.timer(1.0, explosion.delete)
|
||||||
|
|
||||||
|
if self.blast_type != 'ice':
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=int(1.0 + random.random() * 4),
|
||||||
|
emit_type='tendrils',
|
||||||
|
tendril_type='thin_smoke',
|
||||||
|
)
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=int(4.0 + random.random() * 4),
|
||||||
|
emit_type='tendrils',
|
||||||
|
tendril_type='ice' if self.blast_type == 'ice' else 'smoke',
|
||||||
|
)
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
emit_type='distortion',
|
||||||
|
spread=1.0 if self.blast_type == 'tnt' else 2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# And emit some shrapnel.
|
||||||
|
if self.blast_type == 'ice':
|
||||||
|
|
||||||
|
def emit() -> None:
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=30,
|
||||||
|
spread=2.0,
|
||||||
|
scale=0.4,
|
||||||
|
chunk_type='ice',
|
||||||
|
emit_type='stickers',
|
||||||
|
)
|
||||||
|
|
||||||
|
# It looks better if we delay a bit.
|
||||||
|
bs.timer(0.05, emit)
|
||||||
|
|
||||||
|
elif self.blast_type == 'sticky':
|
||||||
|
|
||||||
|
def emit() -> None:
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=int(4.0 + random.random() * 8),
|
||||||
|
spread=0.7,
|
||||||
|
chunk_type='slime',
|
||||||
|
)
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=int(4.0 + random.random() * 8),
|
||||||
|
scale=0.5,
|
||||||
|
spread=0.7,
|
||||||
|
chunk_type='slime',
|
||||||
|
)
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=15,
|
||||||
|
scale=0.6,
|
||||||
|
chunk_type='slime',
|
||||||
|
emit_type='stickers',
|
||||||
|
)
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=20,
|
||||||
|
scale=0.7,
|
||||||
|
chunk_type='spark',
|
||||||
|
emit_type='stickers',
|
||||||
|
)
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=int(6.0 + random.random() * 12),
|
||||||
|
scale=0.8,
|
||||||
|
spread=1.5,
|
||||||
|
chunk_type='spark',
|
||||||
|
)
|
||||||
|
|
||||||
|
# It looks better if we delay a bit.
|
||||||
|
bs.timer(0.05, emit)
|
||||||
|
|
||||||
|
elif self.blast_type == 'impact':
|
||||||
|
|
||||||
|
def emit() -> None:
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=int(4.0 + random.random() * 8),
|
||||||
|
scale=0.8,
|
||||||
|
chunk_type='metal',
|
||||||
|
)
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=int(4.0 + random.random() * 8),
|
||||||
|
scale=0.4,
|
||||||
|
chunk_type='metal',
|
||||||
|
)
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=20,
|
||||||
|
scale=0.7,
|
||||||
|
chunk_type='spark',
|
||||||
|
emit_type='stickers',
|
||||||
|
)
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=int(8.0 + random.random() * 15),
|
||||||
|
scale=0.8,
|
||||||
|
spread=1.5,
|
||||||
|
chunk_type='spark',
|
||||||
|
)
|
||||||
|
|
||||||
|
# It looks better if we delay a bit.
|
||||||
|
bs.timer(0.05, emit)
|
||||||
|
|
||||||
|
else: # Regular or land mine bomb shrapnel.
|
||||||
|
|
||||||
|
def emit() -> None:
|
||||||
|
if self.blast_type != 'tnt':
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=int(4.0 + random.random() * 8),
|
||||||
|
chunk_type='rock',
|
||||||
|
)
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=int(4.0 + random.random() * 8),
|
||||||
|
scale=0.5,
|
||||||
|
chunk_type='rock',
|
||||||
|
)
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=30,
|
||||||
|
scale=1.0 if self.blast_type == 'tnt' else 0.7,
|
||||||
|
chunk_type='spark',
|
||||||
|
emit_type='stickers',
|
||||||
|
)
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=int(18.0 + random.random() * 20),
|
||||||
|
scale=1.0 if self.blast_type == 'tnt' else 0.8,
|
||||||
|
spread=1.5,
|
||||||
|
chunk_type='spark',
|
||||||
|
)
|
||||||
|
|
||||||
|
# TNT throws splintery chunks.
|
||||||
|
if self.blast_type == 'tnt':
|
||||||
|
|
||||||
|
def emit_splinters() -> None:
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=int(20.0 + random.random() * 25),
|
||||||
|
scale=0.8,
|
||||||
|
spread=1.0,
|
||||||
|
chunk_type='splinter',
|
||||||
|
)
|
||||||
|
|
||||||
|
bs.timer(0.01, emit_splinters)
|
||||||
|
|
||||||
|
# Every now and then do a sparky one.
|
||||||
|
if self.blast_type == 'tnt' or random.random() < 0.1:
|
||||||
|
|
||||||
|
def emit_extra_sparks() -> None:
|
||||||
|
bs.emitfx(
|
||||||
|
position=position,
|
||||||
|
velocity=velocity,
|
||||||
|
count=int(10.0 + random.random() * 20),
|
||||||
|
scale=0.8,
|
||||||
|
spread=1.5,
|
||||||
|
chunk_type='spark',
|
||||||
|
)
|
||||||
|
|
||||||
|
bs.timer(0.02, emit_extra_sparks)
|
||||||
|
|
||||||
|
# It looks better if we delay a bit.
|
||||||
|
bs.timer(0.05, emit)
|
||||||
|
|
||||||
|
lcolor = (0.6, 0.6, 1.0) if self.blast_type == 'ice' else (1, 0.3, 0.1)
|
||||||
|
light = bs.newnode(
|
||||||
|
'light',
|
||||||
|
attrs={
|
||||||
|
'position': position,
|
||||||
|
'volume_intensity_scale': 10.0,
|
||||||
|
'color': lcolor,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
scl = random.uniform(0.6, 0.9)
|
||||||
|
scorch_radius = light_radius = self.radius
|
||||||
|
if self.blast_type == 'tnt':
|
||||||
|
light_radius *= 1.4
|
||||||
|
scorch_radius *= 1.15
|
||||||
|
scl *= 3.0
|
||||||
|
|
||||||
|
iscale = 1.6
|
||||||
|
bs.animate(
|
||||||
|
light,
|
||||||
|
'intensity',
|
||||||
|
{
|
||||||
|
0: 2.0 * iscale,
|
||||||
|
scl * 0.02: 0.1 * iscale,
|
||||||
|
scl * 0.025: 0.2 * iscale,
|
||||||
|
scl * 0.05: 17.0 * iscale,
|
||||||
|
scl * 0.06: 5.0 * iscale,
|
||||||
|
scl * 0.08: 4.0 * iscale,
|
||||||
|
scl * 0.2: 0.6 * iscale,
|
||||||
|
scl * 2.0: 0.00 * iscale,
|
||||||
|
scl * 3.0: 0.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bs.animate(
|
||||||
|
light,
|
||||||
|
'radius',
|
||||||
|
{
|
||||||
|
0: light_radius * 0.2,
|
||||||
|
scl * 0.05: light_radius * 0.55,
|
||||||
|
scl * 0.1: light_radius * 0.3,
|
||||||
|
scl * 0.3: light_radius * 0.15,
|
||||||
|
scl * 1.0: light_radius * 0.05,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bs.timer(scl * 3.0, light.delete)
|
||||||
|
|
||||||
|
# Make a scorch that fades over time.
|
||||||
|
scorch = bs.newnode(
|
||||||
|
'scorch',
|
||||||
|
attrs={
|
||||||
|
'position': position,
|
||||||
|
'size': scorch_radius * 0.5,
|
||||||
|
'big': (self.blast_type == 'tnt'),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if self.blast_type == 'ice':
|
||||||
|
scorch.color = (1, 1, 1.5)
|
||||||
|
|
||||||
|
bs.animate(scorch, 'presence', {3.000: 1, 13.000: 0})
|
||||||
|
bs.timer(13.0, scorch.delete)
|
||||||
|
|
||||||
|
if self.blast_type == 'ice':
|
||||||
|
factory.hiss_sound.play(position=light.position)
|
||||||
|
|
||||||
|
lpos = light.position
|
||||||
|
factory.random_explode_sound().play(position=lpos)
|
||||||
|
factory.debris_fall_sound.play(position=lpos)
|
||||||
|
|
||||||
|
# ↓ We don't need any of this, we're fully trained professionals.
|
||||||
|
# bs.camerashake(intensity=5.0 if self.blast_type == 'tnt' else 1.0)
|
||||||
|
|
||||||
|
# TNT is more epic.
|
||||||
|
if self.blast_type == 'tnt':
|
||||||
|
factory.random_explode_sound().play(position=lpos)
|
||||||
|
|
||||||
|
def _extra_boom() -> None:
|
||||||
|
factory.random_explode_sound().play(position=lpos)
|
||||||
|
|
||||||
|
bs.timer(0.25, _extra_boom)
|
||||||
|
|
||||||
|
def _extra_debris_sound() -> None:
|
||||||
|
factory.debris_fall_sound.play(position=lpos)
|
||||||
|
factory.wood_debris_fall_sound.play(position=lpos)
|
||||||
|
|
||||||
|
bs.timer(0.4, _extra_debris_sound)
|
||||||
|
|
||||||
|
|
||||||
|
bomb.Blast.__init__ = new__init__
|
||||||
|
|
||||||
|
org_handlemessage = bomb.Blast.handlemessage
|
||||||
|
|
||||||
|
|
||||||
|
def new_handlemessage(self, msg: Any, *args, **kwargs) -> Any:
|
||||||
|
org_handlemessage(self, msg, *args, **kwargs)
|
||||||
|
|
||||||
|
if isinstance(msg, ExplodeHitMessage):
|
||||||
|
node = bs.getcollision().opposingnode
|
||||||
|
delegate = node.getdelegate(PlayerSpaz)
|
||||||
|
has_used_camerashake = getattr(self, 'has_used_camerashake', False)
|
||||||
|
if not has_used_camerashake and (delegate and not delegate.shield) and not node.invincible:
|
||||||
|
self.has_used_camerashake = True
|
||||||
|
bs.camerashake(intensity=5.0 if self.blast_type == 'tnt' else 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
bomb.Blast.handlemessage = new_handlemessage
|
||||||
|
|
||||||
|
# ba_meta export babase.Plugin
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin(babase.Plugin):
|
||||||
|
pass
|
||||||
599
plugins/utilities/chat_bubbles.py
Normal file
599
plugins/utilities/chat_bubbles.py
Normal file
|
|
@ -0,0 +1,599 @@
|
||||||
|
# ba_meta require api 9
|
||||||
|
# This plugin only works with BombSquad 1.7.49+
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, override
|
||||||
|
|
||||||
|
import babase
|
||||||
|
import bascenev1 as bs
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
plugman = dict(
|
||||||
|
plugin_name="chat_bubbles",
|
||||||
|
description="Adds whatever the players say above their character, includes a few other features too!",
|
||||||
|
external_url="",
|
||||||
|
authors=[
|
||||||
|
{"name": "DinoWattz", "email": "", "discord": ""}
|
||||||
|
],
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prints chat messages to the console as "[Character's Name] Player 1/Player 2: Hello World!"
|
||||||
|
PRINT_CHAT_MESSAGES = True
|
||||||
|
BUBBLE_DURATION: float = 4.7 # 4.7 matches the default onscreen message duration
|
||||||
|
SPEAK_VOLUME = 1.5
|
||||||
|
SHOUT_VOLUME = 3.0
|
||||||
|
|
||||||
|
CELEBRATE_MESSAGE = [(bs.CelebrateMessage, {"duration": 1.0})]
|
||||||
|
|
||||||
|
# Easter egg triggers, sounds, and messages flag
|
||||||
|
# If adding new ones, make sure to test them in-game
|
||||||
|
EASTER_EGGS = [
|
||||||
|
{
|
||||||
|
"triggers": {
|
||||||
|
"hi", "hello", "hey", "hiya", "yo", "sup", "heya", "howdy",
|
||||||
|
"greetings", "salutations", "ahoy", "ahoyhoy", "bonjour",
|
||||||
|
"hola", "ciao", "ola", "oi", "eae", "salve",
|
||||||
|
"bye", "goodbye", "see ya", "see you", "cya",
|
||||||
|
"adios", "chau", "chao", "tchau",
|
||||||
|
"adeus", "ate mais", "falou", "flw"
|
||||||
|
},
|
||||||
|
"node_messages": [("celebrate_r", 1000)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"aaa"},
|
||||||
|
"sound": "spazFall01",
|
||||||
|
"messages": CELEBRATE_MESSAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"aaaa"},
|
||||||
|
"sound": "zoeFall01",
|
||||||
|
"messages": CELEBRATE_MESSAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"aaaaa"},
|
||||||
|
"sound": "kronkFall",
|
||||||
|
"messages": CELEBRATE_MESSAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"potato"},
|
||||||
|
"sound": "kronk2",
|
||||||
|
"messages": CELEBRATE_MESSAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"dau", "tau", "d'oh", "doh"},
|
||||||
|
"sound": "kronk3",
|
||||||
|
"messages": CELEBRATE_MESSAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"drau", "trau", "d'roh", "droh"},
|
||||||
|
"sound": "bunny2",
|
||||||
|
"messages": CELEBRATE_MESSAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"hahaha", "hahahaha",
|
||||||
|
"hahahah", "hahahahah", "lol",
|
||||||
|
"nahahah", "kakaka", "kakakaka", "kkk", "kkkk",
|
||||||
|
"jajaja", "jajajaja", "jajajajaa"},
|
||||||
|
"sound": "mel05",
|
||||||
|
"messages": CELEBRATE_MESSAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"ha", "hah", "ja", "ka"},
|
||||||
|
"sound": "mel06",
|
||||||
|
"messages": CELEBRATE_MESSAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"merry christmas", "merry krismas", "merry xmas", "feliz natal", "feliz navidad"},
|
||||||
|
"sound": "santa02",
|
||||||
|
"messages": CELEBRATE_MESSAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"hohoho", "ho ho ho", "ho-ho-ho"},
|
||||||
|
"sound": "santa05",
|
||||||
|
"messages": CELEBRATE_MESSAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"gg", "good game", "que pro", "q pro"},
|
||||||
|
"sound": "achievement",
|
||||||
|
"messages": CELEBRATE_MESSAGE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"boo", "bad game", "que noob", "q noob"},
|
||||||
|
"sound": "boo",
|
||||||
|
"messages": CELEBRATE_MESSAGE,
|
||||||
|
},
|
||||||
|
# ↓ Node messages are like this ↓
|
||||||
|
{
|
||||||
|
"triggers": {"0", "zero"},
|
||||||
|
"sound": "boxingBell",
|
||||||
|
"node_messages": [("celebrate_l", 1000)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"1", "one"},
|
||||||
|
"sound": "announceOne",
|
||||||
|
"node_messages": [("celebrate_r", 1000)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"2", "two"},
|
||||||
|
"sound": "announceTwo",
|
||||||
|
"node_messages": [("celebrate_r", 1000)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"3", "three"},
|
||||||
|
"sound": "announceThree",
|
||||||
|
"node_messages": [("celebrate_r", 1000)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"4", "four"},
|
||||||
|
"sound": "announceFour",
|
||||||
|
"node_messages": [("celebrate_r", 1000)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"5", "five"},
|
||||||
|
"sound": "announceFive",
|
||||||
|
"node_messages": [("celebrate_r", 1000)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"6", "six"},
|
||||||
|
"sound": "announceSix",
|
||||||
|
"node_messages": [("celebrate_r", 1000)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"7", "seven"},
|
||||||
|
"sound": "announceSeven",
|
||||||
|
"node_messages": [("celebrate_r", 1000)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"8", "eight"},
|
||||||
|
"sound": "announceEight",
|
||||||
|
"node_messages": [("celebrate_r", 1000)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"9", "nine"},
|
||||||
|
"sound": "announceNine",
|
||||||
|
"node_messages": [("celebrate_r", 1000)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"10", "ten"},
|
||||||
|
"sound": "announceTen",
|
||||||
|
"node_messages": [("celebrate_r", 1000)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"xd"},
|
||||||
|
"sound": "santaDeath",
|
||||||
|
"node_messages": [("knockout", 100)],
|
||||||
|
},
|
||||||
|
# ↓ Easter eggs without actor/node messages ↓
|
||||||
|
{
|
||||||
|
"triggers": {"huh", "huhh", "huhhh", "huhhhh", "humph", "hmph", "hum", "humm"},
|
||||||
|
"sound": "agent1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"hoh", "hohh", "hohhh", "hohhhh", "ohh"},
|
||||||
|
"sound": "agent3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"eff"},
|
||||||
|
"sound": "zoeEff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"ow", "oww", "oow"},
|
||||||
|
"sound": "zoeOw",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"pipipi", "bibibi"},
|
||||||
|
"sound": "mel07",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"oh yeah", "oh yea", "oohh yeah", "oohh yea"},
|
||||||
|
"sound": "yeah",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"woo"},
|
||||||
|
"sound": "woo2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"wooo"},
|
||||||
|
"sound": "woo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"woo yeah", "woo yea"},
|
||||||
|
"sound": "woo3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"ooh"},
|
||||||
|
"sound": "ooh",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"wow"},
|
||||||
|
"sound": "wow",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"gasp"},
|
||||||
|
"sound": "gasp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"ahh", "aah"},
|
||||||
|
"sound": "aww",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"triggers": {"nice"},
|
||||||
|
"sound": "nice",
|
||||||
|
},
|
||||||
|
# ↑ Default easter egg list ends here ↑
|
||||||
|
]
|
||||||
|
|
||||||
|
# Chat bubble functionality
|
||||||
|
|
||||||
|
|
||||||
|
def create_bubble(player: bs.SessionPlayer, text: str, name: str):
|
||||||
|
msg = normalize_message(text)
|
||||||
|
is_caps = len(text) > 1 and text.isupper()
|
||||||
|
handled = False
|
||||||
|
bubble_created = False
|
||||||
|
|
||||||
|
activityplayer: bs.Player | None = getattr(player, 'activityplayer', None)
|
||||||
|
|
||||||
|
def _fallback_logic():
|
||||||
|
nonlocal handled, activityplayer
|
||||||
|
color = (1, 1, 0.4)
|
||||||
|
image_icon = {'texture': bs.gettexture("cuteSpaz"), 'tint_texture': bs.gettexture(
|
||||||
|
"cuteSpaz"), 'tint_color': (1.0, 1.0, 1.0), 'tint2_color': (1.0, 1.0, 1.0)}
|
||||||
|
|
||||||
|
if activityplayer:
|
||||||
|
normal_highlight = bs.normalized_color(
|
||||||
|
bs.safecolor(player.highlight, target_intensity=1.15))
|
||||||
|
saturated_highlight = bs.normalized_color(
|
||||||
|
bs.safecolor(player.highlight, target_intensity=0.75))
|
||||||
|
color = saturated_highlight if is_caps else normal_highlight
|
||||||
|
image_icon = activityplayer.get_icon()
|
||||||
|
|
||||||
|
bs.broadcastmessage(f"{name}: {text}",
|
||||||
|
color=color,
|
||||||
|
top=True,
|
||||||
|
image=image_icon)
|
||||||
|
|
||||||
|
for egg in EASTER_EGGS:
|
||||||
|
if msg in egg["triggers"]:
|
||||||
|
if egg.get("sound"):
|
||||||
|
sound = bs.getsound(egg["sound"])
|
||||||
|
sound.play(SHOUT_VOLUME if is_caps else SPEAK_VOLUME)
|
||||||
|
handled = True
|
||||||
|
break
|
||||||
|
if not handled and activityplayer:
|
||||||
|
assert bs.app.classic is not None
|
||||||
|
handled = True
|
||||||
|
character = activityplayer.character
|
||||||
|
char = bs.app.classic.spaz_appearances[character]
|
||||||
|
if is_caps:
|
||||||
|
attack_sounds = [bs.getsound(s) for s in char.attack_sounds]
|
||||||
|
if attack_sounds and isinstance(attack_sounds, (list, tuple)) and len(attack_sounds) > 0:
|
||||||
|
sound = random.choice(attack_sounds)
|
||||||
|
sound.play(SHOUT_VOLUME)
|
||||||
|
else:
|
||||||
|
pickup_sounds = [bs.getsound(s) for s in char.pickup_sounds]
|
||||||
|
if pickup_sounds and isinstance(pickup_sounds, (list, tuple)) and len(pickup_sounds) > 0:
|
||||||
|
sound = random.choice(pickup_sounds)
|
||||||
|
sound.play(SPEAK_VOLUME)
|
||||||
|
|
||||||
|
if activityplayer is None:
|
||||||
|
# No valid activityplayer: play sound if possible (lobby/non-in-game)
|
||||||
|
_fallback_logic()
|
||||||
|
return
|
||||||
|
|
||||||
|
bubbles = activityplayer.customdata.setdefault('chat_bubbles', [])
|
||||||
|
actor_list = []
|
||||||
|
|
||||||
|
# Support for both actor and ghost_actor (from the Ghost Players plugin)
|
||||||
|
if getattr(activityplayer, 'actor', None):
|
||||||
|
actor_list.append(activityplayer.actor)
|
||||||
|
if hasattr(activityplayer, 'customdata') and activityplayer.customdata.get('ghost_actor'):
|
||||||
|
actor_list.append(activityplayer.customdata['ghost_actor'])
|
||||||
|
|
||||||
|
if actor_list:
|
||||||
|
for actor in actor_list:
|
||||||
|
if actor and actor.is_alive() and actor.node:
|
||||||
|
assert actor is not None
|
||||||
|
char_node = actor.node
|
||||||
|
activity = actor.getactivity()
|
||||||
|
if activity is None or getattr(activity, 'expired', False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
with activity.context:
|
||||||
|
# Calculate scale based on text length
|
||||||
|
base_scale = 0.015
|
||||||
|
min_scale = 0.009
|
||||||
|
scale = max(base_scale - 0.0003 * max(len(text) - 20, 0), min_scale)
|
||||||
|
y_offset = 1.2 + 0.25 * len(bubbles)
|
||||||
|
|
||||||
|
mnode = bs.newnode('math', owner=char_node, attrs={
|
||||||
|
'input1': (0, y_offset, 0),
|
||||||
|
'operation': 'add'
|
||||||
|
})
|
||||||
|
char_node.connectattr('torso_position', mnode, 'input2')
|
||||||
|
|
||||||
|
normal_highlight = bs.normalized_color(
|
||||||
|
bs.safecolor(player.highlight, target_intensity=1.15))
|
||||||
|
saturated_highlight = bs.normalized_color(
|
||||||
|
bs.safecolor(player.highlight, target_intensity=0.75))
|
||||||
|
bubble_color = saturated_highlight if is_caps else normal_highlight
|
||||||
|
|
||||||
|
textnode = bs.newnode('text',
|
||||||
|
owner=char_node,
|
||||||
|
attrs={
|
||||||
|
'text': text,
|
||||||
|
'color': bubble_color,
|
||||||
|
'shadow': 0.5,
|
||||||
|
'flatness': 0.5,
|
||||||
|
'scale': scale,
|
||||||
|
'h_align': 'center',
|
||||||
|
'v_align': 'bottom',
|
||||||
|
'in_world': True,
|
||||||
|
'opacity': 1.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
mnode.connectattr('output', textnode, 'position')
|
||||||
|
|
||||||
|
focus = Plugin.Focus(owner=textnode).autoretain()
|
||||||
|
textnode.connectattr('position', focus.node, 'position')
|
||||||
|
|
||||||
|
# Adjust bubble duration for slow-motion activities
|
||||||
|
bubble_duration = BUBBLE_DURATION
|
||||||
|
if hasattr(activity, 'slow_motion') and activity.slow_motion:
|
||||||
|
bubble_duration = max(0.5, bubble_duration / 3)
|
||||||
|
|
||||||
|
# Play sound for all triggers (easter eggs and normal)
|
||||||
|
def _play_chat_sound(actor, sound, volume):
|
||||||
|
if actor.is_alive():
|
||||||
|
actor._safe_play_sound(sound, volume)
|
||||||
|
else:
|
||||||
|
sound.play(volume)
|
||||||
|
|
||||||
|
# Easter egg sound/message
|
||||||
|
for egg in EASTER_EGGS:
|
||||||
|
if msg in egg['triggers']:
|
||||||
|
# Sound selection logic
|
||||||
|
sound = None
|
||||||
|
if egg.get('sound'):
|
||||||
|
sound = bs.getsound(egg['sound'])
|
||||||
|
else:
|
||||||
|
# Default to character sounds if no specific sound is set
|
||||||
|
if is_caps:
|
||||||
|
attack_sounds = getattr(char_node, 'attack_sounds', None)
|
||||||
|
if attack_sounds and isinstance(attack_sounds, (list, tuple)) and len(attack_sounds) > 0:
|
||||||
|
sound = random.choice(attack_sounds)
|
||||||
|
else:
|
||||||
|
pickup_sounds = getattr(char_node, 'pickup_sounds', None)
|
||||||
|
if pickup_sounds and isinstance(pickup_sounds, (list, tuple)) and len(pickup_sounds) > 0:
|
||||||
|
sound = random.choice(pickup_sounds)
|
||||||
|
|
||||||
|
if sound:
|
||||||
|
_play_chat_sound(
|
||||||
|
actor, sound, SHOUT_VOLUME if is_caps else SPEAK_VOLUME)
|
||||||
|
|
||||||
|
# Animation logic
|
||||||
|
if egg.get('messages') and actor.is_alive() and hasattr(actor, 'handlemessage'):
|
||||||
|
for msg_class, msg_kwargs in egg.get('messages', []):
|
||||||
|
msg_kwargs = msg_kwargs or {}
|
||||||
|
actor.handlemessage(msg_class(**msg_kwargs))
|
||||||
|
|
||||||
|
if egg.get('node_messages') and actor.is_alive() and actor.node.exists():
|
||||||
|
for node_args in egg.get('node_messages', []):
|
||||||
|
actor.node.handlemessage(*node_args)
|
||||||
|
|
||||||
|
handled = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not handled:
|
||||||
|
if is_caps:
|
||||||
|
attack_sounds = getattr(char_node, 'attack_sounds', None)
|
||||||
|
if attack_sounds and isinstance(attack_sounds, (list, tuple)) and len(attack_sounds) > 0:
|
||||||
|
sound = random.choice(attack_sounds)
|
||||||
|
_play_chat_sound(actor, sound, SHOUT_VOLUME)
|
||||||
|
if actor.is_alive() and hasattr(actor, 'handlemessage'):
|
||||||
|
actor.handlemessage(bs.CelebrateMessage(duration=1.0))
|
||||||
|
else:
|
||||||
|
pickup_sounds = getattr(char_node, 'pickup_sounds', None)
|
||||||
|
if pickup_sounds and isinstance(pickup_sounds, (list, tuple)) and len(pickup_sounds) > 0:
|
||||||
|
sound = random.choice(pickup_sounds)
|
||||||
|
_play_chat_sound(actor, sound, SPEAK_VOLUME)
|
||||||
|
|
||||||
|
bubbles.append((textnode, mnode))
|
||||||
|
bubble_created = True
|
||||||
|
while len(bubbles) > 3:
|
||||||
|
old_textnode, old_mnode = bubbles.pop(0)
|
||||||
|
if old_textnode is not None and hasattr(old_textnode, 'exists') and old_textnode.exists():
|
||||||
|
old_textnode.delete()
|
||||||
|
if old_mnode is not None and hasattr(old_mnode, 'exists') and old_mnode.exists():
|
||||||
|
old_mnode.delete()
|
||||||
|
update_bubble_offsets(bubbles)
|
||||||
|
|
||||||
|
fade_time = max(0.1, bubble_duration - 0.5)
|
||||||
|
bs.animate(textnode, 'opacity', {fade_time: 1.0, bubble_duration: 0.0})
|
||||||
|
bs.timer(bubble_duration, textnode.delete)
|
||||||
|
bs.timer(bubble_duration, mnode.delete)
|
||||||
|
|
||||||
|
def cleanup():
|
||||||
|
if hasattr(activityplayer, 'customdata'):
|
||||||
|
if 'chat_bubbles' in activityplayer.customdata:
|
||||||
|
activityplayer.customdata['chat_bubbles'] = [
|
||||||
|
b for b in activityplayer.customdata['chat_bubbles'] if b[0] != textnode
|
||||||
|
]
|
||||||
|
bs.timer(bubble_duration, cleanup)
|
||||||
|
|
||||||
|
if not bubble_created:
|
||||||
|
# No valid actor found: play sound if possible
|
||||||
|
_fallback_logic()
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def update_bubble_offsets(bubbles):
|
||||||
|
for i, (tnode, mnode) in enumerate(reversed(bubbles)):
|
||||||
|
y_offset = 1.2 + 0.25 * i
|
||||||
|
if mnode is not None and hasattr(mnode, 'exists') and mnode.exists():
|
||||||
|
mnode.input1 = (0, y_offset, 0)
|
||||||
|
|
||||||
|
# Tools
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_message(text):
|
||||||
|
msg = text.strip().lower()
|
||||||
|
msg = msg.translate(str.maketrans('', '', string.punctuation))
|
||||||
|
msg = ''.join(
|
||||||
|
c for c in unicodedata.normalize('NFD', msg)
|
||||||
|
if unicodedata.category(c) != 'Mn'
|
||||||
|
)
|
||||||
|
|
||||||
|
return msg
|
||||||
|
|
||||||
|
# ba_meta export babase.Plugin
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin(babase.Plugin):
|
||||||
|
def on_app_running(self) -> None:
|
||||||
|
# Hook into chat message filtering
|
||||||
|
# We're delaying until the app runs and using a timer so it's more likely to work with other chat plugins (please don't make a chat plugin/filter that is delayed like this).
|
||||||
|
bs.apptimer(0.001, bs.WeakCallStrict(self.apply_patch))
|
||||||
|
|
||||||
|
def apply_patch(self):
|
||||||
|
import bascenev1._hooks as _hooks
|
||||||
|
|
||||||
|
_org_filter_chat_message = _hooks.filter_chat_message
|
||||||
|
|
||||||
|
def _chat_bubbles_hook(msg: str, client_id: int, *args, **kwargs) -> str | None:
|
||||||
|
org_msg = _org_filter_chat_message(msg, client_id, *args, **kwargs)
|
||||||
|
|
||||||
|
if org_msg is None:
|
||||||
|
# The original message has been ignored, so we should ignore it as well
|
||||||
|
return org_msg
|
||||||
|
|
||||||
|
# Chat bubble sorcery
|
||||||
|
try:
|
||||||
|
roster = bs.get_game_roster()
|
||||||
|
session = bs.get_foreground_host_session()
|
||||||
|
if session is None:
|
||||||
|
# Probably couldn't find a session because we are a client
|
||||||
|
return org_msg
|
||||||
|
sessionplayers = session.sessionplayers
|
||||||
|
|
||||||
|
player_list = []
|
||||||
|
matched = False
|
||||||
|
dead_bubble = False
|
||||||
|
character: str | None = None
|
||||||
|
|
||||||
|
with session.context:
|
||||||
|
for player in sessionplayers:
|
||||||
|
if player.inputdevice.client_id == client_id and player.in_game:
|
||||||
|
player_list.append(player)
|
||||||
|
continue
|
||||||
|
if player_list:
|
||||||
|
# Get combined name for multiple players on same client
|
||||||
|
name = ''
|
||||||
|
created_bubble = False
|
||||||
|
|
||||||
|
for player in player_list:
|
||||||
|
if name == '':
|
||||||
|
name = player.getname()
|
||||||
|
else:
|
||||||
|
name = name + '/' + player.getname()
|
||||||
|
|
||||||
|
# Create chat bubble
|
||||||
|
for player in player_list:
|
||||||
|
activityplayer: bs.Player = player.activityplayer
|
||||||
|
# We have a session player but not an activity player
|
||||||
|
if not activityplayer:
|
||||||
|
if not dead_bubble:
|
||||||
|
create_bubble(player, org_msg, name)
|
||||||
|
created_bubble = dead_bubble = True
|
||||||
|
continue
|
||||||
|
# We have a dead activity player
|
||||||
|
if not activityplayer.is_alive() and not dead_bubble:
|
||||||
|
create_bubble(player, org_msg, name)
|
||||||
|
created_bubble = dead_bubble = True
|
||||||
|
character = activityplayer.character if character is None else character
|
||||||
|
# elif we have an alive activity player
|
||||||
|
elif activityplayer.is_alive():
|
||||||
|
create_bubble(player, org_msg, name)
|
||||||
|
created_bubble = True
|
||||||
|
character = activityplayer.character if character is None else character
|
||||||
|
|
||||||
|
if created_bubble:
|
||||||
|
matched = True
|
||||||
|
if PRINT_CHAT_MESSAGES:
|
||||||
|
print(f"[{character}] {name}: {org_msg}")
|
||||||
|
else:
|
||||||
|
bs.logging.warning(
|
||||||
|
"Failed to create a chat bubble, using non-session player method now.")
|
||||||
|
# Non-session players (in lobby or just spectating, this only works for servers)
|
||||||
|
if not matched and not dead_bubble:
|
||||||
|
for client in roster:
|
||||||
|
if client['client_id'] == client_id:
|
||||||
|
name = client.get('display_string')
|
||||||
|
create_bubble(None, org_msg, name)
|
||||||
|
|
||||||
|
matched = True
|
||||||
|
if PRINT_CHAT_MESSAGES:
|
||||||
|
print(f"[{character}] {name}: {org_msg}")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
bs.logging.exception(f"Error in chat bubble hook: {e}")
|
||||||
|
|
||||||
|
return org_msg
|
||||||
|
|
||||||
|
_hooks.filter_chat_message = _chat_bubbles_hook
|
||||||
|
bs.reload_hooks()
|
||||||
|
|
||||||
|
print("[ChatBubbles] Plugin initialized.")
|
||||||
|
|
||||||
|
class Focus(bs.Actor):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
owner: bs.Node | None = None,
|
||||||
|
):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self._focusnode_material = bs.Material()
|
||||||
|
self._focusnode_material.add_actions(
|
||||||
|
actions=('modify_node_collision', 'collide', False),
|
||||||
|
)
|
||||||
|
|
||||||
|
if getattr(owner, 'owner', None):
|
||||||
|
assert owner is not None
|
||||||
|
area_of_interest = False if getattr(
|
||||||
|
owner.owner, 'is_area_of_interest', False) else True
|
||||||
|
else:
|
||||||
|
area_of_interest = False if getattr(owner, 'is_area_of_interest', False) else True
|
||||||
|
|
||||||
|
# I'm tired of this area_of_interest nonsense, it never works right
|
||||||
|
# area_of_interest = False
|
||||||
|
# actually it might work now that we don't spawn bubbles for dead players.
|
||||||
|
|
||||||
|
self.node = bs.newnode(
|
||||||
|
'prop',
|
||||||
|
delegate=self,
|
||||||
|
owner=owner,
|
||||||
|
attrs={
|
||||||
|
'body': 'sphere',
|
||||||
|
'body_scale': 0.0,
|
||||||
|
'shadow_size': 0.0,
|
||||||
|
'gravity_scale': 0.0,
|
||||||
|
'is_area_of_interest': area_of_interest,
|
||||||
|
'materials': (self._focusnode_material,),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def handlemessage(self, msg: Any) -> Any:
|
||||||
|
assert not self.expired
|
||||||
|
if isinstance(msg, bs.DieMessage):
|
||||||
|
if self.node:
|
||||||
|
self.node.delete()
|
||||||
|
else:
|
||||||
|
super().handlemessage(msg)
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
# ba_meta require api 9
|
# ba_meta require api 9
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import babase
|
import babase
|
||||||
|
|
@ -14,136 +13,81 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
plugman = dict(
|
plugman = dict(
|
||||||
plugin_name="custom_hits",
|
plugin_name="custom_hits",
|
||||||
description="when someone hited rainbow text whit effect will appear",
|
description="when someone hited text whit random effect will appear",
|
||||||
external_url="",
|
external_url="",
|
||||||
authors=[
|
authors=[
|
||||||
{"name": "ATD", "email": "anasdhaoidi001@gmail.com", "discord": ""},
|
{"name": "ATD", "email": "anasdhaoidi001@gmail.com", "discord": ""},
|
||||||
],
|
],
|
||||||
version="1.0.1",
|
version="1.1.0",
|
||||||
)
|
)
|
||||||
|
# ___________________________________________________
|
||||||
|
|
||||||
####################################
|
first_damage = (u'NAH NOOB?', (1, 1, 1), '6')
|
||||||
|
second_damage = (u'GOOD!', (0, 1, 0), '2')
|
||||||
first_damage = (u'\ue042TRY AGAIN?\ue042', (0, 1, 0), '6')
|
third_damage = (u'DAMN!', (0, 0, 0), '3')
|
||||||
second_damage = (u'\ue048GOOD!\ue048', (0, 1, 1), '2')
|
fourth_damage = (u'SRY NOOB', (1, 1, 0), '4')
|
||||||
third_damage = (u'\ue041NICE!\ue041', (0.8, 0.4, 1), '3')
|
five_damage = (u'FAH!!!', (1, 0, 0), '5')
|
||||||
fourth_damage = (u'\ue049UPPS\ue049', (1, 1, 0), '4')
|
|
||||||
five_damage = (u'\ue043DEATH!\ue043', (1, 0, 0), '5')
|
|
||||||
|
|
||||||
|
|
||||||
def custom_effects(pos: float, effect: str = None) -> None:
|
def custom_effects(pos: float, effect: str = None) -> None:
|
||||||
if effect == '1':
|
if effect == '1':
|
||||||
bs.emitfx(
|
bs.emitfx(position=pos, count=3, scale=0.1, spread=0.1, chunk_type='rock')
|
||||||
position=pos,
|
|
||||||
count=3,
|
|
||||||
scale=0.1,
|
|
||||||
spread=0.1,
|
|
||||||
chunk_type='rock')
|
|
||||||
elif effect == '2':
|
elif effect == '2':
|
||||||
bs.emitfx(
|
bs.emitfx(position=pos, count=70, scale=2, spread=0.8, chunk_type='spark')
|
||||||
position=pos,
|
|
||||||
count=70,
|
|
||||||
scale=2,
|
|
||||||
spread=0.8,
|
|
||||||
chunk_type='spark')
|
|
||||||
elif effect == '3':
|
elif effect == '3':
|
||||||
bs.emitfx(
|
bs.emitfx(position=pos, count=6, scale=0.3, spread=0.4, chunk_type='splinter')
|
||||||
position=pos,
|
|
||||||
count=6,
|
|
||||||
scale=0.3,
|
|
||||||
spread=0.4,
|
|
||||||
chunk_type='splinter')
|
|
||||||
elif effect == '4':
|
elif effect == '4':
|
||||||
bs.emitfx(
|
bs.emitfx(position=pos, count=50, scale=3.0, spread=0.9, chunk_type='ice')
|
||||||
position=pos,
|
|
||||||
count=50,
|
|
||||||
scale=3.0,
|
|
||||||
spread=0.9,
|
|
||||||
chunk_type='ice')
|
|
||||||
elif effect == '5':
|
elif effect == '5':
|
||||||
bs.emitfx(
|
bs.emitfx(position=pos, count=80, scale=3.0, spread=1.5, chunk_type='metal')
|
||||||
position=pos,
|
|
||||||
count=80,
|
|
||||||
scale=3.0,
|
|
||||||
spread=1.5,
|
|
||||||
chunk_type='metal')
|
|
||||||
elif effect == '6':
|
elif effect == '6':
|
||||||
bs.emitfx(
|
bs.emitfx(position=pos, count=30, scale=1.2, spread=0.8, chunk_type='slime')
|
||||||
position=pos,
|
|
||||||
count=30,
|
|
||||||
scale=1.2,
|
def on_punched(self, damage: int) -> None:
|
||||||
spread=0.8,
|
pos = self.node.position
|
||||||
chunk_type='slime')
|
|
||||||
|
def custom_text(msg: str, color: float) -> None:
|
||||||
|
text = bs.newnode('text', attrs={
|
||||||
|
'text': msg,
|
||||||
|
'color': color,
|
||||||
|
'in_world': True,
|
||||||
|
'h_align': 'center',
|
||||||
|
'shadow': 0.5,
|
||||||
|
'flatness': 1.0,
|
||||||
|
})
|
||||||
|
bs.animate_array(text, 'position', 3, {
|
||||||
|
0.0: (pos[0], pos[1] + 1.2, pos[2]),
|
||||||
|
2.0: (pos[0], pos[1] + 1.7, pos[2]),
|
||||||
|
})
|
||||||
|
bs.animate(text, 'opacity', {0.8: 1.0, 2.0: 0.0})
|
||||||
|
bs.animate(text, 'scale', {0: 0, 0.1: 0.017, 0.15: 0.014, 2.0: 0.016})
|
||||||
|
bs.timer(2.0, text.delete)
|
||||||
|
|
||||||
|
if damage < 200:
|
||||||
|
custom_text(first_damage[0], first_damage[1])
|
||||||
|
custom_effects(pos, first_damage[2])
|
||||||
|
elif damage < 500:
|
||||||
|
custom_text(second_damage[0], second_damage[1])
|
||||||
|
custom_effects(pos, second_damage[2])
|
||||||
|
elif damage < 800:
|
||||||
|
custom_text(third_damage[0], third_damage[1])
|
||||||
|
custom_effects(pos, third_damage[2])
|
||||||
|
elif damage < 1000:
|
||||||
|
custom_text(fourth_damage[0], fourth_damage[1])
|
||||||
|
custom_effects(pos, fourth_damage[2])
|
||||||
|
else:
|
||||||
|
custom_text(five_damage[0], five_damage[1])
|
||||||
|
custom_effects(pos, five_damage[2])
|
||||||
|
|
||||||
####################################
|
|
||||||
|
|
||||||
# ba_meta export babase.Plugin
|
# ba_meta export babase.Plugin
|
||||||
|
class byATD(babase.Plugin):
|
||||||
|
"""تفعيل ATD Hits — يُستدعى من bootstraping()."""
|
||||||
class CustomHitsPlugin(babase.Plugin):
|
|
||||||
|
|
||||||
def on_punched(self, damage: int) -> None:
|
|
||||||
pos = self.node.position
|
|
||||||
|
|
||||||
def custom_text(msg: str, color: float) -> None:
|
|
||||||
text = bs.newnode(
|
|
||||||
'text',
|
|
||||||
attrs={
|
|
||||||
'text': msg,
|
|
||||||
'color': color,
|
|
||||||
'in_world': True,
|
|
||||||
'h_align': 'center',
|
|
||||||
'shadow': 0.5,
|
|
||||||
'flatness': 1.0})
|
|
||||||
bs.animate_array(text, 'position', 3, {
|
|
||||||
0.0: (pos[0], pos[1] + 1.2, pos[2]),
|
|
||||||
2.0: (pos[0], pos[1] + 1.7, pos[2])
|
|
||||||
})
|
|
||||||
bs.animate(text, 'opacity', {
|
|
||||||
0.8: 1.0,
|
|
||||||
2.0: 0.0
|
|
||||||
})
|
|
||||||
bs.animate(text, 'scale', {
|
|
||||||
0: 0,
|
|
||||||
0.1: 0.017,
|
|
||||||
0.15: 0.014,
|
|
||||||
2.0: 0.016
|
|
||||||
})
|
|
||||||
bs.animate_array(text, 'color', 3, {
|
|
||||||
0.0: (0, 0, 0), # Black
|
|
||||||
0.2: (2.55, 2.55, 2.55), # White
|
|
||||||
0.4: (2, 0, 0), # Red
|
|
||||||
0.6: (0, 2.55, 0), # Lime
|
|
||||||
0.8: (0, 0, 2.55), # Blue
|
|
||||||
1.0: (2.55, 2.55, 0), # Yellow
|
|
||||||
1.2: (0, 2.55, 2.55), # Cyan / Aqua
|
|
||||||
1.4: (2.55, 0, 2.55), # Magenta / Fuchsia
|
|
||||||
1.6: (1.92, 1.92, 1.92), # Silver
|
|
||||||
1.8: (1.28, 1.28, 1.28), # Gray
|
|
||||||
2.0: (1.28, 0, 0), # Maroon
|
|
||||||
2.2: (1.28, 1.28, 0), # Olive
|
|
||||||
2.4: (0, 1.28, 0), # Green
|
|
||||||
2.6: (1.28, 0, 1.28), # Purple
|
|
||||||
2.8: (0, 1.28, 1.28), # Teal
|
|
||||||
3.0: (0, 0, 1.28), # Navy
|
|
||||||
3.2: (1.5, 0.5, 0), # Orange
|
|
||||||
3.4: (1.8, 0.5, 1.6), # Pink
|
|
||||||
3.6: (0.5, 0.5, 0.5), # Gray
|
|
||||||
}, loop=True)
|
|
||||||
bs.timer(2.0, text.delete)
|
|
||||||
if damage < 200:
|
|
||||||
custom_text(first_damage[0], first_damage[1])
|
|
||||||
custom_effects(pos, first_damage[2])
|
|
||||||
elif damage < 500:
|
|
||||||
custom_text(second_damage[0], second_damage[1])
|
|
||||||
custom_effects(pos, second_damage[2])
|
|
||||||
elif damage < 800:
|
|
||||||
custom_text(third_damage[0], third_damage[1])
|
|
||||||
custom_effects(pos, third_damage[2])
|
|
||||||
elif damage < 1000:
|
|
||||||
custom_text(fourth_damage[0], fourth_damage[1])
|
|
||||||
custom_effects(pos, fourth_damage[2])
|
|
||||||
else:
|
|
||||||
custom_text(five_damage[0], five_damage[1])
|
|
||||||
custom_effects(pos, five_damage[2])
|
|
||||||
Spaz.on_punched = on_punched
|
Spaz.on_punched = on_punched
|
||||||
|
try:
|
||||||
|
from bascenev1lib.actor.spazbot import SpazBot
|
||||||
|
SpazBot.on_punched = on_punched
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
bs.broadcastmessage(u' || WELCOME || ', color=(0, 1, 0))
|
||||||
|
|
|
||||||
305
plugins/utilities/enhanced_effects.py
Normal file
305
plugins/utilities/enhanced_effects.py
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
# ba_meta require api 9
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import random
|
||||||
|
import weakref
|
||||||
|
import babase
|
||||||
|
import bascenev1 as bs
|
||||||
|
|
||||||
|
from bascenev1lib.actor.background import Background
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
plugman = dict(
|
||||||
|
plugin_name="enhanced_effects",
|
||||||
|
description="Explosions affect character colors, slow-mo tnt, fair colored shields, a trippy background screen and maybe more in the future!",
|
||||||
|
external_url="",
|
||||||
|
authors=[
|
||||||
|
{"name": "DinoWattz", "email": "", "discord": ""}
|
||||||
|
],
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Transparent Background (kinda hacky, works best on pc with high/higher quality 'visuals' setting)
|
||||||
|
BACKGROUND_OPACITY = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def __modified_background_init(
|
||||||
|
self,
|
||||||
|
fade_time: float = 0.5,
|
||||||
|
start_faded: bool = False,
|
||||||
|
show_logo: bool = False,
|
||||||
|
):
|
||||||
|
super(type(self), self).__init__()
|
||||||
|
self._dying = False
|
||||||
|
self.fade_time = fade_time
|
||||||
|
# We're special in that we create our node in the session
|
||||||
|
# scene instead of the activity scene.
|
||||||
|
# This way we can overlap multiple activities for fades
|
||||||
|
# and whatnot.
|
||||||
|
session = bs.getsession()
|
||||||
|
self._session = weakref.ref(session)
|
||||||
|
with session.context:
|
||||||
|
self.node = bs.newnode(
|
||||||
|
'image',
|
||||||
|
delegate=self,
|
||||||
|
attrs={
|
||||||
|
'fill_screen': True,
|
||||||
|
'texture': bs.gettexture('bg'),
|
||||||
|
'tilt_translate': -0.3,
|
||||||
|
'has_alpha_channel': False,
|
||||||
|
'color': (1, 1, 1),
|
||||||
|
'opacity': BACKGROUND_OPACITY,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not start_faded:
|
||||||
|
bs.animate(
|
||||||
|
self.node,
|
||||||
|
'opacity',
|
||||||
|
{0.0: 0.0, self.fade_time: BACKGROUND_OPACITY},
|
||||||
|
loop=False,
|
||||||
|
)
|
||||||
|
if show_logo:
|
||||||
|
logo_texture = bs.gettexture('logo')
|
||||||
|
logo_mesh = bs.getmesh('logo')
|
||||||
|
logo_mesh_transparent = bs.getmesh('logoTransparent')
|
||||||
|
self.logo = bs.newnode(
|
||||||
|
'image',
|
||||||
|
owner=self.node,
|
||||||
|
attrs={
|
||||||
|
'texture': logo_texture,
|
||||||
|
'mesh_opaque': logo_mesh,
|
||||||
|
'mesh_transparent': logo_mesh_transparent,
|
||||||
|
'scale': (0.7, 0.7),
|
||||||
|
'vr_depth': -250,
|
||||||
|
'color': (0.15, 0.15, 0.15),
|
||||||
|
'position': (0, 0),
|
||||||
|
'tilt_translate': -0.05,
|
||||||
|
'absolute_scale': False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.node.connectattr('opacity', self.logo, 'opacity')
|
||||||
|
# add jitter/pulse for a stop-motion-y look unless we're in VR
|
||||||
|
# in which case stillness is better
|
||||||
|
if not bs.app.env.vr:
|
||||||
|
self.cmb = bs.newnode(
|
||||||
|
'combine', owner=self.node, attrs={'size': 2}
|
||||||
|
)
|
||||||
|
for attr in ['input0', 'input1']:
|
||||||
|
bs.animate(
|
||||||
|
self.cmb,
|
||||||
|
attr,
|
||||||
|
{0.0: 0.693, 0.05: 0.7, 0.5: 0.693},
|
||||||
|
loop=True,
|
||||||
|
)
|
||||||
|
self.cmb.connectattr('output', self.logo, 'scale')
|
||||||
|
cmb = bs.newnode(
|
||||||
|
'combine', owner=self.node, attrs={'size': 2}
|
||||||
|
)
|
||||||
|
cmb.connectattr('output', self.logo, 'position')
|
||||||
|
# Gen some random keys for that stop-motion-y look.
|
||||||
|
keys = {}
|
||||||
|
timeval = 0.0
|
||||||
|
for _i in range(10):
|
||||||
|
keys[timeval] = (random.random() - 0.5) * 0.0015
|
||||||
|
timeval += random.random() * 0.1
|
||||||
|
bs.animate(cmb, 'input0', keys, loop=True)
|
||||||
|
keys = {}
|
||||||
|
timeval = 0.0
|
||||||
|
for _i in range(10):
|
||||||
|
keys[timeval] = (random.random() - 0.5) * 0.0015 + 0.05
|
||||||
|
timeval += random.random() * 0.1
|
||||||
|
bs.animate(cmb, 'input1', keys, loop=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _modified_background_die(self, immediate: bool = False) -> None:
|
||||||
|
session = self._session()
|
||||||
|
if session is None and self.node:
|
||||||
|
# If session is gone, our node should be too,
|
||||||
|
# since it was part of the session's scene.
|
||||||
|
# Let's make sure that's the case.
|
||||||
|
# (since otherwise we have no way to kill it)
|
||||||
|
bs.logging.exception(
|
||||||
|
'got None session on Background _die'
|
||||||
|
' (and node still exists!)'
|
||||||
|
)
|
||||||
|
elif session is not None:
|
||||||
|
with session.context:
|
||||||
|
if not self._dying and self.node:
|
||||||
|
self._dying = True
|
||||||
|
if immediate:
|
||||||
|
self.node.delete()
|
||||||
|
else:
|
||||||
|
bs.animate(
|
||||||
|
self.node,
|
||||||
|
'opacity',
|
||||||
|
{0.0: self.node.opacity, self.fade_time: 0.0},
|
||||||
|
loop=False,
|
||||||
|
)
|
||||||
|
bs.timer(self.fade_time + 0.1, self.node.delete)
|
||||||
|
|
||||||
|
|
||||||
|
Background.__init__ = __modified_background_init
|
||||||
|
Background._die = _modified_background_die
|
||||||
|
|
||||||
|
# Tools
|
||||||
|
|
||||||
|
|
||||||
|
def blend_toward(
|
||||||
|
rgb: tuple[float, float, float],
|
||||||
|
target: tuple[float, float, float],
|
||||||
|
amount: float = 1.0,
|
||||||
|
perceptual: bool = False
|
||||||
|
) -> tuple[float, ...]:
|
||||||
|
"""
|
||||||
|
Blend 'rgb' toward 'target' by 'amount' (0..1).
|
||||||
|
|
||||||
|
If perceptual=True, blend in linear-light space for smoother, more
|
||||||
|
natural-looking fades.
|
||||||
|
"""
|
||||||
|
def clamp01(x: float) -> float:
|
||||||
|
return max(0.0, min(1.0, x))
|
||||||
|
|
||||||
|
def srgb_to_linear(c: float) -> float:
|
||||||
|
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||||
|
|
||||||
|
def linear_to_srgb(c: float) -> float:
|
||||||
|
return 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
|
||||||
|
|
||||||
|
a = clamp01(amount)
|
||||||
|
|
||||||
|
if not perceptual:
|
||||||
|
return tuple(clamp01(c + (t - c) * a) for c, t in zip(rgb, target))
|
||||||
|
|
||||||
|
# Gamma-aware blend
|
||||||
|
rgb_lin = [srgb_to_linear(clamp01(c)) for c in rgb]
|
||||||
|
target_lin = [srgb_to_linear(clamp01(c)) for c in target]
|
||||||
|
blended_lin = [
|
||||||
|
c + (t - c) * a for c, t in zip(rgb_lin, target_lin)
|
||||||
|
]
|
||||||
|
return tuple(clamp01(linear_to_srgb(c)) for c in blended_lin)
|
||||||
|
|
||||||
|
# ba_meta export babase.Plugin
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin(babase.Plugin):
|
||||||
|
def on_app_running(self) -> None:
|
||||||
|
from bascenev1lib.actor.bomb import Blast, ExplodeHitMessage
|
||||||
|
from bascenev1lib.actor.spaz import Spaz
|
||||||
|
from bascenev1lib.actor.spazbot import SpazBot
|
||||||
|
from bascenev1lib.actor.playerspaz import PlayerSpaz
|
||||||
|
|
||||||
|
# Colored Shields
|
||||||
|
org_equip_shields = Spaz.equip_shields
|
||||||
|
|
||||||
|
def equip_shields(self, decay: bool = False) -> None:
|
||||||
|
org_equip_shields(self, decay)
|
||||||
|
if self.shield is not None:
|
||||||
|
safe_highlight = bs.safecolor(
|
||||||
|
getattr(self, '_original_highlight', self.node.highlight), target_intensity=0.75)
|
||||||
|
self.shield.color = bs.normalized_color(safe_highlight)
|
||||||
|
|
||||||
|
Spaz.equip_shields = equip_shields
|
||||||
|
|
||||||
|
# Slow Motion TNT Explosion
|
||||||
|
org_blast_init = Blast.__init__
|
||||||
|
|
||||||
|
def new_init(self: Blast, blast_type='normal', hit_type='explosion', *args, **kwargs) -> None:
|
||||||
|
org_blast_init(self, blast_type=blast_type, hit_type=hit_type, *args, **kwargs)
|
||||||
|
|
||||||
|
if blast_type == 'tnt' and hit_type == 'explosion':
|
||||||
|
bs.camerashake(3.0)
|
||||||
|
activity = bs.getactivity()
|
||||||
|
gnode = activity.globalsnode
|
||||||
|
|
||||||
|
# Pause
|
||||||
|
gnode.paused = True
|
||||||
|
self._pause_timer = bs.DisplayTimer(
|
||||||
|
0.01, bs.CallPartial(setattr, gnode, 'paused', True), True)
|
||||||
|
|
||||||
|
delay = 0.06
|
||||||
|
|
||||||
|
bs.displaytimer(delay, bs.CallPartial(setattr, self, '_pause_timer', None))
|
||||||
|
bs.displaytimer(delay, bs.CallPartial(setattr, gnode, 'paused', False))
|
||||||
|
|
||||||
|
# Slow Motion
|
||||||
|
gnode.slow_motion = True
|
||||||
|
self._slow_motion_timer = bs.DisplayTimer(
|
||||||
|
0.01, bs.CallPartial(setattr, gnode, 'slow_motion', True), True)
|
||||||
|
|
||||||
|
delay += 0.14
|
||||||
|
|
||||||
|
bs.displaytimer(delay, bs.CallPartial(setattr, self, '_slow_motion_timer', None))
|
||||||
|
bs.displaytimer(delay, bs.CallPartial(
|
||||||
|
setattr, gnode, 'slow_motion', activity.slow_motion))
|
||||||
|
|
||||||
|
Blast.__init__ = new_init
|
||||||
|
|
||||||
|
# Explosion Spaz Coloring
|
||||||
|
org_handlemessage = Blast.handlemessage
|
||||||
|
|
||||||
|
def new_handlemessage(self: Blast, msg: Any, *args, **kwargs) -> Any:
|
||||||
|
org_handlemessage(self, msg, *args, **kwargs)
|
||||||
|
|
||||||
|
if isinstance(msg, ExplodeHitMessage):
|
||||||
|
node = bs.getcollision().opposingnode
|
||||||
|
delegate = node.getdelegate(PlayerSpaz) or node.getdelegate(SpazBot)
|
||||||
|
if (delegate and not delegate.shield) and not node.invincible:
|
||||||
|
if not hasattr(delegate, '_original_color'):
|
||||||
|
delegate._original_color = node.color
|
||||||
|
delegate._original_highlight = node.highlight
|
||||||
|
|
||||||
|
original_color = delegate._original_color
|
||||||
|
original_highlight = delegate._original_highlight
|
||||||
|
|
||||||
|
blend_time = 1.0
|
||||||
|
start_time = 6.0
|
||||||
|
end_time = 7.0
|
||||||
|
|
||||||
|
bs.emitfx(
|
||||||
|
position=node.position,
|
||||||
|
velocity=node.velocity,
|
||||||
|
count=int(4.0 + random.random() * 4),
|
||||||
|
emit_type='tendrils',
|
||||||
|
tendril_type='ice' if self.blast_type == 'ice' else 'smoke',
|
||||||
|
)
|
||||||
|
|
||||||
|
explosion_intensity = 0.70 if not self.blast_type == 'tnt' else 0.96
|
||||||
|
|
||||||
|
if self.blast_type == 'ice':
|
||||||
|
explosion_intensity = 0.91
|
||||||
|
explosion_color = (0.07, 0.6, 1.5)
|
||||||
|
elif self.blast_type == 'sticky':
|
||||||
|
explosion_intensity = 0.91
|
||||||
|
explosion_color = (0.07, 0.9, 0.07)
|
||||||
|
blend_time = 1.0
|
||||||
|
start_time = 1.2
|
||||||
|
end_time = 3.0
|
||||||
|
else:
|
||||||
|
explosion_color = (0.07, 0.03, 0.0)
|
||||||
|
|
||||||
|
blend_color = blend_toward(node.color, explosion_color,
|
||||||
|
explosion_intensity, True)
|
||||||
|
blend_highlight = blend_toward(
|
||||||
|
node.highlight, explosion_color, explosion_intensity-0.03, True)
|
||||||
|
|
||||||
|
bs.animate_array(node, 'color', 3, {
|
||||||
|
blend_time: blend_color,
|
||||||
|
start_time: blend_color,
|
||||||
|
end_time: original_color
|
||||||
|
})
|
||||||
|
bs.animate_array(node, 'highlight', 3, {
|
||||||
|
blend_time: blend_highlight,
|
||||||
|
start_time: blend_highlight,
|
||||||
|
end_time: original_highlight
|
||||||
|
})
|
||||||
|
|
||||||
|
delegate._color_timer = bs.Timer(
|
||||||
|
end_time, bs.CallPartial(delattr, delegate, '_original_color'))
|
||||||
|
delegate._highlight_timer = bs.Timer(
|
||||||
|
end_time, bs.CallPartial(delattr, delegate, '_original_highlight'))
|
||||||
|
|
||||||
|
Blast.handlemessage = new_handlemessage
|
||||||
2122
plugins/utilities/fluffyplaylisteditor.py
Normal file
2122
plugins/utilities/fluffyplaylisteditor.py
Normal file
File diff suppressed because it is too large
Load diff
222
plugins/utilities/glowing_profiles.py
Normal file
222
plugins/utilities/glowing_profiles.py
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
# ba_meta require api 9
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
import babase
|
||||||
|
import _babase
|
||||||
|
import bascenev1 as bs
|
||||||
|
from types import FunctionType
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from typing import Type, Callable, Any, Tuple
|
||||||
|
|
||||||
|
plugman = dict(
|
||||||
|
plugin_name="glowing_profiles",
|
||||||
|
description="This plugin gives your profile glowlight, just like on some servers, but only offline",
|
||||||
|
external_url="https://m.youtube.com/watch?v=Jb_dKz99rhY",
|
||||||
|
authors=[
|
||||||
|
{"name": "andrejkuroglo8", "email": "andrejkuroglo8@gmail.com", "discord": "andrewku"},
|
||||||
|
],
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def redefine_method(dst: Tuple[Any, str], src: Tuple[Any, str]) -> None:
|
||||||
|
if hasattr(getattr(*src), '__redefine_type') and getattr(*src).__redefine_type in (
|
||||||
|
RedefineFlag.DECORATE_PRE, RedefineFlag.DECORATE_AFTER, RedefineFlag.DECORATE_ADVANCED):
|
||||||
|
new = getattr(*src)
|
||||||
|
old = getattr(*dst)
|
||||||
|
func: Callable
|
||||||
|
if getattr(*src).__redefine_type == RedefineFlag.DECORATE_AFTER:
|
||||||
|
def func(*args, **kwargs):
|
||||||
|
returned = old(*args, **kwargs)
|
||||||
|
return new(*args, **kwargs, returned=returned)
|
||||||
|
elif getattr(*src).__redefine_type == RedefineFlag.DECORATE_PRE:
|
||||||
|
def func(*args, **kwargs):
|
||||||
|
new(*args, **kwargs)
|
||||||
|
return old(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
def func(*args, **kwargs):
|
||||||
|
return new(*args, **kwargs, old_function=old)
|
||||||
|
|
||||||
|
setattr(*dst, func)
|
||||||
|
else:
|
||||||
|
setattr(*dst, getattr(*src))
|
||||||
|
|
||||||
|
# Fucking super()!
|
||||||
|
# dst.__code__ = CodeType(
|
||||||
|
# src.__code__.co_argcount,
|
||||||
|
# src.__code__.co_posonlyargcount,
|
||||||
|
# src.__code__.co_kwonlyargcount,
|
||||||
|
# src.__code__.co_nlocals,
|
||||||
|
# src.__code__.co_stacksize,
|
||||||
|
# src.__code__.co_flags,
|
||||||
|
# src.__code__.co_code,
|
||||||
|
# src.__code__.co_consts,
|
||||||
|
# src.__code__.co_names,
|
||||||
|
# src.__code__.co_varnames,
|
||||||
|
# src.__code__.co_filename,
|
||||||
|
# dst.__code__.co_name,
|
||||||
|
# src.__code__.co_firstlineno,
|
||||||
|
# src.__code__.co_lnotab,
|
||||||
|
# dst.__code__.co_freevars,
|
||||||
|
# dst.__code__.co_cellvars)
|
||||||
|
|
||||||
|
|
||||||
|
def redefine_class_methods(orig_cls: Type[object]) -> Callable[[Any], None]:
|
||||||
|
"""Returns decorator that redefines all class methods
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
orig_cls (Type[object]): class that will redefined"""
|
||||||
|
|
||||||
|
def decorator(cls) -> None:
|
||||||
|
# for method in filter(lambda x: isinstance(getattr(cls, x), FunctionType), dir(cls)):
|
||||||
|
for method in cls._redefine_methods:
|
||||||
|
if hasattr(orig_cls, method):
|
||||||
|
redefine_method((orig_cls, method), (cls, method))
|
||||||
|
else:
|
||||||
|
setattr(orig_cls, method, getattr(cls, method))
|
||||||
|
# setattr(getattr(orig_cls, 'self'), '__class__', getattr(cls, method)) # Fucking super()!!!!
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
class RedefineFlag(enum.Enum):
|
||||||
|
REDEFINE = 0
|
||||||
|
DECORATE_AFTER = 1
|
||||||
|
DECORATE_PRE = 2
|
||||||
|
DECORATE_ADVANCED = 3
|
||||||
|
DECORATE = DECORATE_AFTER
|
||||||
|
|
||||||
|
|
||||||
|
def redefine_flag(*flags: RedefineFlag) -> Callable[[Callable], Callable]:
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
for flag in flags:
|
||||||
|
if flag in (RedefineFlag.DECORATE_AFTER, RedefineFlag.REDEFINE, RedefineFlag.DECORATE_PRE,
|
||||||
|
RedefineFlag.DECORATE_ADVANCED):
|
||||||
|
func.__redefine_type = flag
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def get_locale(*args):
|
||||||
|
return "Error"
|
||||||
|
|
||||||
|
|
||||||
|
@redefine_class_methods(bs.Chooser)
|
||||||
|
class Chooser:
|
||||||
|
_redefine_methods = ('_gcinit', '_get_glowing_colors', 'update_from_profile',
|
||||||
|
'_getname')
|
||||||
|
|
||||||
|
def _gcinit(self):
|
||||||
|
if hasattr(self, '_gcinit_done'):
|
||||||
|
return
|
||||||
|
self.glow_dict = {}
|
||||||
|
self._markers = ('"', "'", '^', '%', ';', '`')
|
||||||
|
self._get_glowing_colors()
|
||||||
|
self._gcinit_done = True
|
||||||
|
|
||||||
|
@redefine_flag(RedefineFlag.REDEFINE)
|
||||||
|
def _get_glowing_colors(self):
|
||||||
|
"""Search glowing code among profiles."""
|
||||||
|
try:
|
||||||
|
should_del = []
|
||||||
|
for i in self._profilenames:
|
||||||
|
for m in self._markers:
|
||||||
|
if i.startswith(m + ','):
|
||||||
|
code = i.split(',')
|
||||||
|
self.glow_dict[code[0]] = (
|
||||||
|
float(code[1]),
|
||||||
|
float(code[2]),
|
||||||
|
int(code[3]),
|
||||||
|
int(code[4]))
|
||||||
|
# should_del.append(i)
|
||||||
|
for i in should_del:
|
||||||
|
self._profilenames.remove(i)
|
||||||
|
except Exception as err:
|
||||||
|
print(err)
|
||||||
|
ba.screenmessage(
|
||||||
|
get_locale('init_glowing_code_error'),
|
||||||
|
color=(1, 0, 0),
|
||||||
|
clients=[self._player.get_input_device().client_id],
|
||||||
|
transient=True)
|
||||||
|
|
||||||
|
@redefine_flag(RedefineFlag.DECORATE_ADVANCED)
|
||||||
|
def _getname(self, full=True, old_function=None):
|
||||||
|
name = old_function(self, full)
|
||||||
|
for m in self._markers:
|
||||||
|
name = name.replace(m, '')
|
||||||
|
return name
|
||||||
|
|
||||||
|
@redefine_flag(RedefineFlag.DECORATE_ADVANCED)
|
||||||
|
def update_from_profile(self, old_function):
|
||||||
|
self._gcinit()
|
||||||
|
from bascenev1 import _profile
|
||||||
|
try:
|
||||||
|
self._profilename = self._profilenames[self._profileindex]
|
||||||
|
character = self._profiles[self._profilename]['character']
|
||||||
|
|
||||||
|
if self._profilename[0] in self.glow_dict:
|
||||||
|
if (character not in self._character_names
|
||||||
|
and character in _ba.app.spaz_appearances):
|
||||||
|
self._character_names.append(character)
|
||||||
|
self._character_index = self._character_names.index(character)
|
||||||
|
|
||||||
|
player_glowing_dict = self.glow_dict[self._profilename[0]]
|
||||||
|
color_marker = player_glowing_dict[0]
|
||||||
|
color_marker = max(-999.0, min(color_marker, 50.0))
|
||||||
|
|
||||||
|
highlight_marker = float(player_glowing_dict[1])
|
||||||
|
highlight_marker = max(-999.0, min(highlight_marker, 50.0))
|
||||||
|
|
||||||
|
stabilize_color = int(player_glowing_dict[2]) > 0
|
||||||
|
stabilize_highlight = int(player_glowing_dict[3]) > 0
|
||||||
|
self._color, self._highlight = \
|
||||||
|
_profile.get_player_profile_colors(
|
||||||
|
self._profilename,
|
||||||
|
profiles=self._profiles)
|
||||||
|
|
||||||
|
if stabilize_color:
|
||||||
|
m = max(self._color)
|
||||||
|
self._color = list(self._color)
|
||||||
|
for i in (0, 1, 2):
|
||||||
|
if self._color[i] == m:
|
||||||
|
self._color[i] = self._color[i] * color_marker
|
||||||
|
self._color = tuple(self._color)
|
||||||
|
else:
|
||||||
|
self._color = (
|
||||||
|
self._color[0] * color_marker,
|
||||||
|
self._color[1] * color_marker,
|
||||||
|
self._color[2] * color_marker)
|
||||||
|
|
||||||
|
if not stabilize_highlight:
|
||||||
|
self._highlight = (
|
||||||
|
self._highlight[0] * highlight_marker,
|
||||||
|
self._highlight[1] * highlight_marker,
|
||||||
|
self._highlight[2] * highlight_marker)
|
||||||
|
else:
|
||||||
|
m = max(self._highlight)
|
||||||
|
self._highlight = list(self._highlight)
|
||||||
|
for i in (0, 1, 2):
|
||||||
|
if self._highlight[i] == m:
|
||||||
|
self._highlight[i] = \
|
||||||
|
self._highlight[i] * highlight_marker
|
||||||
|
self._highlight = tuple(self._highlight)
|
||||||
|
else:
|
||||||
|
old_function(self)
|
||||||
|
except KeyError:
|
||||||
|
self.character_index = self._random_character_index
|
||||||
|
self._color = self._random_color
|
||||||
|
self._highlight = self._random_highlight
|
||||||
|
|
||||||
|
self._update_icon()
|
||||||
|
self._update_text()
|
||||||
|
|
||||||
|
# ba_meta export babase.Plugin
|
||||||
|
|
||||||
|
|
||||||
|
class Glowing(babase.Plugin):
|
||||||
|
pass
|
||||||
|
|
@ -34,7 +34,7 @@ plugman = dict(
|
||||||
authors=[
|
authors=[
|
||||||
{"name": "ATD", "email": "anasdhaoidi001@gmail.com", "discord": ""},
|
{"name": "ATD", "email": "anasdhaoidi001@gmail.com", "discord": ""},
|
||||||
],
|
],
|
||||||
version="1.0.0",
|
version="1.0.1",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -593,10 +593,10 @@ class NewProfileBrowserWindow:
|
||||||
position=(position[0] + 25, position[1] + 10),
|
position=(position[0] + 25, position[1] + 10),
|
||||||
size=(0, 0),
|
size=(0, 0),
|
||||||
scale=0.45,
|
scale=0.45,
|
||||||
color=(0.7, 0.9, 0.7, 1.0),
|
color=(0.5, 0.5, 0.5, 0.5),
|
||||||
draw_controller=self.button,
|
draw_controller=self.button,
|
||||||
maxwidth=60,
|
maxwidth=60,
|
||||||
text=(f"Ultimate Powerup {_sp_}Manager"),
|
text=(f"powerups manager 1.1.1"),
|
||||||
h_align='center',
|
h_align='center',
|
||||||
v_align='center',
|
v_align='center',
|
||||||
)
|
)
|
||||||
|
|
@ -708,7 +708,7 @@ def _bomb_init(
|
||||||
'shield', owner=self.node, attrs={'color': (6.5, 6.5, 2.0), 'radius': 0.6}
|
'shield', owner=self.node, attrs={'color': (6.5, 6.5, 2.0), 'radius': 0.6}
|
||||||
)
|
)
|
||||||
self.node.connectattr('position', self.shield_fire, 'position')
|
self.node.connectattr('position', self.shield_fire, 'position')
|
||||||
self.fire_effect_time = bs.Timer(0.1, bs.WeakCallPartial(fire_effect, self), repeat=True)
|
self.fire_effect_time = bs.Timer(0.1, bs.CallPartial(fire_effect, self), repeat=True)
|
||||||
elif self.bm_type == 'impairment':
|
elif self.bm_type == 'impairment':
|
||||||
self.bomb_type = self.bm_type
|
self.bomb_type = self.bm_type
|
||||||
tex = bs.gettexture('eggTex3')
|
tex = bs.gettexture('eggTex3')
|
||||||
|
|
@ -1410,7 +1410,7 @@ def new_handlemessage(self, msg: Any) -> Any:
|
||||||
damage = 103
|
damage = 103
|
||||||
if not self.shield:
|
if not self.shield:
|
||||||
for firex in range(duration):
|
for firex in range(duration):
|
||||||
bs.timer(index, bs.WeakCallPartial(fire, index, damage))
|
bs.timer(index, bs.CallPartial(fire, index, damage))
|
||||||
self._fire_time = bs.Timer(0.1, babase.CallPartial(fire_effect), repeat=True)
|
self._fire_time = bs.Timer(0.1, babase.CallPartial(fire_effect), repeat=True)
|
||||||
index += 1
|
index += 1
|
||||||
else:
|
else:
|
||||||
|
|
@ -1836,20 +1836,20 @@ class PowerupManagerWindow(PopupWindow):
|
||||||
]
|
]
|
||||||
|
|
||||||
self.tabdefs = {
|
self.tabdefs = {
|
||||||
"Action 1": ['powerupIceBombs', (1, 1, 1)],
|
"Action 1": ['levelIcon', (1, 1, 1)],
|
||||||
"Action 2": ['settingsIcon', (0, 1, 0)],
|
"Action 2": ['settingsIcon', (1, 1, 1)],
|
||||||
"Action 3": ['inventoryIcon', (1, 1, 1)],
|
"Action 3": ['inventoryIcon', (1, 1, 1)],
|
||||||
"Action 4": ['storeIcon', (1, 1, 1)],
|
"Action 4": ['storeIcon', (1, 1, 1)],
|
||||||
"Action 5": ['advancedIcon', (1, 1, 1)],
|
"Action 5": ['advancedIcon', (1, 1, 1)],
|
||||||
"About": ['heart', (1.5, 0.3, 0.3)],
|
"About": ['achievementEmpty', (1, 1, 1)],
|
||||||
}
|
}
|
||||||
|
|
||||||
if STORE['Buy Firebombs'] and STORE['Buy Option'] and STORE['Buy Percentage']:
|
if STORE['Buy Firebombs'] and STORE['Buy Option'] and STORE['Buy Percentage']:
|
||||||
self.tabdefs = {
|
self.tabdefs = {
|
||||||
"Action 1": ['powerupIceBombs', (1, 1, 1)],
|
"Action 1": ['levelIcon', (1, 1, 1)],
|
||||||
"Action 2": ['settingsIcon', (0, 1, 0)],
|
"Action 2": ['settingsIcon', (1, 1, 1)],
|
||||||
"Action 3": ['inventoryIcon', (1, 1, 1)],
|
"Action 3": ['inventoryIcon', (1, 1, 1)],
|
||||||
"About": ['heart', (1.5, 0.3, 0.3)],
|
"About": ['achievementEmpty', (1, 1, 1)],
|
||||||
}
|
}
|
||||||
|
|
||||||
self.listdef = list(self.tabdefs)
|
self.listdef = list(self.tabdefs)
|
||||||
|
|
@ -2748,10 +2748,11 @@ class PowerupManagerWindow(PopupWindow):
|
||||||
position=(110, v - 20),
|
position=(110, v - 20),
|
||||||
size=(width, 50),
|
size=(width, 50),
|
||||||
scale=1.4,
|
scale=1.4,
|
||||||
color=(0.2, 1.2, 0.2),
|
big=True,
|
||||||
|
color=(0.5, 0.5, 0.5),
|
||||||
h_align="center",
|
h_align="center",
|
||||||
v_align="center",
|
v_align="center",
|
||||||
text=("Ultimate Powerup Manager v2.5"),
|
text=("powerup manager 1.1.1"),
|
||||||
maxwidth=width * 30,
|
maxwidth=width * 30,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -2942,25 +2943,11 @@ class PowerupManagerWindow(PopupWindow):
|
||||||
babase.app.classic.profile_browser_window()
|
babase.app.classic.profile_browser_window()
|
||||||
|
|
||||||
|
|
||||||
def add_plugin():
|
|
||||||
try:
|
|
||||||
from baBearModz import BearPlugin
|
|
||||||
except Exception as e:
|
|
||||||
return bs.timer(2.5, lambda e=e: bs.broadcastmessage('Error plugin: ' + str(e), (1, 0, 0)))
|
|
||||||
BearPlugin(
|
|
||||||
icon='logo',
|
|
||||||
creator='UPDATE TO API 9 BY ATD(anas) and less',
|
|
||||||
button_color=(1, 1, 0),
|
|
||||||
plugin=UltimatePowerupManager,
|
|
||||||
window=PowerupManagerWindow,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ba_meta export babase.Plugin
|
# ba_meta export babase.Plugin
|
||||||
|
|
||||||
|
|
||||||
class UltimatePowerupManager(babase.Plugin):
|
class UltimatePowerupManager(babase.Plugin):
|
||||||
# browser.ProfileBrowserWindow = NewProfileBrowserWindow
|
# ProfileBrowserWindow = NewProfileBrowserWindow
|
||||||
pupbox.PowerupBoxFactory = NewPowerupBoxFactory
|
pupbox.PowerupBoxFactory = NewPowerupBoxFactory
|
||||||
pupbox.PowerupBox.__init__ = _pbx_
|
pupbox.PowerupBox.__init__ = _pbx_
|
||||||
Bomb.__init__ = _bomb_init
|
Bomb.__init__ = _bomb_init
|
||||||
|
|
|
||||||
142
plugins/utilities/rank_system.py
Normal file
142
plugins/utilities/rank_system.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
# ba_meta require api 9
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import babase
|
||||||
|
import bascenev1 as bs
|
||||||
|
|
||||||
|
plugman = dict(
|
||||||
|
plugin_name="rank_system",
|
||||||
|
description="ranks system for servers or for local players",
|
||||||
|
external_url="",
|
||||||
|
authors=[
|
||||||
|
{"name": "ATD", "email": "anasdhaoidi001@gmail.com", "discord": ""},
|
||||||
|
],
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
MODS_DIR = os.path.dirname(__file__)
|
||||||
|
STATS_DIR = os.path.join(MODS_DIR, "stats")
|
||||||
|
STATS_FILE = os.path.join(STATS_DIR, "ranks.json")
|
||||||
|
|
||||||
|
|
||||||
|
class RankSystem:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.data = {}
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
if not os.path.exists(STATS_DIR):
|
||||||
|
os.makedirs(STATS_DIR)
|
||||||
|
|
||||||
|
if os.path.exists(STATS_FILE):
|
||||||
|
try:
|
||||||
|
with open(STATS_FILE, "r") as f:
|
||||||
|
self.data = json.load(f)
|
||||||
|
except:
|
||||||
|
self.data = {}
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
with open(STATS_FILE, "w") as f:
|
||||||
|
json.dump(self.data, f, indent=4)
|
||||||
|
|
||||||
|
def add_score(self, account_id, score=1):
|
||||||
|
if account_id not in self.data:
|
||||||
|
self.data[account_id] = {
|
||||||
|
"score": 0,
|
||||||
|
"rank": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
self.data[account_id]["score"] += score
|
||||||
|
self.update_ranks()
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def update_ranks(self):
|
||||||
|
sorted_players = sorted(
|
||||||
|
self.data.items(),
|
||||||
|
key=lambda x: x[1]["score"],
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, (aid, p) in enumerate(sorted_players):
|
||||||
|
p["rank"] = i + 1
|
||||||
|
|
||||||
|
def get_rank(self, account_id):
|
||||||
|
return self.data.get(account_id, {}).get("rank")
|
||||||
|
|
||||||
|
|
||||||
|
rank_sys = RankSystem()
|
||||||
|
|
||||||
|
|
||||||
|
class RankTag:
|
||||||
|
def __init__(self, node, rank):
|
||||||
|
|
||||||
|
m = bs.newnode(
|
||||||
|
"math",
|
||||||
|
owner=node,
|
||||||
|
attrs={
|
||||||
|
"input1": (0, 1.2, 0),
|
||||||
|
"operation": "add"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
node.connectattr("torso_position", m, "input2")
|
||||||
|
|
||||||
|
if rank == 1:
|
||||||
|
text = "1"
|
||||||
|
color = (1, 1, 1)
|
||||||
|
elif rank == 2:
|
||||||
|
text = "2"
|
||||||
|
color = (1, 1, 1)
|
||||||
|
elif rank == 3:
|
||||||
|
text = "3"
|
||||||
|
color = (1, 1, 1)
|
||||||
|
else:
|
||||||
|
text = f"#{rank}"
|
||||||
|
color = (1, 1, 1)
|
||||||
|
|
||||||
|
t = bs.newnode(
|
||||||
|
"text",
|
||||||
|
owner=node,
|
||||||
|
attrs={
|
||||||
|
"text": text,
|
||||||
|
"in_world": True,
|
||||||
|
"color": color,
|
||||||
|
"scale": 0.01,
|
||||||
|
"h_align": "center"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
m.connectattr("output", t, "position")
|
||||||
|
|
||||||
|
|
||||||
|
# 🔌 plugin
|
||||||
|
# ba_meta export babase.Plugin
|
||||||
|
class byATD(babase.Plugin):
|
||||||
|
|
||||||
|
def on_app_running(self):
|
||||||
|
print("Rank System Loaded ")
|
||||||
|
|
||||||
|
import bascenev1._gameactivity as ga
|
||||||
|
|
||||||
|
old_spawn = ga.GameActivity.spawn_player_spaz
|
||||||
|
|
||||||
|
def new_spawn(self, player, *args, **kwargs):
|
||||||
|
spaz = old_spawn(self, player, *args, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
aid = player.sessionplayer.get_account_id()
|
||||||
|
|
||||||
|
rank_sys.add_score(aid, 1)
|
||||||
|
|
||||||
|
rank = rank_sys.get_rank(aid)
|
||||||
|
if rank:
|
||||||
|
RankTag(spaz.node, rank)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print("Rank error:", e)
|
||||||
|
|
||||||
|
return spaz
|
||||||
|
|
||||||
|
ga.GameActivity.spawn_player_spaz = new_spawn
|
||||||
129
plugins/utilities/sleep_on_afk.py
Normal file
129
plugins/utilities/sleep_on_afk.py
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
# ba_meta require api 9
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import babase
|
||||||
|
import bascenev1 as bs
|
||||||
|
|
||||||
|
from bascenev1lib.actor.spaz import Spaz
|
||||||
|
|
||||||
|
plugman = dict(
|
||||||
|
plugin_name="sleep_on_afk",
|
||||||
|
description="Staying idle for 40 seconds will make your character fall asleep, they need rest too..",
|
||||||
|
external_url="",
|
||||||
|
authors=[
|
||||||
|
{"name": "DinoWattz", "email": "", "discord": ""}
|
||||||
|
],
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
INGAME_TIME = 40 # (in seconds)
|
||||||
|
|
||||||
|
# Spaz Changes
|
||||||
|
|
||||||
|
|
||||||
|
def _afk_sleep_knockout(self, value: float) -> None:
|
||||||
|
if not self.node:
|
||||||
|
return
|
||||||
|
if not self.is_alive() and hasattr(self.getplayer(self), 'sessionplayer'):
|
||||||
|
current_activity = self.getactivity()
|
||||||
|
player = self.getplayer(self).sessionplayer
|
||||||
|
# Pop timer data if the player is dead
|
||||||
|
if current_activity.customdata.get(str(player.id) + '_knockoutTimer') is not None:
|
||||||
|
current_activity.customdata.pop(str(player.id) + '_knockoutTimer')
|
||||||
|
return
|
||||||
|
|
||||||
|
self.node.handlemessage('knockout', value)
|
||||||
|
|
||||||
|
|
||||||
|
Spaz._afk_sleep_knockout = _afk_sleep_knockout # type: ignore
|
||||||
|
|
||||||
|
# Idle Checker
|
||||||
|
|
||||||
|
|
||||||
|
def idle_start(activity: bs.Activity):
|
||||||
|
activity.customdata['afk_timer'] = bs.Timer(
|
||||||
|
0.5, bs.CallStrict(idle_check, activity), repeat=True)
|
||||||
|
|
||||||
|
|
||||||
|
def idle_check(current_activity: bs.Activity):
|
||||||
|
current_session = current_activity.session
|
||||||
|
wait_time = INGAME_TIME if not current_activity.slow_motion else round(INGAME_TIME / 3)
|
||||||
|
current = bs.time() * 1000
|
||||||
|
if not current_session:
|
||||||
|
return
|
||||||
|
for player in current_session.sessionplayers:
|
||||||
|
if (
|
||||||
|
not player.exists()
|
||||||
|
or not player.in_game
|
||||||
|
or not getattr(player, 'activityplayer', None)
|
||||||
|
or not getattr(player.activityplayer, 'actor', None)
|
||||||
|
or not getattr(player.activityplayer.actor, 'node', None)
|
||||||
|
or not player.activityplayer.is_alive()
|
||||||
|
or not player.activityplayer.actor.node
|
||||||
|
or not player.activityplayer.actor.node.exists()
|
||||||
|
and not getattr(player.activityplayer.actor.node, 'invincible', False)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
player_actor = player.activityplayer.actor
|
||||||
|
player_node = player_actor.node
|
||||||
|
player_data = player.activityplayer.customdata
|
||||||
|
player_turbo_times = player_actor._turbo_filter_times
|
||||||
|
|
||||||
|
if player_node.move_up_down != 0.0 or player_node.move_left_right != 0.0:
|
||||||
|
player_data['last_input'] = current
|
||||||
|
elif player_turbo_times:
|
||||||
|
highest_turbo_time = player_turbo_times.get(
|
||||||
|
max(player_turbo_times, key=player_turbo_times.get))
|
||||||
|
if highest_turbo_time > player_data.get('last_input', current):
|
||||||
|
player_data.update({'last_input': highest_turbo_time})
|
||||||
|
player_data['last_input'] = player_data.get('last_input', current)
|
||||||
|
|
||||||
|
last_input = max(0, player_data['last_input'])
|
||||||
|
afk_time = int((current - last_input) / 1000)
|
||||||
|
|
||||||
|
# print(player_data)
|
||||||
|
# print(last_input)
|
||||||
|
# print(current)
|
||||||
|
# print(player.getname() + ": " + str(afk_time))
|
||||||
|
# print(wait_time)
|
||||||
|
|
||||||
|
if afk_time >= wait_time:
|
||||||
|
current_activity.customdata[str(player.id) + '_knockoutTimer'] = bs.Timer(
|
||||||
|
0.1, bs.WeakCallStrict(player_actor._afk_sleep_knockout, 100.0), repeat=True)
|
||||||
|
|
||||||
|
# Make the player's node not an area of interest if it was one
|
||||||
|
if not getattr(player_actor, '_previous_is_area_of_interest', False):
|
||||||
|
player_actor._previous_is_area_of_interest = player_node.is_area_of_interest
|
||||||
|
|
||||||
|
if player_actor._previous_is_area_of_interest:
|
||||||
|
player_node.is_area_of_interest = False
|
||||||
|
|
||||||
|
elif current_activity.customdata.get(str(player.id) + '_knockoutTimer') is not None:
|
||||||
|
current_activity.customdata.pop(str(player.id) + '_knockoutTimer')
|
||||||
|
|
||||||
|
# Restore the player's node area of interest if necessary
|
||||||
|
if getattr(player_actor, '_previous_is_area_of_interest', False):
|
||||||
|
player_node.is_area_of_interest = True
|
||||||
|
|
||||||
|
if hasattr(player_actor, "_previous_is_area_of_interest"):
|
||||||
|
delattr(player_actor, "_previous_is_area_of_interest")
|
||||||
|
|
||||||
|
|
||||||
|
# Setup new activity
|
||||||
|
org_on_begin = bs.Activity.on_begin
|
||||||
|
|
||||||
|
|
||||||
|
def patched_on_begin(self, *args, **kwargs):
|
||||||
|
idle_start(self)
|
||||||
|
|
||||||
|
return org_on_begin(self, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
bs.Activity.on_begin = patched_on_begin
|
||||||
|
|
||||||
|
# ba_meta export babase.Plugin
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin(babase.Plugin):
|
||||||
|
pass
|
||||||
|
|
@ -2,11 +2,20 @@ import sys
|
||||||
import json
|
import json
|
||||||
import ast
|
import ast
|
||||||
import os
|
import os
|
||||||
|
import hashlib
|
||||||
|
import subprocess
|
||||||
import get_latest
|
import get_latest
|
||||||
import versioning_tools
|
from auto_apply_version_metadata import get_comparable_version_tuple_from_string
|
||||||
|
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
||||||
|
MANIFEST_PATHS = {
|
||||||
|
"minigames": "plugins/minigames.json",
|
||||||
|
"utilities": "plugins/utilities.json",
|
||||||
|
"maps": "plugins/maps.json",
|
||||||
|
"plugman": "index.json",
|
||||||
|
}
|
||||||
|
|
||||||
print("DOES THIS RUN AUTO APPLY PLUGIN METADATA?")
|
print("DOES THIS RUN AUTO APPLY PLUGIN METADATA?")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -15,23 +24,69 @@ def debug_print(*args, **kwargs):
|
||||||
print(*args, **kwargs)
|
print(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def get_latest_version(plugin_name, category) -> str:
|
def version_key(version):
|
||||||
filepaths = {
|
"""Comparison/sort key for a version string, so 1.0.10 sorts above 1.0.9."""
|
||||||
"minigames": "plugins/minigames.json",
|
try:
|
||||||
"utilities": "plugins/utilities.json",
|
return get_comparable_version_tuple_from_string(version)
|
||||||
"maps": "plugins/maps.json",
|
except ValueError:
|
||||||
"plugman": "index.json",
|
raise ValueError(f"Version {version!r} is not in x.y.z form.")
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
def md5sum_of(path):
|
||||||
|
with open(path, "rb") as fin:
|
||||||
|
return hashlib.md5(fin.read()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_version(plugin_name, category) -> str:
|
||||||
try:
|
try:
|
||||||
if category != "plugman":
|
if category != "plugman":
|
||||||
return get_latest.get_latest_plugin_version(plugin_name, filepaths[category])
|
return get_latest.get_latest_plugin_version(plugin_name, MANIFEST_PATHS[category])
|
||||||
return get_latest.get_latest_plugman_version()
|
return get_latest.get_latest_plugman_version()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
def read_manifest_at(path, ref):
|
||||||
|
"""Load a manifest as it exists at `ref`, or None if it can't be read there."""
|
||||||
|
try:
|
||||||
|
blob = subprocess.run(
|
||||||
|
["git", "show", f"{ref}:{path}"],
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
).stdout
|
||||||
|
return json.loads(blob)
|
||||||
|
except (OSError, subprocess.CalledProcessError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_published_versions(plugin_name, category):
|
||||||
|
"""Versions of `plugin_name` that are already published on the PR's base branch.
|
||||||
|
|
||||||
|
The working tree cannot answer this. Once ci-apply.yml has pushed its
|
||||||
|
"[ci] apply-plugin-metadata-and-formatting" commit back to the PR branch,
|
||||||
|
the PR's own copy of the manifest already lists the version being added -
|
||||||
|
so comparing against the working tree rejects every re-run of PR Check,
|
||||||
|
including the one ci-apply.yml's own push triggers, and every re-run caused
|
||||||
|
by a contributor pushing a follow-up commit.
|
||||||
|
|
||||||
|
PLUGMAN_BASE_REF is set by ci-check.yml to the PR's base sha. Local runs
|
||||||
|
fall back to origin/main, then to the working tree when there is no history
|
||||||
|
to consult at all.
|
||||||
|
"""
|
||||||
|
path = MANIFEST_PATHS[category]
|
||||||
|
manifest = None
|
||||||
|
for ref in (os.environ.get("PLUGMAN_BASE_REF"), "origin/main", "main"):
|
||||||
|
if ref:
|
||||||
|
manifest = read_manifest_at(path, ref)
|
||||||
|
if manifest is not None:
|
||||||
|
break
|
||||||
|
if manifest is None:
|
||||||
|
with open(path, "r") as file:
|
||||||
|
manifest = json.load(file)
|
||||||
|
return manifest["plugins"].get(plugin_name, {}).get("versions", {})
|
||||||
|
|
||||||
|
|
||||||
def update_plugman_json(version):
|
def update_plugman_json(version):
|
||||||
with open("index.json", "r+") as file:
|
with open("index.json", "r+") as file:
|
||||||
data = json.load(file)
|
data = json.load(file)
|
||||||
|
|
@ -45,39 +100,58 @@ def update_plugman_json(version):
|
||||||
data["versions"] = dict(sorted(data["versions"].items(), reverse=True))
|
data["versions"] = dict(sorted(data["versions"].items(), reverse=True))
|
||||||
|
|
||||||
|
|
||||||
def update_plugin_json(plugin_info, category):
|
def update_plugin_json(plugin_info, category, plugin_path):
|
||||||
name = plugin_info["plugin_name"]
|
name = plugin_info["plugin_name"]
|
||||||
|
version = plugin_info["version"]
|
||||||
|
|
||||||
|
# Ensure the version is always greater than the already PUBLISHED version -
|
||||||
|
# what is on the base branch, not what this PR's own tree happens to say.
|
||||||
|
published = get_published_versions(name, category)
|
||||||
|
if published:
|
||||||
|
latest_published = max(published, key=version_key)
|
||||||
|
if version_key(version) <= version_key(latest_published):
|
||||||
|
raise Exception(
|
||||||
|
"Version cant be lower or equal than the previous version. "
|
||||||
|
f"{name} {latest_published} is already published; bump the version "
|
||||||
|
f"in its plugman dict (currently {version})."
|
||||||
|
)
|
||||||
|
|
||||||
with open(f"plugins/{category}.json", "r+") as file:
|
with open(f"plugins/{category}.json", "r+") as file:
|
||||||
data = json.load(file)
|
data = json.load(file)
|
||||||
try:
|
plugin = data["plugins"].get(name)
|
||||||
# Check if plugin is already in the json
|
if plugin is None:
|
||||||
plugin = data["plugins"][name]
|
# New plugin. Key order here is the shape every other entry has.
|
||||||
plugman_version = int(
|
plugin = data["plugins"][name] = {
|
||||||
versioning_tools.semantic_to_str(get_latest_version(name, category))
|
|
||||||
)
|
|
||||||
current_version = int(versioning_tools.semantic_to_str(plugin_info["version"]))
|
|
||||||
# Ensure the version is always greater from the already released version
|
|
||||||
if current_version > plugman_version:
|
|
||||||
plugin["versions"][plugin_info["version"]] = None
|
|
||||||
# Ensure latest version appears first
|
|
||||||
plugin["versions"] = dict(sorted(plugin["versions"].items(), reverse=True))
|
|
||||||
plugin["description"] = plugin_info["description"]
|
|
||||||
plugin["external_url"] = plugin_info["external_url"]
|
|
||||||
plugin["authors"] = plugin_info["authors"]
|
|
||||||
# In future
|
|
||||||
# We can clear the version metadata for if current_version = plugman_version for PRs
|
|
||||||
# So that its easier to update the plugin after review without changing version
|
|
||||||
elif current_version <= plugman_version:
|
|
||||||
raise Exception("Version cant be lower or equal than the previous version.")
|
|
||||||
except KeyError:
|
|
||||||
data["plugins"][name] = {
|
|
||||||
"description": plugin_info["description"],
|
"description": plugin_info["description"],
|
||||||
"external_url": plugin_info["external_url"],
|
"external_url": plugin_info["external_url"],
|
||||||
"authors": plugin_info["authors"],
|
"authors": plugin_info["authors"],
|
||||||
"versions": {plugin_info["version"]: None},
|
"versions": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
versions = plugin["versions"]
|
||||||
|
stamped = versions.get(version)
|
||||||
|
# A null placeholder is only (re)written when there is something for
|
||||||
|
# auto_apply_version_metadata.py to stamp, which keeps re-runs over an
|
||||||
|
# already-processed tree a no-op - otherwise ci-apply.yml's push would
|
||||||
|
# trigger a PR Check that undoes the stamp, forever.
|
||||||
|
#
|
||||||
|
# A stamped entry whose md5sum no longer matches the file means the
|
||||||
|
# contributor pushed further edits under the same UNPUBLISHED version
|
||||||
|
# (the published case raised above). Reset it so the stamp is
|
||||||
|
# recomputed, rather than demanding a bump for every review iteration.
|
||||||
|
if version not in versions or (
|
||||||
|
isinstance(stamped, dict) and stamped.get("md5sum") != md5sum_of(plugin_path)
|
||||||
|
):
|
||||||
|
versions[version] = None
|
||||||
|
|
||||||
|
# Ensure latest version appears first
|
||||||
|
plugin["versions"] = dict(
|
||||||
|
sorted(versions.items(), key=lambda item: version_key(item[0]), reverse=True)
|
||||||
|
)
|
||||||
|
plugin["description"] = plugin_info["description"]
|
||||||
|
plugin["external_url"] = plugin_info["external_url"]
|
||||||
|
plugin["authors"] = plugin_info["authors"]
|
||||||
|
|
||||||
file.seek(0)
|
file.seek(0)
|
||||||
json.dump(data, file, indent=2, ensure_ascii=False)
|
json.dump(data, file, indent=2, ensure_ascii=False)
|
||||||
# Ensure old content is removed
|
# Ensure old content is removed
|
||||||
|
|
@ -132,7 +206,7 @@ def extract_plugman(plugins):
|
||||||
)
|
)
|
||||||
result[kw.arg] = ast.literal_eval(kw.value)
|
result[kw.arg] = ast.literal_eval(kw.value)
|
||||||
if category:
|
if category:
|
||||||
update_plugin_json(result, category=category)
|
update_plugin_json(result, category=category, plugin_path=plugin)
|
||||||
else:
|
else:
|
||||||
update_plugman_json(result)
|
update_plugman_json(result)
|
||||||
# raise ValueError("Variable plugman not found in the file or has unsupported format.")
|
# raise ValueError("Variable plugman not found in the file or has unsupported format.")
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,39 @@ from packaging.version import Version
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
# Raised by Repo.commit() when a revision cannot be resolved. Both entries are
|
||||||
|
# required because which one you get depends on how the sha is spelled: an
|
||||||
|
# unresolvable abbreviated name - the 7-8 char form this repo actually stores,
|
||||||
|
# see auto_apply_version_metadata.py - raises BadName, whereas a full-length
|
||||||
|
# but absent 40-hex sha raises plain ValueError. BadName/BadObject are NOT
|
||||||
|
# ValueError subclasses, so neither entry is redundant. Kept narrow so lenient
|
||||||
|
# mode can't mask unrelated repository errors.
|
||||||
|
#
|
||||||
|
# BadName/BadObject originate in gitdb but are re-exported by GitPython in
|
||||||
|
# git.exc.__all__, so git.exc.* is the supported public spelling and is used
|
||||||
|
# here deliberately - it avoids importing gitdb, a transitive dependency that
|
||||||
|
# test/pip_reqs.txt does not declare directly.
|
||||||
|
UNRESOLVED_COMMIT_ERRORS = (ValueError, git.exc.BadName, git.exc.BadObject)
|
||||||
|
|
||||||
|
|
||||||
|
def is_unpublished_version(repository, version_metadata):
|
||||||
|
"""True if this version entry is being introduced by the PR under test.
|
||||||
|
|
||||||
|
Such an entry can't satisfy the history checks yet: its metadata is either
|
||||||
|
still an unstamped ``null`` placeholder, or was stamped against the current
|
||||||
|
HEAD by ci-check.yml's preview - describing the reformatted working tree
|
||||||
|
rather than anything committed. Published entries always point at an
|
||||||
|
earlier commit whose tree really does contain the described file, so they
|
||||||
|
are unaffected. Only consulted in lenient mode (ci-check.yml); the
|
||||||
|
authoritative strict run on push-to-main still validates these.
|
||||||
|
"""
|
||||||
|
if not version_metadata or not version_metadata.get("commit_sha"):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return repository.commit(version_metadata["commit_sha"]) == repository.head.commit
|
||||||
|
except UNRESOLVED_COMMIT_ERRORS:
|
||||||
|
return False # unresolvable: let the caller's handler report it
|
||||||
|
|
||||||
|
|
||||||
class TestPluginManagerMetadata(unittest.TestCase):
|
class TestPluginManagerMetadata(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
@ -40,8 +73,19 @@ class TestPluginManagerMetadata(unittest.TestCase):
|
||||||
assert sorted_versions == versions
|
assert sorted_versions == versions
|
||||||
|
|
||||||
def test_versions(self):
|
def test_versions(self):
|
||||||
|
lenient = os.environ.get("PLUGMAN_CI_LENIENT_HISTORY") == "1"
|
||||||
for version_name, version_metadata in self.content["versions"].items():
|
for version_name, version_metadata in self.content["versions"].items():
|
||||||
commit = self.repository.commit(version_metadata["commit_sha"])
|
if lenient and is_unpublished_version(self.repository, version_metadata):
|
||||||
|
print(f"[lenient] skipping {version_name}: not committed yet")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
commit = self.repository.commit(version_metadata["commit_sha"])
|
||||||
|
except UNRESOLVED_COMMIT_ERRORS as err:
|
||||||
|
if lenient:
|
||||||
|
print(f"[lenient] skipping {version_name}: commit "
|
||||||
|
f"{version_metadata['commit_sha']} not found yet ({err})")
|
||||||
|
continue
|
||||||
|
raise
|
||||||
plugin_manager = commit.tree / self.plugin_manager
|
plugin_manager = commit.tree / self.plugin_manager
|
||||||
with io.BytesIO(plugin_manager.data_stream.read()) as fin:
|
with io.BytesIO(plugin_manager.data_stream.read()) as fin:
|
||||||
content = fin.read()
|
content = fin.read()
|
||||||
|
|
@ -144,9 +188,21 @@ class BaseCategoryMetadataTestCases:
|
||||||
self.assertTrue(len(plugin_metadata["versions"]) > 0)
|
self.assertTrue(len(plugin_metadata["versions"]) > 0)
|
||||||
|
|
||||||
def test_versions(self):
|
def test_versions(self):
|
||||||
|
lenient = os.environ.get("PLUGMAN_CI_LENIENT_HISTORY") == "1"
|
||||||
for plugin_name, plugin_metadata in self.content["plugins"].items():
|
for plugin_name, plugin_metadata in self.content["plugins"].items():
|
||||||
for version_name, version_metadata in plugin_metadata["versions"].items():
|
for version_name, version_metadata in plugin_metadata["versions"].items():
|
||||||
commit = self.repository.commit(version_metadata["commit_sha"])
|
if lenient and is_unpublished_version(self.repository, version_metadata):
|
||||||
|
print(f"[lenient] skipping {plugin_name} {version_name}: "
|
||||||
|
"not committed yet")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
commit = self.repository.commit(version_metadata["commit_sha"])
|
||||||
|
except UNRESOLVED_COMMIT_ERRORS as err:
|
||||||
|
if lenient:
|
||||||
|
print(f"[lenient] skipping {plugin_name} {version_name}: "
|
||||||
|
f"commit {version_metadata['commit_sha']} not found yet ({err})")
|
||||||
|
continue
|
||||||
|
raise
|
||||||
plugin = os.path.join(self.category, f"{plugin_name}.py")
|
plugin = os.path.join(self.category, f"{plugin_name}.py")
|
||||||
plugin_commit_sha = commit.tree / plugin
|
plugin_commit_sha = commit.tree / plugin
|
||||||
with io.BytesIO(plugin_commit_sha.data_stream.read()) as fin:
|
with io.BytesIO(plugin_commit_sha.data_stream.read()) as fin:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue