Feature/enemy vehicles - #85
Merged
Merged
Conversation
added 10 commits
August 16, 2026 23:11
Enemy vehicles and cyberpunkred.com linking. A point release on the same reasoning as 1.9.1: this adds to the vehicle feature rather than introducing one. If the character-code import is judged a new capability instead, this should be 1.10.0.
Nothing needed storing. NPC sheets already render the whole template, vehicle section included, and already live in folders - so an enemy vehicle typed on one has persisted from session to session since before this existed. What was missing was a read: every roster query filtered is_npc = 0. A sibling module rather than a branch inside vehicleState, because the key differs. Everything on the player path is keyed by username - a rider names the owner's login, occupancy is written onto that player's own sheet - and NPC sheets have none. Generalising that key is the correct fix and the wrong size: it would touch seatIn, seatOut, the roster and the token mirror to change how the table's own seating works mid-campaign. Grouped by the NPC's folder, since a GM with a campaign of antagonists is looking for the gang's van rather than scrolling one flat list. Tests pin the seam in both directions: neither roster shows the other's vehicles or crew, and the enemy path refuses a player's sheet id outright.
Three admin-gated socket actions, a roster hook, and a sibling window. Reached from the sidebar and from a token menu, both admin-only. The roster is asked for rather than pushed, and refused to anyone but the GM, so a player's client never holds enemy pools or armour at all. That is what keeps 'what may players see' from being a question this feature has to answer - attackers still get the cover badge on the token, which already worked. A sibling window rather than a mode of the player one, matching the server split: these are keyed by NPC sheet id, and sharing the component would mean threading two shapes of occupant through every dropdown to save a diagram. The geometry and the colours are imported, though, so a car reads the same on both and a hull at a quarter looks as urgent here as there. Grouped by the NPC's folder. The empty state names the NPC sheet and says they persist between sessions, since that is the first thing a GM meets and the part that is not obvious. The diagram is read-only for now: seating an NPC needs an occupancy field keyed by sheet id where the player path spells it as a username.
A player who had not added a vehicle of their own saw an empty VEHICLES window even with other people's cars on the roster. The client asks for the roster as soon as it knows the game system, which is before the user logs in - so the request arrives unidentified and is dropped, by design. The identify-time push exists to cover exactly that, and it was still gated to CWN and still called roster() with no system, so under Cyberpunk it either never fired or read CWN sheets. Nothing else refreshed the window until somebody happened to save a sheet. Mine, from widening the gates in 1.9.1: I updated the request handler and missed the push. Audited the rest. One more had the same shape - the import-apply resync was gated correctly but mirrored the wrong system. The damage-path syncAll is genuinely CWN-only, since Cyberpunk routes no damage through a vehicle, and now says so rather than looking like a third instance. Two tests pin the default, because omitting the argument is silent: a Cyberpunk roster and mirror both come back empty without it. The socket wiring itself has no test harness, so that layer is still covered by reading rather than by tests.
Keyed by the token rather than by a character sheet, which is the better model and replaces the sheet-id plan: an enemy token need not have a sheet at all - a nameless ganger in the back of a van is a location row and nothing else - and the token is the thing that moves, so filtering by map becomes a join rather than a guess. A new vehicle_occupants table, UNIQUE on location_id: "a token is in one seat" expressed once, in the place that cannot drift. Both foreign keys cascade, so a ganger deleted from the map is not still sitting in a van. The seat pickers filter to the map the GM is looking at - city map, or a battle map floor - because running a rooftop fight should not mean scrolling past every ganger in the city. The clause tests battle_map_id IS ? rather than = ?, since comparing with = NULL matches nothing in SQL and the city map is exactly the null case. Only the GM's own tokens are offered, and a player's is refused outright rather than merely hidden: players seat themselves in their own window, and two ways to move one person is how they come to disagree.
Every seat gets a dropdown of the GM's tokens on the map they are looking at, so the diagram is no longer read-only and SEATING TO COME is gone. Friendlies are offered alongside hostiles but tinted blue and prefixed, with a legend saying what the marking means. Both being the same green was the risk worth designing against: putting a body from your own side in a hostile driver's seat is a mistake made in a hurry, and the label alone does not stop it. Emptying a seat sends the token that is leaving rather than just a null. "Nobody is in this seat now" and "this specific ganger got out" are different statements, and only the second can be applied without re-reading the seat. The map level is sent with every ask, so walking onto a battle map re-filters the pickers instead of leaving yesterday's street full of gangers in them.
Friendly tokens on the map level appear in the player window's seat pickers alongside the people at the table. Hostiles are not offered, and a hostile is refused by the server rather than merely absent from the picker - a picker is a suggestion, this is the rule. A seat can now be claimed two ways: a player's occupancy lives on their own sheet, an NPC's in vehicle_occupants. "One seat, one occupant" is enforced across both, in both directions - a person taking a seat turns out the NPC in it, and an NPC turns out the person. Without that a car quietly seats two bodies in one place and nobody notices until the shooting starts. Anyone may turn an NPC out. The "only you can take yourself out" rule exists to protect people's autonomy, and a token has none - so the GM can undo whatever a player has done here, which is the control that makes letting players invite safe in the first place. The table operations are now one shared module rather than two copies: the GM seating enemy tokens and a player inviting a friendly are the same write with a different allowlist of shapes. That also gave every seated token its own shape in the roster, which fixes a latent bug - the window had been colouring passengers by looking them up in a list filtered to the current map, so a friendly who walked off it would have been redrawn as hostile.
Completes what the last commit built the server half of: friendly tokens on the map level appear in the seat pickers beside the people at the table, prefixed and blue, the same blue the GM window uses so the colour means one thing everywhere. Seat values are prefixed p: or t:. A username and a token id are both strings in a dropdown, so a player called "12" would otherwise be indistinguishable from token 12 - and the failure mode is seating the wrong body, silently. Emptying a seat with an NPC in it turns the NPC out with no permission check, where emptying a person's seat still asks whether it is yours. That asymmetry protects autonomy, and a token has none - which is what lets the GM undo an invite they dislike. A rider who has since left the map level keeps an option marked OFF MAP. Without it their seat reads as empty and the next change to that seat turns them out without anyone meaning to. The player roster now takes the map level too, so both windows filter the same way rather than one offering every friendly in the city.
Every new file audited against the README rather than eyeballed, after a silent no-op replace slipped one past last time: seven added files, seven entries. The local checklist described seating as sheet-id-keyed, which was the plan before the token-keyed model replaced it. Corrected to record what was built and why the plan changed, rather than leaving a stale design doc that reads as current.
The select is tinted by whoever is sitting in it, and an option that states no colour of its own inherits that - so one NPC aboard turned every player in the dropdown blue too, which is the opposite of what the tint is for. Every option now states its own colour. The GM window's EMPTY option had the same gap. The test asserts the inline style rather than the computed one. jsdom does not resolve inheritance, so a computed-colour check passed whether the bug was present or not - the first version of this test proved nothing, and only sabotaging the fix showed it. What is checkable, and what actually prevents the bug, is that every option carries a colour.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Test plan
Pre-merge checklist
Code quality:
Version & Release:
frontend/package.jsonversiondocker-compose.ymlAPP_VERSIONCHANGELOG.mdwith release notesBefore merging to main:
Related issues