Files
canalhandia/preflight.sh
T
marcos 4ec9817636 Add weekly rankings, spontaneous AI lines and BlueMap note markers
Weekly rankings (/ranking semanal [metrica]). On a server with three regulars
an all-time board is decided by whoever started first and then stops being a
contest. Subtracting a baseline taken at the start of the week makes it one
again. Rotation is time-based and idempotent — the snapshot carries the
timestamp it was taken at and is replaced only once a week has actually
elapsed, so a server that restarts nightly does not reset the week every day.
There is a test for exactly that. Players who did not move are dropped, since
the point is who is playing this week; a player missing from the baseline
counts their whole value, having joined during the week; a negative difference
is dropped rather than shown, because statistics only go up and a negative
means a stale baseline, not a result worth printing.

Spontaneous AI lines (ia.comentar-eventos, ia.saudacao — both off by default).
The persona comments on a run of deaths, and greets players as they join using
their own numbers. Both are opt-in: a chatty AI nobody asked for is the fastest
way to make players hate the feature.

Budget is what makes this tolerable rather than obnoxious, and it is the piece
worth reading. A player question is self-limiting — someone chose to spend it.
A line the AI decides to make on its own is not, and it costs money every time,
so three limits all have to pass: a gap between any two lines, a daily cap of
its own separate from the /ia cap, and a per-subject cooldown so one unlucky
player is not narrated all evening. allows() does not spend, so a caller that
decides not to fire (nobody online, the model returned nothing) has burned
nothing; saySomething spends up front rather than on success, because two
events in the same tick would otherwise both pass allows() and fire together —
the exact double-message the gap exists to prevent. The subject map drops
expired entries on each spend, or a long-lived server would leak one entry per
player who ever triggered a line.

A death streak now decays: three deaths across an evening is not a streak,
three in ten minutes is. Spontaneous lines are silent on failure — nobody asked
for it, so nobody should see it fail.

The five-minute sweep no longer hides behind the marcos toggle. It was a
milestones-only task, so turning off marcos also silenced achievements and
froze the weekly board; each of the three now checks its own module inside the
body.

BlueMap markers (notas.no-mapa, default on). Public notes already carry a world
and coordinates and the server already runs BlueMap, so this joins the two.
BlueMapBridge is the only class that touches BlueMap's API and every entry
point catches NoClassDefFoundError as well as Exception — the failure mode of a
missing optional dependency is a linkage error, not an exception — so a server
without BlueMap logs one fine-level line and carries on. The dependency is
'provided' because BlueMap ships those classes itself; a second copy inside our
jar would shadow them and break the real plugin, and preflight now fails if the
scope is ever dropped.

Markers are rebuilt rather than incrementally patched: BlueMap discards
everything when it unloads and expects addons to re-create markers on its
enable callback, and a full rebuild of a tiny list cannot drift out of sync the
way a missed delete would. Notes are matched to the map that renders their
world, or a Nether note would be drawn at the same numeric coordinates in the
overworld, pointing at nothing. Note text is player-written and lands in a web
page, so it is HTML-escaped — ampersand first, or the other replacements would
be double-escaped. Private notes are never drawn, at any setting.

295 tests, up from 263.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-08 01:03:10 +00:00

