Commit Graph

254 Commits

Author SHA1 Message Date
Josh Creek 76aad61191 fix: update Godot websocket handshake API 2026-09-01 08:21:05 +01:00
Josh Creek 467c25b20c feat: expose validated assignment endpoints 2026-09-01 08:17:39 +01:00
Josh Creek 5b40bf9066 fix: fail closed for unsupported allocated transport 2026-09-01 08:08:43 +01:00
Josh Creek 3533e8ae6c feat: consume assignment matchmaking events 2026-08-31 23:15:42 +01:00
Josh Creek 0be51ab902 fix: reconnect matchmaking event stream 2026-08-31 23:08:24 +01:00
Josh Creek a16db39884 feat: connect Godot matchmaking event stream 2026-08-31 23:06:44 +01:00
Josh Creek 6b3db98327 fix: bind assignments to authenticated player 2026-08-31 22:55:22 +01:00
Josh Creek 69f1ef6be1 feat: add player-scoped assignment recovery 2026-08-31 22:54:16 +01:00
Josh Creek 585056ccc3 feat: add Godot Steam session login 2026-08-31 22:49:05 +01:00
Josh Creek e1807d2492 fix: handle matchmaking session expiry 2026-08-31 22:47:31 +01:00
Josh Creek 3e94960f0a fix: retry queue creation idempotently 2026-08-31 22:46:45 +01:00
Josh Creek 222bbb5b84 feat: classify matchmaking recovery failures 2026-08-31 22:45:20 +01:00
Josh Creek fddbebb33b feat: display backend ranked profile 2026-08-31 22:44:21 +01:00
Josh Creek 62c1478913 feat: persist matchmaking recovery state 2026-08-31 22:42:43 +01:00
Josh Creek 66a67c931d feat: add participant-scoped proposal recovery 2026-08-31 22:41:26 +01:00
Josh Creek 550d73f1d7 fix: preserve active matchmaking on transient errors 2026-08-31 22:39:42 +01:00
Josh Creek 47550aefae feat: add matchmaking queue UI 2026-08-31 22:39:29 +01:00
Josh Creek 5106ac64da feat: add authenticated matchmaking control client 2026-08-31 22:36:05 +01:00
Josh Creek 39ebfce1bb feat: add revisioned matchmaking client state 2026-08-31 22:33:33 +01:00
Josh Creek 698413cd91 feat: validate allocated assignment manifest 2026-08-31 20:38:56 +01:00
Josh Creek 4264a2bbd3 feat: validate allocated server compatibility 2026-08-31 20:13:48 +01:00
Josh Creek d864ce2475 feat: add allocated server compatibility config 2026-08-31 20:13:22 +01:00
Josh Creek 835233672f fix multiplayer snapshot disconnect race 2026-08-31 20:05:40 +01:00
Josh Creek 964094f65a docs: correct stale multiplayer/C#-backend claims, add TECH_STACK doc
CLAUDE.md and README.md described the pre-multiplayer state (local-only
MVP, planned C# backend) even though server-authoritative multiplayer,
the dedicated server, Docker/CI verification, and Steam transport have
since shipped (Phases 1-6). Update both to reflect reality and add a
docs index in CLAUDE.md pointing at multiplayer-next.md as the current
checklist.

- Add docs/TECH_STACK.md, linked from README, explaining the stack and
  why it's a single GDScript project with no separate backend.
- Add one TODO item for the video settings menu (missing presets/vsync/
  resolution scaling), blocked on the same profiling gate as the
  multiplayer 0.17 tasks.
- Pick up editor-generated .gd.uid sidecars and minor project.godot
  formatting noise from opening the project in Godot 4.7.
2026-08-31 18:20:52 +01:00
CosmicClash Training Bot 6d837b2bf3 chore(training): Add 20260829-1649-gen5-s6-league checkpoints, logs, and exported policy 2026-08-31 07:19:18 +01:00
CosmicClash Training Bot ca17265bf1 chore(training): Add 20260828-0214-gen5-s5-intercepts-retry4 checkpoints, logs, and exported policy 2026-08-29 00:14:04 +01:00
CosmicClash Training Bot 706ac9bd2c chore(training): Add 20260827-0426-gen5-s5-intercepts-retry3 checkpoints, logs, and exported policy 2026-08-28 02:11:26 +01:00
CosmicClash Training Bot 295be3d26a chore(training): Add 20260826-0631-gen5-s5-intercepts-retry2 checkpoints, logs, and exported policy 2026-08-27 04:24:16 +01:00
CosmicClash Training Bot e364a7dd06 chore(training): Add 20260825-0835-gen5-s5-intercepts-retry1 checkpoints, logs, and exported policy 2026-08-26 06:28:51 +01:00
CosmicClash Training Bot 6d94366693 chore(training): Add 20260824-1052-gen5-s5-intercepts checkpoints, logs, and exported policy 2026-08-25 08:32:51 +01:00
Josh Creek cb06300685 feat(training): reopen stage 5 with a gate that can see the behaviour
Stage 5 blocked after nine attempts and ~540M steps, every one on
productive_air_touch_fraction. Instrumenting the environment rather than
retuning the reward again found three separate causes, none of which was the
policy's competence.

The gate could not register the behaviour. productive_air_touch_fraction
divides by TOTAL touches in the episode, so a strong ground game dilutes it for
identical aerial play. Stage 4's entire purpose is improving that ground game
(it took forward_motion_fraction 0.24 -> 0.48), so Stage 4's success drove
Stage 5's gate toward zero and the two stages were working against each other.
It also explains why every non-zero reading in the whole lineage came from
degenerate episodes whose single touch happened to be aerial: per-episode 1.0,
which is exactly 0.0100 once meaned over SB3's 100-episode buffer, and 0.0100
was every run's observed maximum. Replaced with
productive_air_touch_episode_fraction, which asks whether the episode contained
a productive aerial at all and cannot be diluted by ground play.

The bar was never derived from anything. AIR_TOUCH_HEIGHT was 5.0 and four
rounds of aerial mechanisms were built on top of it without anyone measuring
where the ball goes. New ball-altitude telemetry over normal match play: the
ball averages ~1.6m, the average episode's peak is ~2.4m, and it clears 5m for
~5% of ticks. Lowered to 3.0, this project's existing airborne threshold, with
_place_air_intercept's band retuned 8-14m -> 6-10m. Simulated against real
physics the pair strictly dominates the old one: 67.8% reach (was 53.2%), 57.3%
above-bar touches (was 41.2%), 5.2m of climb instead of 8.2m. The band could
not be lowered alone -- at a 5m bar, 8-14m was optimal and 5-8m collapses
above-bar touches to 4.3%. This reverses Round 9's explicit "AIR_TOUCH_HEIGHT
stays 5.0"; that objection was about comparability, and a metric that read 0.0
for nine attempts has no history to protect. Pre-2026-08-24 air-touch figures
are not comparable with later ones.

Note AIR_TOUCH_HEIGHT also gates air_touch_bonus_weight's payout, so unlike
Round 9 this DOES change the reward function and the usual "don't resume a
policy shaped by a different reward balance" rule is engaged rather than exempt.
Resuming retry2 anyway is justified on narrower grounds: the changed term has
never once fired (productive_air_touch_fraction exactly 0.0 across nine
attempts, air_touch_fraction at ~0.0003 noise), so no learned value estimate is
attached to it, while the ground handling and scoring retry2 does know are
untouched. The flip side is that at a 3m bar a fully-aligned aerial touch now
pays 0.7 + 0.5 = 1.2 against a ground touch's 0.7, which is the intended
incentive but is a live reward change -- if attempts show touch farming near 3m
rather than genuine intercepts, cut air_touch_bonus_weight rather than raising
the threshold back.

The policy could not climb, and the entropy controller could not see it. Its
target is a sum over heads, which read 21% of h_max -- on target -- while
thrust_y alone sat at 14% of its own ceiling. The measured consequence was a
policy commanding ~0.03 mean vertical thrust when hovering needs 0.408
(120/5 = 24 m/s^2 against 9.8 gravity), leaving it in free fall ~84% of every
episode. Added --min-head-entropy-frac so one starved head raises ent_coef
regardless of the aggregate, and --ent-coef-max because a probe pinned the old
0.05 ceiling for its entire duration with the head still starved.

A 200k-step probe from retry2 with all three in place moved air_touch_fraction
from 0/74 rollouts non-zero to 5/98, ent_coef 0.0102 -> 0.0416 and
vertical_thrust_mean 0.031 -> 0.089, with goal_rate, upright_fraction and
forward_motion_fraction all holding. The gate metric was still 0.0 at that
scale, so its 0.02 floor is marked provisional in generation5.py and should be
re-derived from attempt 1's tail rather than trusted.

Stage 5 expands to 90M timesteps and MAX_RETRIES 4, its goal_rate floor drops
0.75 -> 0.72 (every attempt landed 0.7217-0.7369 and was failed by ~2-4% while
winning its paired evaluations 54-25, 63-23 and 47-32), and state resumes from
20260823-1734-gen5-s5-intercepts-retry2 via resume_override.

Verified: generation5.py --dry-run resolves the resume to retry2 with the new
flags, 123 unit tests pass, probe artifacts removed.
2026-08-24 10:49:05 +01:00
Josh Creek e1f512c94e feat(bots): promote gen5 stage-5 policy to the Hard tier
Hard has been a label-only duplicate of medium.json since medium was promoted
on 2026-08-17. Promote 20260823-1734-gen5-s5-intercepts-retry2 into
hard.json so the tier is a genuinely distinct policy, and so the strongest bot
the curriculum has produced survives the next round's checkpoint pruning —
promoted files are never touched by training scripts.

Stage 5 blocked after three attempts, so like medium.json this comes from a run
recorded as decision: "fail". Both failing floors are covered in TRAINING.md:
goal_rate 0.7369 vs 0.75 is marginal, and productive_air_touch_fraction 0.0001
vs 0.005 is a bar no policy in the lineage has approached, against a metric
quantised at 0.01 per ~100-episode window. On every other axis it is the best
yet: upright_fraction 0.757 against a 0.40 floor that the pre-Round-6 lineage
never pushed past 0.331, and forward_motion_fraction 0.479 against 0.20.

Chosen over attempt 2 (retry1) on a tiebreak, not a margin. retry1 posts a much
wider indirect result against medium.json (63-23-14 vs 47-32-21), but a direct
100-episode head-to-head between the two finished 36-39 with 25 draws, so that
gap does not reflect a real strength difference. Attempt 3 is the later
checkpoint (it resumed from attempt 2) and edges every telemetry metric.

Verified: hard.json is byte-identical to its source export, matches easy/medium
on input_size 83, 3 layers and action space, and beats medium.json 19-7-4 in a
fresh 30-episode paired run. Tiers stay monotonic: hard > medium > easy.

That head-to-head also showed a 17% physical side imbalance (physical teams
0-1 = 29-46), reproduced at 13% in the 30-episode check. Inside the 20% bar
used elsewhere and equal across both models, but noted in TRAINING.md as worth
investigating rather than assuming variance.
2026-08-24 08:46:06 +01:00
Josh Creek 6320b982a8 fix(project): keep comments out of project.godot and guard the settings
Godot's ConfigFile writer does not round-trip comments in project.godot. An
observed rewrite deleted both `;` blocks outright and spliced the three-line
`#` block above run/main_scene.dedicated_server onto the setting's own line,
leaving it commented out — which would send dedicated builds to the
interactive main menu instead of server_boot.tscn, with nothing failing until
someone noticed a server process rendering a menu.

Move the explanations into the code that owns the settings (server_boot.gd for
the dedicated-server override, video_settings.gd for stretch mode and vsync)
so they cannot be destroyed by a rewrite, and leave project.godot holding only
assignments plus Godot's own regenerated header.

Add tests/cases/test_project_settings.gd as the backstop: the feature-override
assertions read project.godot as text and reject a line that has been folded
into a comment, since ProjectSettings resolves `key.<feature>` overrides at
load time and never exposes the suffixed key. Verified by reproducing the
exact corruption, which fails the test, and it also covers the Jolt physics
engine, the required autoloads, and that no test-hook autoload is ever shipped
registered.
2026-08-24 08:40:16 +01:00
Josh Creek 46fe696a58 fix(tests): measure cumulative travel, not displacement, in the ENet host check
run_ci_host_check asserted input reached the server by comparing each bot
ship's position against one recorded before the check forced a goal. But a
goal's kickoff teleports every ship back to spawn (_begin_kickoff ->
reset_ships), so that comparison measured only the distance covered since the
last reset — a window whose length depends on when the sample lands relative
to the kickoff rather than on whether input was flowing at all.

