Skip to content

Feature/enemy vehicles - #85

Merged
over2take merged 10 commits into
mainfrom
feature/enemy-vehicles
Aug 17, 2026
Merged

Feature/enemy vehicles#85
over2take merged 10 commits into
mainfrom
feature/enemy-vehicles

Conversation

@over2take

Copy link
Copy Markdown
Owner

Summary

Test plan

  • Tested locally
  • Tests pass

Pre-merge checklist

Code quality:

  • Code follows project style
  • No breaking changes (or clearly documented)
  • No console errors or warnings

Version & Release:

  • Version bumped? If releasing to users, update:
    • frontend/package.json version
    • docker-compose.yml APP_VERSION
    • CHANGELOG.md with release notes
  • GitHub Actions will auto-tag Docker images with the new version

Before merging to main:

  • All tests passing
  • PR reviewed and approved
  • Branch is up to date with main
  • No merge conflicts

Related issues

Developer 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.
@over2take
over2take merged commit a8aa5f8 into main Aug 17, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant