One account is now one session. Two peers signed in as the same account each got their own player built from the SAME character record, so both held a copy of one bag and whichever wrote last won: drop the single potion from both copies and two potions hit the floor. Reconciling two live mirrors of one record cannot be made to work, so the handshake refuses the second connection -- the newcomer, not the incumbent, or claiming an account would be a way to kick anyone off the server. A muzzle offset is a teleport and the bullet pool never sees it: it samples geometry once per tick. Forward that is 19px, but Doubleshot adds up to 45px sideways, wider than a 32px wall plus the 6px hitbox holding you off it. Six Doubleshots, stand flush against a wall, fire along it, and live shots came out the far side. Shots now start from the shooter when geometry stands in the way, which keeps the count honest. Bullet-spawn events carry the firing actor's exact position, and they were filtered by a flat 2900-unit radius against an 800-unit actor cull. A depth-1 map's diagonal is about 2521, so that radius culled nothing: every enemy that fired was pinpointed for every player, through fog, on terrain the client was deliberately not streamed. The test is now per bullet -- can THIS one reach ground you can see -- which keeps the guarantee the wide radius existed for and drops the radar. Found while fixing the above: a rejection never reached the client. The RPC is queued and only leaves on the frame's flush, so kicking in the same call threw the reason away, and _on_server_disconnected overwrote last_error anyway. Both were true of protocol mismatch too, which is the one message a player most needs. Smoke asserts the reason arrives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 lines
9.7 KiB
Bash
Executable File
220 lines
9.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# End-to-end integration test: boot a dedicated server, connect two scripted
|
|
# bot clients over a real ENet socket, and assert that the whole loop happened --
|
|
# handshake, lobby, portal into a dungeon, and the emergency escape back.
|
|
#
|
|
# This is the test that catches everything unit tests structurally cannot: RPC
|
|
# wiring, codec round-trips over the wire, instance transfers, and the client's
|
|
# reconciliation loop. Run it before calling any networking change done.
|
|
#
|
|
# tools/smoke.sh # ~35s
|
|
# KEEP=1 tools/smoke.sh # keep the logs and print where they are
|
|
set -uo pipefail
|
|
cd "$(dirname "$0")/.."
|
|
GODOT="${GODOT:-godot}"
|
|
PORT="${PORT:-27099}"
|
|
SERVER_TICKS="${SERVER_TICKS:-2000}"
|
|
CLIENT_TICKS="${CLIENT_TICKS:-1700}"
|
|
OUT="$(mktemp -d -t transcience-smoke-XXXXXX)"
|
|
|
|
PIDS=()
|
|
cleanup() {
|
|
for pid in "${PIDS[@]:-}"; do
|
|
kill "$pid" 2>/dev/null
|
|
done
|
|
wait 2>/dev/null
|
|
if [[ "${KEEP:-0}" == "1" ]]; then
|
|
echo "logs kept in $OUT"
|
|
else
|
|
rm -rf "$OUT"
|
|
fi
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
echo "smoke: server on port $PORT, logs in $OUT"
|
|
# A scratch character store: without it a rerun resumes the characters the
|
|
# previous run created, and "a character was created" stops being true.
|
|
"$GODOT" --headless --path . -- --server --port "$PORT" --autoquit "$SERVER_TICKS" \
|
|
--store "$OUT/characters.json" \
|
|
> "$OUT/server.log" 2>&1 &
|
|
PIDS+=($!)
|
|
|
|
for _ in $(seq 1 60); do
|
|
grep -q "SERVER_READY" "$OUT/server.log" 2>/dev/null && break
|
|
sleep 0.25
|
|
done
|
|
if ! grep -q "SERVER_READY" "$OUT/server.log" 2>/dev/null; then
|
|
echo "FAIL: server never became ready"; cat "$OUT/server.log"; exit 1
|
|
fi
|
|
|
|
for n in 1 2; do
|
|
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
|
|
--name "bot$n" --account "$((7000 + n))" \
|
|
--autoquit "$CLIENT_TICKS" > "$OUT/bot$n.log" 2>&1 &
|
|
PIDS+=($!)
|
|
sleep 0.4
|
|
done
|
|
|
|
# A bot that claims an account another bot is already playing. One account is
|
|
# one session: two peers on one account each get their own player built from the
|
|
# SAME character record, so both hold a copy of one bag and whichever writes last
|
|
# wins -- drop the single potion from both copies and two potions hit the floor.
|
|
# The handshake has to refuse it, and it has to refuse the NEWCOMER, or claiming
|
|
# someone's account becomes a way to kick them off the server.
|
|
for _ in $(seq 1 60); do
|
|
grep -q "authenticated as account 7001" "$OUT/server.log" 2>/dev/null && break
|
|
sleep 0.25
|
|
done
|
|
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
|
|
--name "twinbot" --account 7001 --autoquit 300 \
|
|
> "$OUT/twinbot.log" 2>&1 &
|
|
PIDS+=($!)
|
|
sleep 0.4
|
|
|
|
# A fourth bot that leaves *politely* -- the same Net.shutdown() the in-game
|
|
# menu's "Disconnect" button calls. The escape channel is keyed on the socket
|
|
# closing, not on how it closed, so a clean exit must be caught exactly like the
|
|
# SIGKILL below. If someone ever adds a "clean leave" message that bypasses the
|
|
# channel, this is what catches it.
|
|
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
|
|
--name "leavebot" --account 7101 --leave-after 120 --autoquit "$CLIENT_TICKS" \
|
|
> "$OUT/leavebot.log" 2>&1 &
|
|
PIDS+=($!)
|
|
sleep 0.4
|
|
|
|
# A third bot that gets SIGKILLed the moment it is inside a dungeon. This is the
|
|
# anti-disconnect-cheese path: the server must keep its body in the world,
|
|
# channel it out over the same one second the escape button costs, and only then
|
|
# forget the peer -- never delete it instantly on socket close.
|
|
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
|
|
--name "dropbot" --account 7102 --autoquit "$CLIENT_TICKS" > "$OUT/dropbot.log" 2>&1 &
|
|
DROP_PID=$!
|
|
for _ in $(seq 1 80); do
|
|
grep -q "entered instance .*DUNGEON" "$OUT/dropbot.log" 2>/dev/null && break
|
|
sleep 0.25
|
|
done
|
|
if grep -q "entered instance .*DUNGEON" "$OUT/dropbot.log" 2>/dev/null; then
|
|
# Reaped here so bash does not print its own "Killed" job-control line; the
|
|
# kill is the point of the test, not a failure.
|
|
{ kill -9 "$DROP_PID"; wait "$DROP_PID"; } 2>/dev/null || true
|
|
else
|
|
echo " WARN dropbot never reached a dungeon; drop assertions will fail"
|
|
PIDS+=("$DROP_PID")
|
|
fi
|
|
|
|
wait 2>/dev/null
|
|
|
|
fails=0
|
|
check() { # check <label> <file> <pattern>
|
|
if grep -qE "$3" "$2"; then
|
|
echo " ok $1"
|
|
else
|
|
echo " FAIL $1 (no match for /$3/ in $(basename "$2"))"
|
|
fails=$((fails + 1))
|
|
fi
|
|
}
|
|
# Plain engine errors, which the pattern below deliberately does not catch:
|
|
# "SCRIPT ERROR" and friends are GDScript problems, and a great many engine
|
|
# complaints are neither. A settings file missing a key logged one ERROR line
|
|
# per action at startup and this suite reported a clean pass, which is what
|
|
# motivated this check.
|
|
#
|
|
# One line is excluded by name rather than by pattern: Godot reports a resource
|
|
# still in use at exit on every run, and naming it means anything else that
|
|
# turns up is a real finding.
|
|
no_engine_errors() { # no_engine_errors <label> <file>
|
|
local hits
|
|
hits=$(grep -E "^ERROR:" "$2" | grep -vF "resources still in use at exit" | wc -l)
|
|
if [[ "$hits" == "0" ]]; then
|
|
echo " ok $1"
|
|
else
|
|
echo " FAIL $1 ($hits engine error line(s) in $(basename "$2"))"
|
|
grep -E "^ERROR:" "$2" | grep -vF "resources still in use at exit" | head -5 | sed 's/^/ /'
|
|
fails=$((fails + 1))
|
|
fi
|
|
}
|
|
|
|
count() { # count <label> <file> <pattern> <expected>
|
|
local hits
|
|
hits=$(grep -cE "$3" "$2" || true)
|
|
if [[ "$hits" == "$4" ]]; then
|
|
echo " ok $1"
|
|
else
|
|
echo " FAIL $1 (/$3/ matched $hits time(s), expected $4, in $(basename "$2"))"
|
|
fails=$((fails + 1))
|
|
fi
|
|
}
|
|
|
|
refute() { # refute <label> <file> <pattern>
|
|
local hits
|
|
hits=$(grep -cE "$3" "$2" || true)
|
|
if [[ "$hits" == "0" ]]; then
|
|
echo " ok $1"
|
|
else
|
|
echo " FAIL $1 ($hits line(s) matched /$3/ in $(basename "$2"))"
|
|
grep -E "$3" "$2" | head -5 | sed 's/^/ /'
|
|
fails=$((fails + 1))
|
|
fi
|
|
}
|
|
|
|
echo "assertions:"
|
|
check "bot1 authenticated" "$OUT/server.log" "authenticated as account 7001"
|
|
check "bot2 authenticated" "$OUT/server.log" "authenticated as account 7002"
|
|
check "a character was created" "$OUT/server.log" "created 'bot1'"
|
|
check "and persisted to the store" "$OUT/characters.json" "bot1"
|
|
check "and are played" "$OUT/server.log" "playing 'bot1'"
|
|
# The bots press interact and the use key while in a dungeon, so the item half
|
|
# of the input frame crosses a real socket on every run. What they almost never
|
|
# do is produce a drop to act on -- that end of it is tools/diag_loot.tscn.
|
|
check "inventories reach the save file" "$OUT/characters.json" "\"inventory\""
|
|
check "a dungeon instance opened" "$OUT/server.log" "opened dungeon instance"
|
|
# Which arena a run gets is a coin flip on its seed, so this only asserts that
|
|
# SOME boss spawned and that it is one the content knows about -- which is what
|
|
# catches an arena wired to a boss id that does not resolve.
|
|
check "a boss spawned" "$OUT/server.log" "BOSS_SPAWNED (warden|cantor) in instance"
|
|
# Bots pick their entrance from their account id, so a run with several of them
|
|
# opens one of each kind. This is what catches a portal wired to the wrong
|
|
# dungeon, or an instance matcher that ignores which dungeon was asked for.
|
|
check "the real run opened" "$OUT/server.log" "opened dungeon instance [0-9]+ \(warden_descent\)"
|
|
check "the choir vault opened" "$OUT/server.log" "opened dungeon instance [0-9]+ \(choir_vault\)"
|
|
check "the proving grounds opened" "$OUT/server.log" "opened dungeon instance [0-9]+ \(proving_grounds\)"
|
|
check "and each ended with its own boss" "$OUT/server.log" "BOSS_SPAWNED cantor in instance"
|
|
check "emergency escape completed" "$OUT/server.log" "escaped to lobby"
|
|
check "bot1 reached a dungeon" "$OUT/bot1.log" "entered instance .*DUNGEON"
|
|
check "bot1 returned to the lobby" "$OUT/bot1.log" "entered instance .*LOBBY"
|
|
check "a hard drop is channelled, not instant" \
|
|
"$OUT/server.log" "dropped in instance [0-9]+, channelling out"
|
|
check "the dropped body is released" "$OUT/server.log" "released from instance [0-9]+ after drop"
|
|
check "a polite disconnect leaves too" "$OUT/leavebot.log" "BOT_GRACEFUL_LEAVE"
|
|
check "a polite disconnect is also channelled" \
|
|
"$OUT/server.log" "'leavebot' dropped in instance [0-9]+, channelling out"
|
|
# No assertion for upgrades here on purpose. Bots never walk to the
|
|
# quartermaster, so anything written would either duplicate a check above or
|
|
# assert nothing at all. The upgrade-state message DOES cross the wire on every
|
|
# login, so a malformed one shows up in the script-error refutations below --
|
|
# and tools/diag_upgrades.tscn covers the loop itself.
|
|
check "a duplicate account is refused" "$OUT/server.log" \
|
|
"claimed account 7001, already held by peer"
|
|
check "and the newcomer is the one told no" "$OUT/twinbot.log" \
|
|
"that account is already playing"
|
|
count "the account was only ever seated once" "$OUT/server.log" \
|
|
"authenticated as account 7001" 1
|
|
# The victim keeps playing: refusing the second connection must not disturb the
|
|
# session that already held the account.
|
|
check "the original session survives it" "$OUT/bot1.log" "entered instance .*LOBBY"
|
|
|
|
refute "no server script errors" "$OUT/server.log" "SCRIPT ERROR|Parse Error|USER ERROR"
|
|
refute "no client script errors" "$OUT/bot1.log" "SCRIPT ERROR|Parse Error|USER ERROR"
|
|
no_engine_errors "no engine errors on the server" "$OUT/server.log"
|
|
no_engine_errors "no engine errors on a client" "$OUT/bot1.log"
|
|
|
|
echo
|
|
if [[ $fails -eq 0 ]]; then
|
|
echo "SMOKE PASS"
|
|
else
|
|
echo "SMOKE FAIL ($fails)"
|
|
echo "--- server.log (tail) ---"; tail -40 "$OUT/server.log"
|
|
echo "--- bot1.log (tail) ---"; tail -40 "$OUT/bot1.log"
|
|
fi
|
|
exit $fails
|