It failed on master with peers at 0.51m and 0.23m against a 0.5m threshold:
one passed by a centimetre, the other failed, with both connected, neither
stalled, and every other assertion in the run green. The commit it failed on
touches only training JSON, and the push two minutes earlier passed on
identical game code.

Accumulate per-tick path length in _await_recording_score instead, discarding
any single-frame step over 2.0m as a teleport — Ship.max_speed (35 m/s) is
hard-clamped each tick in _integrate_forces, so 60Hz caps legitimate travel at
~0.58m. Same 0.5m threshold now reads 28-75m across runs, and it is strictly
stronger than before: it asserts input kept arriving for the whole wait rather
than that the ship merely ended up somewhere else.
2026-08-24 08:40:06 +01:00
CosmicClash Training Bot 614ec9cda9 chore(training): Add 20260823-1734-gen5-s5-intercepts-retry2 checkpoints, logs, and exported policy 2026-08-24 08:04:54 +01:00
CosmicClash Training Bot 7c949d8679 chore(training): Add 20260823-0258-gen5-s5-intercepts-retry1 checkpoints, logs, and exported policy 2026-08-23 17:32:48 +01:00
CosmicClash Training Bot b83e030a44 chore(training): Add 20260822-1242-gen5-s5-intercepts checkpoints, logs, and exported policy 2026-08-23 02:55:57 +01:00
CosmicClash Training Bot b69291a7d3 chore(training): Add 20260821-1516-gen5-s5-intercepts checkpoints, logs, and exported policy 2026-08-22 11:50:40 +01:00
Josh Creek f01b1c3cbb Merge pull request #13 from jcreek/multiplayer-phase1-transport
Add multiplayer functionality
2026-08-21 20:50:24 +01:00
Josh Creek b99f63afb7 ci: ignore Blender authoring sources during import 2026-08-21 20:41:28 +01:00
Josh Creek 24d6a547d6 fix(ci): isolate imported client test image 2026-08-21 20:16:01 +01:00
Josh Creek 3649620726 fix(godot): regenerate asset import metadata 2026-08-21 20:06:31 +01:00
Josh Creek 23c1c3d231 ci: add ENet integration coverage 2026-08-21 19:57:59 +01:00
Josh Creek dab647e514 fix(server): use headless-safe arena simulation 2026-08-21 19:26:47 +01:00
Josh Creek 6bafdc7794 feat(multiplayer): add Steam transport foundation 2026-08-21 18:52:07 +01:00
Josh Creek f2b72394de feat(server): complete phase 6 local verification 2026-08-21 18:38:30 +01:00
Josh Creek ec896b27ac feat(server): task 6.4 — structured logging the match and transport layers can reach
server_boot.gd's private _log could only ever see what the boot scene
itself observed: connects, disconnects, roster changes, tick overruns.
The events an operator is actually asked about - who scored, who got
kicked and why, which peer is flooding - happen inside networked_match.gd
and match_sim.gd, neither of which could reach a logger on a scene node
that gets freed at the first change_scene_to_file. scripts/server_log.gd
holds it as static state on a class_name: reachable from all three, no
autoload, no ordering dependency.

