Issue #3616353: Give back a place at the end of the run when the clicked one would be left on its own
The rule from [#3616316] refused opening a hole on the way in and did nothing on the way out. Hold A1, A2 and A3 on the map, click A2 to give it back, and A2 is left empty between the other two — reported from the live map, reproduced with a probe.
What ships: the clicked place is honored when it can be, and otherwise a place at an extremity of the same run goes back instead. No refusal, no move, no exchange. The end state is the one that moving a neighbour into the hole would have reached, without the exchange: the run stays whole, one place shorter, and the place they clicked stays with the party.
It behaves exactly as though the booker had clicked that end themselves, and by construction rather than by imitation: the swap repoints the booking before BookingManager::release() is called, so the release, the event it announces, SessionPlaceSubscriber's update of the session's own-writes record, the order resync and the released list the map redraws from all describe the place that actually went back. Two things are added on top, both necessary: the clicked place is reported in booked, because the map greys it optimistically the instant it is clicked and would otherwise keep drawing it free until the next full read; and a dialog, because something happened that was not asked for.
We gave back one of your end places instead, so yours stay side by side. A single empty place is rarely booked.
The second sentence is the reason the refusal on the way in already gives, so a booker meets one explanation whichever way they hit the rule. The dialog is a native <dialog>, so the focus trap, Escape, the backdrop and the return of focus come from the platform, and the sentence is announced as well as shown.
The ranking. An extremity wins even where it leaves a place on its own too, which needs the run boxed in by other bookings and the row's edge: both answers leave a single place then, and only that one keeps the party whole, a party split around a hole being the worse of the two. Between two extremities the one leaving the larger stretch wins, which decides anything only where the venue asks for more than two, and the nearer to the click settles the rest so the booker keeps the places closest to the one they pointed at.
Where the code went. PlaceOrphans::releaseInstead(), because that class already knows what an acceptable leftover looks like and this is the same measurement read the other way round. The policy read moves out of VenuePlacementProvider into PlaceSpacingRule, so the map and the seating strategy cannot disagree about the number. The strategy itself is untouched: this is not a re-place, so "a chosen place is never moved" still holds everywhere, which is also why consolidate() was not used — it refuses outright as soon as any anchor was chosen, and every place clicked on a map is.
Tests
PlaceOrphansTest goes to 18: an end honored as clicked; the middle of three giving back an end; a place against the row's edge giving back the far end; a boxed-in run still giving back an end; the larger stretch winning between two ends at a minimum of three; the click standing with no rule attached; and a place the booker does not hold returned unchanged.
Green locally with OrphanedPlacesTest, SeatWithoutOrphansTest, PlaceBookingTest, SlotBookingPlacementTest, VenuePlacementProviderTest and PlaceHoldCostTest. phpcs and phpstan clean, run after the last edit.
Docs: a "Giving a place back" section in docs/seating.md. French translation included.
AI-Generated: Yes (Claude Code wrote the code, its tests and this description; the maintainer found the defect on the live map and settled the design, including the ranking and the message.)