216 lines
8.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# Pre-flight harness for a Canalhandia deploy.
#
# Every check below is READ-ONLY. It never restarts the server, never writes to
# the plugins directory, and never touches the world. Run it after staging a
# jar and before asking Crafty to restart: a red line here is a crash-on-boot
# you get to fix while the server is still up.
#
# Usage: ./preflight.sh [path/to/new.jar]
# (defaults to target/Canalhandia-1.0.0.jar)
set -uo pipefail
JAR="${1:-target/Canalhandia-1.0.0.jar}"
NS=minecraft
SERVER_ID=6e39a8b2-300b-42d6-8139-f397c23e461b
PLUGINS="/crafty/servers/${SERVER_ID}/plugins"
K="microk8s kubectl -n ${NS}"
fail=0
pass() { printf ' \033[32mOK\033[0m %s\n' "$1"; }
warn() { printf ' \033[33mWARN\033[0m %s\n' "$1"; }
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail + 1)); }
head() { printf '\n\033[1m%s\033[0m\n' "$1"; }
CRAFTY=$($K get pods -l app=crafty-controller -o jsonpath='{.items[0].metadata.name}' 2>/dev/null)
if [ -z "$CRAFTY" ]; then
CRAFTY=$($K get pods --no-headers 2>/dev/null | awk '/^crafty-controller/ {print $1; exit}')
fi
head "1. Local build"
if [ ! -f "$JAR" ]; then
bad "jar not found: $JAR"
else
pass "jar present: $JAR ($(stat -c%s "$JAR") bytes)"
# A jar that does not open is a jar the server will refuse at boot.
if unzip -t "$JAR" >/dev/null 2>&1; then
pass "jar archive is intact"
else
bad "jar archive is corrupt (unzip -t failed)"
fi
# plugin.yml is the only file Bukkit truly requires; without it the plugin is
# skipped silently and nothing in the deploy takes effect.
if unzip -p "$JAR" plugin.yml >/dev/null 2>&1; then
pass "plugin.yml present in jar"
else
bad "plugin.yml MISSING from jar — the plugin would not load at all"
fi
# Kept in step with Canalhandia.onEnable's register() list. A command that is
# registered in code but absent here logs a warning at boot and then silently
# does nothing in game, which is a hard failure to diagnose from inside.
CMDS="canalhandia curiosidade adivinha enquete ranking reagir reacoes \
palpite votar legal wow top f ia iap errado nota save \
recado recados mortes conquistas"
missing=""
for cmd in $CMDS; do
unzip -p "$JAR" plugin.yml 2>/dev/null | grep -qE "^ ${cmd}:" || missing="$missing $cmd"
done
if [ -z "$missing" ]; then
pass "all $(echo $CMDS | wc -w) commands declared in plugin.yml"
else
bad "commands missing from plugin.yml:$missing"
fi
# Permissions the new features gate on. An undeclared Bukkit permission falls
# back to op-only, which would silently stop normal players writing notes.
for perm in canalhandia.nota canalhandia.nota.publica canalhandia.recado; do
if unzip -p "$JAR" plugin.yml 2>/dev/null | grep -q " ${perm}:"; then
pass "permission ${perm} declared"
else
bad "permission ${perm} MISSING — would default to op-only"
fi
done
# BlueMap's API is compile-only (provided scope): BlueMap ships those classes
# itself, and a second copy inside this jar would shadow them and break the
# real plugin. This check is the guard against someone dropping the scope.
if unzip -l "$JAR" 2>/dev/null | grep -q "bluecolored"; then
bad "BlueMap classes are BUNDLED in the jar — the dependency must stay 'provided'"
else
pass "BlueMap API not bundled (provided scope intact)"
fi
# config.yml ships defaults; a jar without it means saveDefaultConfig() writes
# nothing and every setting silently falls back to the hardcoded default.
if unzip -p "$JAR" config.yml >/dev/null 2>&1; then
pass "config.yml present in jar"
else
bad "config.yml MISSING from jar"
fi
fi
head "2. Tests"
if [ -d target/surefire-reports ]; then
tests=$(grep -ho 'tests="[0-9]*"' target/surefire-reports/*.xml 2>/dev/null |
grep -o '[0-9]*' | paste -sd+ | bc)
bads=$(grep -ho 'failures="[0-9]*"\|errors="[0-9]*"' target/surefire-reports/*.xml 2>/dev/null |
grep -o '[0-9]*' | paste -sd+ | bc)
if [ "${bads:-1}" = "0" ]; then
pass "${tests} tests, 0 failures/errors"
else
bad "${bads} test failures/errors — run mvn test"
fi
else
warn "no surefire reports; run the test suite before deploying"
fi
head "3. Cluster"
if [ -z "$CRAFTY" ]; then
bad "crafty-controller pod not found in namespace ${NS}"
else
pass "crafty pod: $CRAFTY"
if $K exec "$CRAFTY" -- test -d "$PLUGINS" 2>/dev/null; then
pass "plugins directory reachable"
else
bad "cannot reach $PLUGINS"
fi
fi
head "4. Staged jar on the server"
if [ -n "$CRAFTY" ] && [ -f "$JAR" ]; then
local_sum=$(sha256sum "$JAR" | cut -c1-8)
remote_sum=$($K exec "$CRAFTY" -- sha256sum "$PLUGINS/Canalhandia-1.0.0.jar" 2>/dev/null | cut -c1-8)
if [ -z "$remote_sum" ]; then
bad "no Canalhandia jar staged on the server"
elif [ "$local_sum" = "$remote_sum" ]; then
pass "staged jar matches the local build (sha $local_sum)"
else
bad "staged jar is sha $remote_sum, local build is $local_sum — copy it again"
fi
# Ownership: the JVM runs as uid 1000 / gid 0. A root-owned jar is a jar the
# server cannot read, and the failure looks like "plugin just did not load".
owner=$($K exec "$CRAFTY" -- stat -c '%u:%g' "$PLUGINS/Canalhandia-1.0.0.jar" 2>/dev/null)
if [ "$owner" = "1000:0" ]; then
pass "jar ownership 1000:0"
else
bad "jar ownership is '$owner', expected 1000:0 — chown it"
fi
# A rollback target must exist before, not after, something goes wrong.
if $K exec "$CRAFTY" -- sh -c "ls $PLUGINS/Canalhandia-1.0.0.jar.bak-* >/dev/null 2>&1"; then
pass "rollback jar(s) present"
else
bad "no .bak jar to roll back to"
fi
fi
head "5. Live config"
if [ -n "$CRAFTY" ]; then
cfg="$PLUGINS/Canalhandia/config.yml"
if $K exec "$CRAFTY" -- test -f "$cfg" 2>/dev/null; then
pass "config.yml present on the server"
# Parsed HERE rather than in the pod: the crafty image has no python, and a
# config.yml that does not parse is a plugin that disables itself on boot.
tmp=$(mktemp)
if $K exec "$CRAFTY" -- cat "$cfg" > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then
# Pick a parser. "no parser available" must NOT be reported as "invalid":
# a harness that cries wolf is a harness people learn to ignore.
if python3 -c "import yaml" 2>/dev/null; then
yaml_check() { python3 -c "import yaml,sys;yaml.safe_load(open(sys.argv[1]))" "$1"; }
elif command -v docker >/dev/null 2>&1; then
yaml_check() { docker run --rm -v "$1":/c.yml:ro python:3.12-slim \
sh -c "pip install -q pyyaml >/dev/null 2>&1 && python3 -c \
'import yaml;yaml.safe_load(open(\"/c.yml\"))'"; }
else
yaml_check() { return 2; }
fi
yaml_check "$tmp" 2>/dev/null
case $? in
0) pass "config.yml parses as valid YAML" ;;
2) warn "no YAML parser available (pip install pyyaml) — not checked" ;;
*) bad "config.yml on the server is NOT valid YAML — the plugin would fail to load" ;;
esac
# The keys this deploy depends on. A missing key is not fatal (Settings
# has defaults) but it means the merge did not happen as intended.
for key in "modulos:" "zoacao:" "personalidade:" "contexto-chat:" "estado-servidor:"; do
if grep -q "$key" "$tmp"; then
pass "config has ${key%:}"
else
warn "config has no '${key%:}' — will fall back to the built-in default"
fi
done
else
warn "could not read config.yml out of the pod"
fi
rm -f "$tmp"
if $K exec "$CRAFTY" -- sh -c "ls $PLUGINS/Canalhandia/config.yml.bak-* >/dev/null 2>&1"; then
pass "config backup(s) present"
else
bad "no config.yml.bak-* to roll back to"
fi
else
bad "config.yml missing on the server"
fi
fi
head "6. Server health right now"
if [ -n "$CRAFTY" ]; then
logf="/crafty/servers/${SERVER_ID}/logs/latest.log"
# tr -dc keeps digits only: grep -c prints nothing on no-match under some
# shells, and the stray newline made the numeric test below explode.
online=$($K exec "$CRAFTY" -- sh -c "grep -c 'joined the game' $logf" 2>/dev/null | tr -dc '0-9')
pass "log readable (${online:-0} join lines this session)"
errs=$($K exec "$CRAFTY" -- sh -c "tail -500 $logf | grep -ci 'ERROR\]'" 2>/dev/null | tr -dc '0-9')
if [ "${errs:-0}" -gt 0 ]; then
warn "${errs} ERROR lines in the last 500 — read them before restarting"
else
pass "no ERROR lines in the last 500"
fi
fi
printf '\n'
if [ "$fail" -eq 0 ]; then
printf '\033[32mPRE-FLIGHT CLEAN — safe to restart.\033[0m\n'
exit 0
fi
printf '\033[31m%d CHECK(S) FAILED — do NOT restart yet.\033[0m\n' "$fail"
exit 1