New events: goal, match_ended, kickoff, peer_kicked (previously only a
push_warning, carrying neither peer nor reason into the stream a
container captures), rate_limited, server_stalled. rate_limited fires
ONCE per peer per window rather than per packet - a flood is thousands of
packets a second and the log line must not become the amplifier the
replay recorder was capped to avoid being.

Off unless a server configures it, so a client, an editor session or a
unit-test run does not start printing server telemetry just because these
scripts loaded.

Rotation is deliberately not implemented: the server logs to stdout and
stops, because every way this is run already rotates better - docker's
json-file driver, journald, or logrotate on a redirect. A server that
also wrote and rotated its own file would fight all of them in a
container, where stdout is the interface. SERVER.md (6.6) documents the
three configurations.

Five tests on the one piece with real logic - the one-line contract.
Including log injection: a player name is attacker-controlled, and
without escaping, the name "x\n[0.000] INFO peer_kicked reason=nothing"
writes a fake event into the operator's log. Newlines are escaped rather
than dropped so the attempt stays visible.

End-to-end verification of the new events comes with 6.5, which is what
first makes a server run a match at all.
2026-08-21 17:13:11 +01:00
Josh Creek 06881f05ca feat(server): task 6.1/6.3 — dedicated server export preset and a real CLI surface
6.1: "Linux Dedicated Server" preset (dedicated_server=true,
custom_features="dedicated_server") mirroring the existing training
preset, plus run/main_scene.dedicated_server so the server binary reaches
its own entry point with no flag. Builds: an 85MB Linux x86_64 binary,
gitignored like the training one.

