b351bc2d55
ci / verify (push) Successful in 48s
Every level banks one choice. Choices queue, and are spent at an NPC in the
hub: walk to it, press E, take one of three weighted options. Seven upgrades,
all data — split shot, glass cannon, spread, sniper, doubleshot, poison,
eraser — and SimWorld gained no per-upgrade branch to run any of them.
The four ambiguities in the brief were settled with the user first, since
each changes what gets written:
damage base x (1 + sum additive) x product multiplicative. The flat
+5% every upgrade carries, spread's -10%, doubleshot's -50%
and glass cannon's +100% pool; sniper multiplies on top, so
two snipers is 4x and not +200%.
glass half the LEVELLED maximum, multiplying if taken twice, so the
price does not fade to a rounding error by level 15.
poison independent stacks, not a refresh.
split +/-45 degrees from the original heading.
Independent poison stacks sound expensive and are not: every dose lasts the
same number of ticks, so doses expire in the order they were added, the
pending expiries are a plain FIFO, and PoisonTrack only ever looks at its
front. O(1) per actor per tick however many are live.
Stats are derived from the upgrade list and never stored, the way level is
derived from experience -- a saved stat cannot disagree with the upgrades
that produced it. Upgrade riders (split charges, poison, erase chance) travel
on the bullet instead, because a shot in flight has to keep what it was fired
with rather than gaining Poison because the shooter just took it.
Two invariants this collided with, both now pinned:
- bullet speed gained a ceiling. Wall collision samples once per tick, so
anything over a tile per tick tunnels; two snipers asked for 2480 u/s
against a 1920 threshold, and a tunnelling bullet looks like a bullet.
- BULLET_INTEREST_RADIUS rose to 2900, because an upgraded player shot is
now the longest-travelling bullet in the game. test_interest measured
the worst case from static content, which upgrades quietly invalidated.
Choosing is intent checked three ways: a choice must be owed, the index must
name one of the three options the SERVER put on the table, and the player
must be standing at the NPC. The offer is rolled once and persisted, so
closing the screen is not a reroll and neither is a crash.
tools/diag_upgrades.tscn covers level -> banked choice -> refused in a
dungeon and refused across the room -> taken at the NPC -> new stats ->
on disk. Bots never walk to the quartermaster, so the smoke test cannot.
Known gap recorded in the roadmap: at PLAYER_BULLET_DAMAGE = 6, the +5% the
first upgrade carries rounds back to 6 and visibly does nothing. It comes out
right in aggregate, but the fix is a balance edit across content.gd and so is
the user's call.
check.sh clean, 357 tests, SMOKE PASS (18 assertions), all four diagnostics
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
154 lines
6.5 KiB
Bash
Executable File
154 lines
6.5 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 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
|
|
}
|
|
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"
|
|
# 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 proving grounds opened" "$OUT/server.log" "opened dungeon instance [0-9]+ \(proving_grounds\)"
|
|
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.
|
|
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"
|
|
|
|
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
|