diff --git a/voidcrew/edits/ship_access.dm b/voidcrew/edits/ship_access.dm index e93619ab199..fdc8fa05b19 100644 --- a/voidcrew/edits/ship_access.dm +++ b/voidcrew/edits/ship_access.dm @@ -92,6 +92,12 @@ * than on the /obj/machinery/door override above is deliberate: it runs ahead of * `emergency` and `unres_sides`, neither of which should punch a hole in a lock the * captain deliberately set. + * + * The lock refuses NPCs too. Most ship doors carry no mapped access, so the NPC rule + * above never touches them, and before this a pirate boarding party walked through a + * crew-locked hull's airlocks while its players were turned away. A clientless mob gets + * through only if it is that ship's crew by is_npc_ship_crew(). Bots and silicons stay on + * the upstream path, as with the NPC rule above. */ GLOBAL_LIST_EMPTY(crew_locked_ships) @@ -108,18 +114,19 @@ GLOBAL_LIST_EMPTY(crew_locked_ships) /obj/machinery/door/proc/refused_by_ship_crew_lock(mob/user) if(!length(GLOB.crew_locked_ships)) return FALSE - // Players only. Clientless mobs are covered by the NPC rule above, and bots and - // silicons stay on the upstream path so their own credentials are still read. - if(!user || !GET_CLIENT(user)) + if(!user) return FALSE + // Bots and silicons stay on the upstream path so their own credentials are still read. if(isbot(user) || HAS_SILICON_ACCESS(user)) return FALSE - var/area/shuttle/voidcrew/ship_area = get_area(src) - if(!istype(ship_area)) + var/has_client = !!GET_CLIENT(user) + if(!has_client && !isliving(user)) return FALSE - var/obj/structure/overmap/ship/ship = ship_area.shuttle_port?.current_ship - if(isnull(ship) || !ship.crew_only_airlocks) + var/obj/structure/overmap/ship/ship = crew_locked_ship() + if(isnull(ship)) return FALSE + if(!has_client) + return !ship.is_npc_ship_crew(user) if(ship.is_ship_crew(user)) return FALSE if(TIMER_COOLDOWN_FINISHED(user, "ship_crew_lock_refusal")) @@ -127,6 +134,61 @@ GLOBAL_LIST_EMPTY(crew_locked_ships) to_chat(user, span_warning("This airlock is keyed to the [ship.name]'s crew.")) return TRUE +/** + * The crew-locked ship this door belongs to, or null. Only airlocks and windoors carry the + * lock. Costs one list read while no ship has the lock on. + */ +/obj/machinery/door/proc/crew_locked_ship() + if(!length(GLOB.crew_locked_ships)) + return null + if(!istype(src, /obj/machinery/door/airlock) && !istype(src, /obj/machinery/door/window)) + return null + var/area/shuttle/voidcrew/ship_area = get_area(src) + if(!istype(ship_area)) + return null + var/obj/structure/overmap/ship/ship = ship_area.shuttle_port?.current_ship + if(isnull(ship) || !ship.crew_only_airlocks) + return null + return ship + +/** + * Whether a mob with no player in it counts as this ship's crew for the crew lock: + * - its mind is on the roster (a crewmember's body with nobody in it right now), + * - it is a stray who joined this ship, aboard or following the crew there, + * - or it is an animal one of the ship's crew has befriended, like a tamed pet. + * Everything else, pirate boarders included, is not crew. + */ +/obj/structure/overmap/ship/proc/is_npc_ship_crew(mob/living/npc) + if(is_ship_crew(npc)) + return TRUE + if(istype(npc, /mob/living/basic/ambient_npc/stray)) + var/mob/living/basic/ambient_npc/stray/stray = npc + return !stray.is_wild() && stray.get_ship() == src + var/list/friends = npc.ai_controller?.blackboard[BB_FRIENDS_LIST] + for(var/mob/friend as anything in friends) + if(!QDELETED(friend) && is_ship_crew(friend)) + return TRUE + return FALSE + +/** + * Whether `door` stays shut for an NPC that carries no access: it has mapped access, or its + * ship's crew lock is on and `npc` is not that ship's crew. Without `npc`, any crew lock + * counts, which is right for planning a boarding party's route. + * + * Every NPC door planner asks this to decide which doors to break instead of open, so it + * has to agree with what allowed() does for an NPC at that door. + */ +/proc/door_refuses_npc(obj/machinery/door/door, mob/living/npc) + if(QDELETED(door)) + return FALSE + if((istype(door, /obj/machinery/door/airlock) || istype(door, /obj/machinery/door/window)) \ + && (LAZYLEN(door.req_access) || LAZYLEN(door.req_one_access))) + return TRUE + var/obj/structure/overmap/ship/ship = door.crew_locked_ship() + if(isnull(ship)) + return FALSE + return isnull(npc) || !ship.is_npc_ship_crew(npc) + /obj/machinery/door/airlock/allowed(mob/user) if(refused_by_ship_crew_lock(user)) return FALSE @@ -154,11 +216,8 @@ GLOBAL_LIST_EMPTY(crew_locked_ships) /// Whether a crew-locked ship's door must stay shut for this item, which was thrown by /// someone who is not crew, or by nobody at all. /obj/machinery/door/proc/refuses_thrown_item(obj/item/thrown) - var/area/shuttle/voidcrew/ship_area = get_area(src) - if(!istype(ship_area)) - return FALSE - var/obj/structure/overmap/ship/ship = ship_area.shuttle_port?.current_ship - if(isnull(ship) || !ship.crew_only_airlocks) + var/obj/structure/overmap/ship/ship = crew_locked_ship() + if(isnull(ship)) return FALSE var/mob/thrower = thrown.throwing?.get_thrower() return isnull(thrower) || !ship.is_ship_crew(thrower) diff --git a/voidcrew/modules/npc_ships/code/mob_patrol.dm b/voidcrew/modules/npc_ships/code/mob_patrol.dm index 0af98dc6d9e..6865b6ce6ad 100644 --- a/voidcrew/modules/npc_ships/code/mob_patrol.dm +++ b/voidcrew/modules/npc_ships/code/mob_patrol.dm @@ -246,10 +246,10 @@ PATROL_LOG("FAILED: Path too short ([length(path)] doors)") return null - // Also build a list of doors that require access (pirates have none, so these need attacking) + // Also build a list of doors that refuse NPCs (pirates have no access and are never crew, so these need attacking) var/list/locked_doors = list() for(var/obj/machinery/door/D in path) - if(door_requires_access(D)) + if(door_refuses_npc(D)) locked_doors[REF(D)] = TRUE // Cache the locked doors list @@ -605,28 +605,6 @@ controller.clear_blackboard_key(BB_EXPLORATION_INDEX) controller.clear_blackboard_key(BB_EXPLORATION_TARGET) -/** - * Check if a door requires access to open. - * Pirates have no access, so any door with req_access or req_one_access will need to be attacked. - */ -/proc/door_requires_access(obj/machinery/door/D) - if(!D) - return FALSE - - // Check for airlock-specific access requirements - if(istype(D, /obj/machinery/door/airlock)) - var/obj/machinery/door/airlock/A = D - if(LAZYLEN(A.req_access) || LAZYLEN(A.req_one_access)) - return TRUE - - // Check for windoor access requirements - if(istype(D, /obj/machinery/door/window)) - var/obj/machinery/door/window/W = D - if(LAZYLEN(W.req_access) || LAZYLEN(W.req_one_access)) - return TRUE - - return FALSE - /** * Get or generate the cached patrol path for a ship. * Paths are cached in GLOB.boarding_patrol_paths keyed by ship ref. @@ -882,8 +860,14 @@ // Pre-populate the failed doors list with doors that require access var/list/locked_doors = GLOB.boarding_locked_doors[ship_ref] + locked_doors = locked_doors ? locked_doors.Copy() : list() + // The captain can turn the crew lock on after the path was cached, so judge it again now + if(target_ship.crew_only_airlocks) + for(var/obj/machinery/door/door in path) + if(door_refuses_npc(door)) + locked_doors[REF(door)] = TRUE if(length(locked_doors)) - controller.blackboard["_failed_blocking_doors"] = locked_doors.Copy() + controller.blackboard["_failed_blocking_doors"] = locked_doors PATROL_LOG("Pre-marked [length(locked_doors)] doors as needing attack (no access)") PATROL_LOG("SUCCESS: Assigned [mob_to_assign] to patrol, start_index=[start_index], path_length=[length(path)]") diff --git a/voidcrew/modules/npc_ships/code/outpost_patrol.dm b/voidcrew/modules/npc_ships/code/outpost_patrol.dm index c6f20899a9a..ad94f58fb8d 100644 --- a/voidcrew/modules/npc_ships/code/outpost_patrol.dm +++ b/voidcrew/modules/npc_ships/code/outpost_patrol.dm @@ -462,7 +462,7 @@ GLOBAL_LIST_EMPTY(outpost_patrol_caches) var/list/locked_doors = list() for(var/obj/machinery/door/door as anything in path) - if(door_requires_access(door)) + if(door_refuses_npc(door)) locked_doors[REF(door)] = TRUE GLOB.boarding_locked_doors[key] = locked_doors diff --git a/voidcrew/modules/npc_ships/code/pirate_crew_ai.dm b/voidcrew/modules/npc_ships/code/pirate_crew_ai.dm index 252035ceb11..b2dbe66ce31 100644 --- a/voidcrew/modules/npc_ships/code/pirate_crew_ai.dm +++ b/voidcrew/modules/npc_ships/code/pirate_crew_ai.dm @@ -401,8 +401,9 @@ var/obj/machinery/door/airlock/airlock = door if(airlock.seal) return FALSE - // A docked or air-safe external airlock opens for anyone from inside (try_safety_unlock()) - if(istype(airlock, /obj/machinery/door/airlock/external)) + // A docked or air-safe external airlock opens for anyone from inside (try_safety_unlock()), + // unless its ship's crew lock refuses us (try_to_activate_door() in ship_access.dm) + if(istype(airlock, /obj/machinery/door/airlock/external) && !(airlock.requiresID() && airlock.refused_by_ship_crew_lock(pawn))) var/obj/machinery/door/airlock/external/hatch = airlock if(hatch.space_dir && (hatch.shuttledocked || hatch.cyclelinkedairlock?.shuttledocked || is_safe_turf(get_step(hatch, hatch.space_dir), TRUE, FALSE))) return TRUE diff --git a/voidcrew/modules/unit_tests/voidcrew_ship_access.dm b/voidcrew/modules/unit_tests/voidcrew_ship_access.dm index 443795c4300..bf3a1a1e981 100644 --- a/voidcrew/modules/unit_tests/voidcrew_ship_access.dm +++ b/voidcrew/modules/unit_tests/voidcrew_ship_access.dm @@ -292,6 +292,68 @@ QDEL_NULL(toolbox.throwing) ship.ship_team.remove_member(crewmember.mind) +/// A crew lock keeps out pirate NPCs, not just players, and the boarders' planners break those doors. +/datum/unit_test/voidcrew_ship_crew_airlock_npcs + parent_type = /datum/unit_test/voidcrew_ship_crew_airlocks + +/datum/unit_test/voidcrew_ship_crew_airlock_npcs/Destroy() + if(ship) + clear_ship_patrol_path(ship) + return ..() + +/datum/unit_test/voidcrew_ship_crew_airlock_npcs/Run() + var/obj/machinery/door/airlock/instant/door = allocate(/obj/machinery/door/airlock/instant) + door.autoclose = FALSE + door_turf = get_turf(door) + original_area = door_turf.loc + ship_area = new + door_turf.change_area(original_area, ship_area) + var/obj/docking_port/mobile/voidcrew/port = allocate(/obj/docking_port/mobile/voidcrew) + ship = allocate(/obj/structure/overmap/ship) + ship_area.shuttle_port = port + port.current_ship = ship + ship.ship_team = new /datum/team/voidcrew() + ship.ship_team.ship = ship + + var/mob/living/basic/trooper/pirate/melee/pirate = allocate(/mob/living/basic/trooper/pirate/melee, get_step(door, EAST)) + var/mob/living/carbon/human/consistent/crewmember = allocate(/mob/living/carbon/human/consistent, get_step(door, EAST)) + crewmember.mock_client = allocate(/datum/client_interface) + crewmember.mind_initialize() + ship.ship_team.add_member(crewmember.mind) + // A crewmember's body with nobody in it, e.g. a disconnected player + var/mob/living/carbon/human/consistent/vacant_crew = allocate(/mob/living/carbon/human/consistent, get_step(door, EAST)) + vacant_crew.mind_initialize() + ship.ship_team.add_member(vacant_crew.mind) + + // Lock off: a door with no access opens for an NPC, as upstream + TEST_ASSERT(door.allowed(pirate), "an unlocked ship's unaccessed airlock refused a pirate") + TEST_ASSERT(!door_refuses_npc(door), "the door planner marked an unlocked, unaccessed airlock for attack") + TEST_ASSERT(pirate_can_open_door(pirate, door), "the pirate AI thinks an unlocked ship's airlock is shut to it") + + TEST_ASSERT(ship.set_crew_only_airlocks(TRUE), "could not enable crew-only airlocks") + TEST_ASSERT(!door.allowed(pirate), "a crew-locked ship's airlock let a pirate NPC through") + pirate.last_bumped = 0 + door.Bumped(pirate) + TEST_ASSERT(door.density && !door.operating, "a pirate bumped open a crew-locked airlock") + TEST_ASSERT(door.allowed(crewmember), "the crew lock refused a rostered player") + TEST_ASSERT(door.allowed(vacant_crew), "the crew lock refused a rostered body with no player in it") + TEST_ASSERT(door_refuses_npc(door), "the door planner did not mark a crew-locked airlock for attack") + TEST_ASSERT(door_refuses_npc(door, pirate), "the door planner thinks a crew-locked airlock opens for a pirate") + TEST_ASSERT(!door_refuses_npc(door, vacant_crew), "the door planner thinks a crew-locked airlock refuses its own crew") + TEST_ASSERT(!pirate_can_open_door(pirate, door), "the pirate AI thinks a crew-locked airlock opens for it") + + // The boarding patrol pre-marks crew-locked doors for attack, even on a path cached before the lock + GLOB.boarding_patrol_paths[REF(ship)] = list(door) + GLOB.boarding_locked_doors[REF(ship)] = list() + TEST_ASSERT(assign_mob_to_patrol(pirate, ship), "could not put the pirate on the boarding patrol") + var/list/attack_doors = pirate.ai_controller.blackboard["_failed_blocking_doors"] + TEST_ASSERT(LAZYACCESS(attack_doors, REF(door)), "the boarding patrol did not mark the crew-locked airlock for attack") + + TEST_ASSERT(ship.set_crew_only_airlocks(FALSE), "could not disable crew-only airlocks") + TEST_ASSERT(door.allowed(pirate), "turning the crew lock off did not let the pirate through again") + ship.ship_team.remove_member(crewmember.mind) + ship.ship_team.remove_member(vacant_crew.mind) + /// Same-type ship jobs must not confer command, prevent acting command, or survive demotion. /datum/unit_test/voidcrew_pill_captain_commands/Run() var/datum/map_template/shuttle/voidcrew/pill/template = allocate(/datum/map_template/shuttle/voidcrew/pill)