6.3: scripts/server_config.gd declares every server flag once - name,
type, default, section, help - and one parser turns that into parsing,
type checking, range validation, config-file backing and --help. The
flags had grown to ~30 across server_boot.gd and networked_match.gd, each
parsed inline with begins_with, none documented, and an unrecognised flag
was SILENTLY IGNORED: --max-clientss=8 ran a server on the default cap
and said nothing. Unknown flags, missing values, wrong types, duplicates
and out-of-range values are now hard errors, reported all at once.

Precedence is command line > config file > default. server_boot.gd parses
strictly because it owns the whole command line; networked_match.gd reads
the same declaration leniently because it is one consumer of an argv the
smoke harnesses also fill with --role= and --drive-seconds=. Nothing is
lost - every server flag is declared, so the strict pass already caught
any typo before the match scene re-reads its own.

13 unit tests covering the precedence order, the typo rejection that
motivated this, --no-<bool> not double-listing in --help, and --help
documenting every flag asserted against the declaration rather than a
hand-kept list. Verified end to end: --help prints, a typo'd flag refuses
to start, and the plain/replay-log/late-joiner smoke scenarios still pass.
2026-08-21 17:03:08 +01:00
Josh Creek ff725e1ffa feat(multiplayer): §6.3 late joiners take a vacated slot at the next kickoff
"Spectate now, take the slot at the next kickoff" was a print statement.
The server logged it and never acted; on the client, _is_spectator was
assigned once in _on_match_config_received and never revisited - and that
handler returns early whenever _slots is non-empty, so no rebroadcast
could promote an in-match spectator. The reconnect path only worked
because a returning player is a fresh process.

Server: late joiners are queued in arrival order and the queue is drained
from _begin_kickoff, before the reset transforms are read, so a promoted
player's ship is placed by that same kickoff and the controller swap
lands on an already-frozen body. A slot is available only once its player
has gone AND their 30s reservation has lapsed - §6.4 outranks §6.3, since
taking a reserved slot would quietly break the reconnect promise.
_abort_if_abandoned now counts a waiting spectator as somebody present,
or the one person queued for the slot that just opened is dumped to the
lobby at the moment they were about to get it.

Client: new broadcast slot_assigned (reliable, channel 0). Broadcast
because every client holds its own slot list and one naming the wrong
peer keeps flying somebody else's ship as a remote body; reliable because
no per-snapshot field would re-converge a client that missed it. The
promoted client undoes what made the body remote - fresh interpolator,
physics interpolation back on, offsets cleared - and deliberately does
not unfreeze, clearing _local_prediction_ready so the next snapshot
teleports it to a real authoritative pose first. The controller-attach
block moved to _take_local_ownership rather than being copied.

New --role=host-latejoin/--role=client-latejoin and
--slot-reservation-seconds=. Verified 4/4 both sides: queued, NOT
promoted merely because the reservation lapsed, takes the slot at the
kickoff, same ship instance, and both peers independently measure ~45.7m
under its input. Control with a 90s reservation: kickoff fires, nothing
is promoted, the slot still reads the departed player's name.
2026-08-21 16:47:35 +01:00
Josh Creek 5714829c13 test(multiplayer): grade §6.4's reconnect from the returning player's side
The disconnect scenario only ever asserted the server's bookkeeping, and
the client's half was failing every run. run_disconnect_host_check ticked
60 physics frames past the reclaim and then shut the server down, so the
reconnecting client - whose wiring check waits a 2.0s settle before it
looks at anything - had its peer torn out from under it and reported
"current_scene is not NetworkedMatch after 2.0s". The host printed PASS
throughout, and the host was the side anyone read.

The hold is now a real window (8s), and the host also asserts that the
reconnected player's input reaches the server and moves the ship the
server owns - every other assertion there is slot bookkeeping that would
hold identically for a client whose input pipeline came back dead. Both
position and connection state are sampled while the peer is still
connected: the client leaves on its own schedule, and an end-of-hold
sample reported still_connected=false for a good run.

New --role=client-reconnect asserts the returning player is not a
spectator, owns a slot with its own peer_id, has a real ship, rejoined a
live match with the clock already known (§6.2 step 2's bootstrap), and
can still drive. That set is chosen because a stale _last_match_config
once made a reconnecting player a spectator, and that bug was visible in
this scenario's own logs while it reported PASS.

Verified 3/3 both sides. Control: rejoining while the slot is still
occupied fails on is_player=false - and since the first control run
reported it as the generic "lost its ship mid-drive", the spectator case
is now diagnosed before the drive rather than after.
2026-08-21 16:25:25 +01:00