Adds vehicle type badges, mission readiness, smart highlighting, OR requirement logic, personnel intelligence, and a safe smart ARR command board to MissionChief.
// ==UserScript==
// @name MissionChief Dispatch Intelligence Overlay
// @namespace TKB-Gaming
// @version 4.2.0
// @description Adds vehicle type badges, mission readiness, smart highlighting, OR requirement logic, personnel intelligence, and a safe smart ARR command board to MissionChief.
// @author Conroy1988
// @license MIT
// @match *://*.meldkamerspel.com/missions/*
// @match *://meldkamerspel.com/missions/*
// @match *://*.missionchief.com/missions/*
// @match *://missionchief.com/missions/*
// @match *://*.missionchief.co.uk/missions/*
// @match *://missionchief.co.uk/missions/*
// @grant none
// @run-at document-idle
// ==/UserScript==
/*
MIT License
Copyright (c) 2026 Conroy1988
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
(function () {
'use strict';
/**********************************************************************
* CONFIG
**********************************************************************/
const CONFIG = {
enableVehicleTypeBadges: true,
enableReadinessPanel: true,
enableVehicleRowHighlighting: true,
enableARRCommandBoard: true,
enablePersonnelIntelligence: true,
lightBadgeColor: '#E20000',
darkBadgeColor: '#FF4040',
showFallbackTypeId: true,
lookupNamesFromVehicleEditPage: true,
greyOutIrrelevantVehicles: true,
highlightSelectedVehicles: true,
greyOutIrrelevantARRButtons: true,
// Stars/pins stay disabled. The previous star system caused UI duplication on AJAX refresh.
enableARRStars: false,
enableARRPinnedShortcuts: false,
panelTitle: 'Dispatch Intelligence',
arrPanelTitle: 'Smart ARR Command Board',
refreshDelayMs: 140,
maxRequirementTextLength: 6000,
debug: false
};
const CUSTOM_TYPE_NAMES = {};
const CUSTOM_ROLE_RULES = [];
const CUSTOM_PERSONNEL_RULES = [];
/**********************************************************************
* CONSTANTS
**********************************************************************/
const BADGE_CLASS = 'mcdi-type-badge';
const ROLE_CHIP_CLASS = 'mcdi-role-chip';
const PERSONNEL_CHIP_CLASS = 'mcdi-personnel-chip';
const PANEL_ID = 'mcdi-readiness-panel';
const ARR_PANEL_ID = 'mcdi-arr-command-panel';
const STYLE_ID = 'mcdi-style';
const CACHE_KEY = `mcdi_vehicle_type_names_${location.hostname}`;
const ARR_STATE_KEY = `mcdi_arr_state_${location.hostname}`;
const ROW_SELECTOR = [
'#vehicle_show_table_body_all tr[vehicle_type]',
'#vehicle_show_table_body_all .vehicle_select_table_tr[vehicle_type]',
'tr.vehicle_select_table_tr[vehicle_type]',
'tr[vehicle_type]'
].join(',');
const ARR_CONTROL_SELECTOR = [
'button',
'a.btn',
'input[type="button"]',
'input[type="submit"]',
'[role="button"]',
'.btn'
].join(',');
const IGNORED_ARR_LABELS = new Set([
'units',
'ambulance',
'fire',
'police',
'coastguard/lifeboat',
'coastguard',
'lifeboat',
'airfield operations',
'search & rescue',
'search and rescue',
'event',
'dispatch',
'send',
'back',
'cancel',
'load vehicles',
'show all',
'hide'
]);
let observer = null;
let observerTarget = null;
let refreshTimer = null;
let typeNameCache = loadJSON(CACHE_KEY, {});
let arrState = loadJSON(ARR_STATE_KEY, { usefulOnly: false });
const pendingLookups = new Map();
/**********************************************************************
* ROLE + PERSONNEL DEFINITIONS
**********************************************************************/
const ROLE_DEFINITIONS = [
{
key: 'sar_trailer',
label: 'SAR / Rescue Trailer',
requirement: /\b(trailers?|operational\s*support\s*trailer|sar\s*flood\s*rescue.*trailer|flood\s*rescue\s*unit.*trailer|rescue\s*watercraft.*trailer|hovercraft.*trailer|boat\s*trailer|inland\s*rescue\s*boat.*trailer)\b/i,
vehicle: /\b(operational\s*support\s*trailer|sar\s*flood\s*rescue.*trailer|flood\s*rescue\s*unit.*trailer|rescue\s*watercraft.*trailer|hovercraft.*trailer|boat\s*trailer|inland\s*rescue\s*boat.*trailer)\b/i
},
{
key: 'personal_sar_vehicle',
label: 'Personal SAR Vehicle',
requirement: /\b(personal\s*sar\s*vehicles?)\b/i,
vehicle: /\b(personal\s*sar\s*vehicles?)\b/i
},
{
key: 'police_helicopter',
label: 'Police Helicopter',
requirement: /\b(police\s*helicopters?)\b/i,
vehicle: /\b(police\s*helicopters?)\b/i
},
{
key: 'drone',
label: 'Drone Equipment',
requirement: /\b(drone\s*equipment|drones?)\b/i,
vehicle: /\b(drone\s*equipment|drones?)\b/i
},
{
key: 'ops_support_van',
label: 'Operational Support Van',
requirement: /\b(operational\s*support\s*vans?)\b/i,
vehicle: /\b(operational\s*support\s*vans?)\b/i
},
{
key: 'ops_support_trailer',
label: 'Operational Support Trailer',
requirement: /\b(operational\s*support\s*trailers?)\b/i,
vehicle: /\b(operational\s*support\s*trailers?)\b/i
},
{
key: 'support_unit',
label: 'Support Unit',
requirement: /\b(support\s*units?)\b/i,
vehicle: /\b(support\s*units?)\b/i
},
{
key: 'four_by_four',
label: '4x4 Vehicles',
requirement: /\b(4x4\s*vehicles?|4x4\s*units?|light\s*4x4|sar\s*4x4)\b/i,
vehicle: /\b(4x4\s*vehicles?|4x4\s*units?|light\s*4x4|sar\s*4x4|personal\s*sar\s*vehicle)\b/i
},
{
key: 'fire_engine',
label: 'Fire Engines',
requirement: /\b(fire\s*engines?|pumps?|appliances?|fire\s*appliances?|type\s*[12]\s*fire\s*engines?)\b/i,
vehicle: /\b(fire\s*engines?|pumps?|rescue\s*pumps?|water\s*ladder|fire\s*appliances?|type\s*[12]\s*fire\s*engines?|rpump|wr[lt]?)\b/i
},
{
key: 'rescue_support',
label: 'Rescue Support',
requirement: /\b(rescue\s*support\s*units?|rsu|rescue\s*support)\b/i,
vehicle: /\b(rescue\s*support\s*units?|rsu|rescue\s*pumps?|rescue\s*support)\b/i
},
{
key: 'aerial',
label: 'Aerials',
requirement: /\b(aerials?|aerial\s*appliances?|aerial\s*ladder\s*platforms?|alps?|turntable\s*ladders?|platforms?|carp)\b/i,
vehicle: /\b(aerials?|aerial\s*appliances?|aerial\s*ladder\s*platforms?|alps?|turntable\s*ladders?|platforms?|carp|rescue\s*stairs)\b/i
},
{
key: 'fire_officer',
label: 'Fire Officers',
requirement: /\b(fire\s*officers?|incident\s*commanders?|command\s*units?|command\s*support|command\s*vehicles?)\b/i,
vehicle: /\b(fire\s*officers?|incident\s*commanders?|command\s*units?|command\s*support|command\s*vehicles?|iccu|acu)\b/i
},
{
key: 'water_carrier',
label: 'Water Carriers',
requirement: /\b(water\s*carriers?|water\s*tankers?|bulk\s*water|water\s*bowser)\b/i,
vehicle: /\b(water\s*carriers?|water\s*tankers?|bulk\s*water|water\s*bowser|airport\s*water\s*carrier)\b/i
},
{
key: 'hazmat',
label: 'HazMat / CBRN',
requirement: /\b(haz\s*mat|hazmat|cbrn|hazardous\s*materials?|environmental\s*protection)\b/i,
vehicle: /\b(haz\s*mat|hazmat|cbrn|hazardous\s*materials?|environmental\s*protection)\b/i
},
{
key: 'police_car',
label: 'Police Cars',
requirement: /\b(police\s*cars?|patrol\s*cars?|irvs?|incident\s*response\s*vehicles?|police\s*vehicles?)\b/i,
vehicle: /\b(police\s*cars?|patrol\s*cars?|irvs?|incident\s*response\s*vehicles?|police\s*vehicles?|panda|area\s*car)\b/i
},
{
key: 'traffic_car',
label: 'Traffic Cars',
requirement: /\b(traffic\s*cars?|traffic\s*units?|roads?\s*policing|rpu|road\s*policing)\b/i,
vehicle: /\b(traffic\s*cars?|traffic\s*units?|roads?\s*policing|rpu|road\s*policing|armed\s*traffic\s*car)\b/i
},
{
key: 'armed_police',
label: 'Armed Police',
requirement: /\b(armed\s*police|armed\s*response|arvs?|firearms?|afo)\b/i,
vehicle: /\b(armed\s*police|armed\s*response|arvs?|firearms?|afo|police\s*arv)\b/i
},
{
key: 'dog_unit',
label: 'Dog Units',
requirement: /\b(dog\s*units?|k9|police\s*dogs?)\b/i,
vehicle: /\b(dog\s*units?|k9|police\s*dogs?|multiple\s*dog\s*carrier)\b/i
},
{
key: 'public_order',
label: 'Public Order',
requirement: /\b(public\s*order|riot|level\s*[12]\s*public\s*order)\b/i,
vehicle: /\b(public\s*order|level\s*[12]\s*public\s*order|mounted\s*unit)\b/i
},
{
key: 'ambulance',
label: 'Ambulances',
requirement: /\b(ambulances?|double\s*crewed\s*ambulances?|dcas?|patient\s*transport)\b/i,
vehicle: /\b(ambulances?|double\s*crewed\s*ambulances?|dcas?|patient\s*transport|patient\s*transport\s*service)\b/i
},
{
key: 'rrv',
label: 'RRVs',
requirement: /\b(rrvs?|rapid\s*response\s*vehicles?|response\s*cars?)\b/i,
vehicle: /\b(rrvs?|rapid\s*response\s*vehicles?|response\s*cars?|rrv\s*\/?\s*gp|specialist\s*paramedic\s*rrv|srv)\b/i
},
{
key: 'ambulance_officer',
label: 'Ambulance Officers',
requirement: /\b(ambulance\s*officers?|operational\s*commanders?|ambulance\s*commanders?)\b/i,
vehicle: /\b(ambulance\s*officers?|operational\s*commanders?|ambulance\s*commanders?)\b/i
},
{
key: 'hart',
label: 'HART',
requirement: /\b(hart|hazardous\s*area\s*response|usar\s*medical)\b/i,
vehicle: /\b(hart|hazardous\s*area\s*response|usar\s*medical|mass\s*casualty|medical\s*equipment\s*trailer|mvp)\b/i
},
{
key: 'hems',
label: 'HEMS',
requirement: /\b(hems|air\s*ambulance|helicopter\s*emergency\s*medical)\b/i,
vehicle: /\b(hems|air\s*ambulance|helicopter\s*emergency\s*medical)\b/i
},
{
key: 'sar',
label: 'Search & Rescue',
requirement: /\b(search\s*and\s*rescue|\bsar\b|mountain\s*rescue|rescue\s*4x4|sar\s*commander)\b/i,
vehicle: /\b(search\s*and\s*rescue|\bsar\b|mountain\s*rescue|rescue\s*4x4|sar\s*commander|personal\s*sar\s*vehicle|drone\s*equipment)\b/i
},
{
key: 'coastguard',
label: 'Coastguard / Lifeboat',
requirement: /\b(coastguard|coast\s*guard|mud\s*rescue|water\s*rescue|flood\s*rescue|lifeboat|hovercraft|boat\s*trailer)\b/i,
vehicle: /\b(coastguard|coast\s*guard|cg\s*commander|cg\s*mud\s*rescue|mud\s*rescue|mud\s*decontamination|water\s*rescue|flood\s*rescue|lifeboat|hovercraft|boat\s*trailer|inland\s*rescue\s*boat|rescue\s*watercraft|crv|ilb|alb)\b/i
},
{
key: 'airfield',
label: 'Airfield Operations',
requirement: /\b(airfield|airport|major\s*foam\s*tender|airfield\s*operations|airfield\s*ff\s*command)\b/i,
vehicle: /\b(airfield|airport|major\s*foam\s*tender|airfield\s*operations|airfield\s*ff\s*command|riv|rescue\s*stairs)\b/i
},
{
key: 'tow',
label: 'Recovery / Tow',
requirement: /\b(tow\s*trucks?|recovery\s*vehicles?|recovery\s*trucks?|breakdown\s*vehicles?)\b/i,
vehicle: /\b(tow\s*trucks?|recovery\s*vehicles?|recovery\s*trucks?|breakdown\s*vehicles?)\b/i
},
{
key: 'bomb_disposal',
label: 'Bomb Disposal',
requirement: /\b(bomb\s*disposal|eod|explosive\s*ordnance|explosives?\s*unit)\b/i,
vehicle: /\b(bomb\s*disposal|eod|explosive\s*ordnance|explosives?\s*unit)\b/i
},
{
key: 'custody',
label: 'Custody',
requirement: /\b(custody|detention|prisoner\s*transport|transport\s*van|cell\s*van)\b/i,
vehicle: /\b(custody|detention|prisoner\s*transport|transport\s*van|cell\s*van|detention\s*van|armed\s*cell\s*van)\b/i
}
];
const PERSONNEL_DEFINITIONS = [
{
key: 'search_technician',
label: 'Search Technicians',
requirement: /\b(search\s*technicians?|search\s*techs?)\b/i,
carrier: /\b(4x4\s*vehicles?|4x4\s*units?|sar\s*4x4|light\s*4x4|personal\s*sar\s*vehicle|operational\s*support\s*van|support\s*unit|search\s*and\s*rescue|\bsar\b)\b/i
},
{
key: 'search_advisor',
label: 'Search Advisor',
requirement: /\b(search\s*advisors?|search\s*advisers?)\b/i,
carrier: /\b(operational\s*support\s*van|support\s*unit|sar\s*commander|search\s*advisor|search\s*adviser|personal\s*sar\s*vehicle|drone\s*equipment)\b/i
},
{
key: 'hazmat_personnel',
label: 'HazMat Personnel',
requirement: /\b(hazmat\s*personnel|haz\s*mat\s*personnel|cbrn\s*personnel)\b/i,
carrier: /\b(haz\s*mat|hazmat|cbrn)\b/i
},
{
key: 'firearms_personnel',
label: 'Firearms Personnel',
requirement: /\b(firearms\s*personnel|armed\s*officers?|afos?)\b/i,
carrier: /\b(armed\s*police|armed\s*response|arv|firearms)\b/i
}
];
/**********************************************************************
* BASIC HELPERS
**********************************************************************/
function debugLog(...args) {
if (CONFIG.debug) console.log('[MCDI]', ...args);
}
function loadJSON(key, fallback) {
try {
return JSON.parse(localStorage.getItem(key) || JSON.stringify(fallback));
} catch (_) {
return fallback;
}
}
function saveJSON(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (_) {
// ignored
}
}
function cleanText(value) {
return String(value || '')
.replace(/\u00a0/g, ' ')
.replace(/[✓✔✅✘✖❌★☆]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function normaliseKey(value) {
return cleanText(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function isNumericType(value) {
return /^\d+$/.test(String(value || '').trim());
}
function parseFirstInteger(value) {
const match = cleanText(value).match(/\d+/);
return match ? Number(match[0]) : 0;
}
function getPageWindow() {
try {
if (typeof unsafeWindow !== 'undefined') return unsafeWindow;
} catch (_) {
// ignored
}
return window;
}
function isVisible(element) {
if (!element) return false;
if (element.closest(`#${PANEL_ID}, #${ARR_PANEL_ID}`)) return false;
if (element.closest('#vehicle_show_table_body_all')) return false;
return Boolean(element.offsetParent || element.getClientRects().length);
}
function removeOldBrokenStars() {
document.querySelectorAll('.mcdi-arr-star').forEach(star => star.remove());
}
/**********************************************************************
* STYLES
**********************************************************************/
function installStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
.${BADGE_CLASS} {
color: ${CONFIG.lightBadgeColor};
font-size: 11px;
font-weight: 700;
margin-left: 6px;
white-space: nowrap;
opacity: 0.95;
}
body.dark .${BADGE_CLASS},
html[data-theme="dark"] .${BADGE_CLASS},
html[data-bs-theme="dark"] .${BADGE_CLASS} {
color: ${CONFIG.darkBadgeColor};
}
#${PANEL_ID},
#${ARR_PANEL_ID} {
border: 1px solid rgba(0, 0, 0, 0.15);
border-left: 5px solid #2b7cff;
border-radius: 6px;
background: rgba(255, 255, 255, 0.96);
color: #202124;
padding: 8px 10px;
margin: 8px 0 10px 0;
font-size: 12px;
line-height: 1.35;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
z-index: 2000;
clear: both;
}
#${ARR_PANEL_ID} {
border-left-color: #9333ea;
}
body.dark #${PANEL_ID},
body.dark #${ARR_PANEL_ID},
html[data-theme="dark"] #${PANEL_ID},
html[data-theme="dark"] #${ARR_PANEL_ID},
html[data-bs-theme="dark"] #${PANEL_ID},
html[data-bs-theme="dark"] #${ARR_PANEL_ID} {
background: rgba(32, 33, 36, 0.96);
color: #f1f3f4;
border-color: rgba(255, 255, 255, 0.22);
}
.mcdi-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-weight: 800;
margin-bottom: 5px;
}
.mcdi-panel-status {
font-size: 11px;
font-weight: 800;
padding: 2px 7px;
border-radius: 99px;
border: 1px solid rgba(0, 0, 0, 0.12);
white-space: nowrap;
}
.mcdi-status-ready { background: #dff5e5; color: #116329; }
.mcdi-status-warning { background: #fff4ce; color: #7a4b00; }
.mcdi-status-missing { background: #fde3e3; color: #8b1a1a; }
.mcdi-status-unknown { background: #e9ecef; color: #444; }
.mcdi-muted { opacity: 0.72; }
.mcdi-requirement-list,
.mcdi-personnel-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 4px 10px;
margin-top: 5px;
}
.mcdi-req-row,
.mcdi-personnel-row {
display: flex;
justify-content: space-between;
gap: 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
padding: 2px 0;
}
body.dark .mcdi-req-row,
body.dark .mcdi-personnel-row,
html[data-theme="dark"] .mcdi-req-row,
html[data-theme="dark"] .mcdi-personnel-row,
html[data-bs-theme="dark"] .mcdi-req-row,
html[data-bs-theme="dark"] .mcdi-personnel-row {
border-bottom-color: rgba(255, 255, 255, 0.12);
}
.mcdi-section-title {
font-weight: 900;
margin-top: 8px;
margin-bottom: 2px;
}
tr.mcdi-needed {
background: rgba(34, 197, 94, 0.16) !important;
box-shadow: inset 4px 0 0 #22c55e;
}
tr.mcdi-possible {
background: rgba(59, 130, 246, 0.13) !important;
box-shadow: inset 4px 0 0 #3b82f6;
}
tr.mcdi-personnel-needed {
background: rgba(147, 51, 234, 0.13) !important;
box-shadow: inset 4px 0 0 #9333ea;
}
tr.mcdi-selected {
outline: 2px solid rgba(34, 197, 94, 0.45);
outline-offset: -2px;
}
tr.mcdi-irrelevant {
opacity: 0.55;
}
tr.mcdi-irrelevant:hover {
opacity: 0.95;
}
tr.mcdi-no-match {
box-shadow: inset 4px 0 0 rgba(148, 163, 184, 0.55);
}
.${ROLE_CHIP_CLASS},
.${PERSONNEL_CHIP_CLASS} {
display: inline-block;
font-size: 10px;
font-weight: 800;
border-radius: 99px;
padding: 1px 5px;
margin-left: 4px;
white-space: nowrap;
}
.${ROLE_CHIP_CLASS} {
background: rgba(43, 124, 255, 0.12);
color: #174ea6;
border: 1px solid rgba(43, 124, 255, 0.24);
}
.${PERSONNEL_CHIP_CLASS} {
background: rgba(147, 51, 234, 0.12);
color: #6b21a8;
border: 1px solid rgba(147, 51, 234, 0.24);
}
body.dark .${ROLE_CHIP_CLASS},
html[data-theme="dark"] .${ROLE_CHIP_CLASS},
html[data-bs-theme="dark"] .${ROLE_CHIP_CLASS} {
background: rgba(138, 180, 248, 0.16);
color: #8ab4f8;
border-color: rgba(138, 180, 248, 0.30);
}
body.dark .${PERSONNEL_CHIP_CLASS},
html[data-theme="dark"] .${PERSONNEL_CHIP_CLASS},
html[data-bs-theme="dark"] .${PERSONNEL_CHIP_CLASS} {
background: rgba(216, 180, 254, 0.16);
color: #d8b4fe;
border-color: rgba(216, 180, 254, 0.30);
}
.mcdi-arr-control {
transition: box-shadow 0.12s ease, opacity 0.12s ease, filter 0.12s ease;
}
.mcdi-arr-recommended {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.85), 0 0 10px rgba(34, 197, 94, 0.45) !important;
filter: saturate(1.15);
}
.mcdi-arr-useful {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.80), 0 0 8px rgba(59, 130, 246, 0.35) !important;
}
.mcdi-arr-personnel {
box-shadow: 0 0 0 2px rgba(147, 51, 234, 0.80), 0 0 8px rgba(147, 51, 234, 0.35) !important;
}
.mcdi-arr-risk {
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.85), 0 0 8px rgba(245, 158, 11, 0.35) !important;
}
.mcdi-arr-irrelevant {
opacity: 0.45;
filter: grayscale(0.7);
}
.mcdi-arr-irrelevant:hover {
opacity: 0.95;
filter: none;
}
.mcdi-arr-hidden {
display: none !important;
}
.mcdi-arr-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
margin-top: 7px;
}
.mcdi-arr-toggle {
display: inline-flex;
align-items: center;
gap: 5px;
font-weight: 700;
cursor: pointer;
user-select: none;
}
.mcdi-arr-pill {
display: inline-block;
border-radius: 999px;
padding: 2px 7px;
margin-right: 5px;
margin-top: 3px;
font-size: 11px;
font-weight: 800;
background: rgba(147, 51, 234, 0.12);
border: 1px solid rgba(147, 51, 234, 0.26);
}
`;
document.head.appendChild(style);
}
/**********************************************************************
* VEHICLE TYPE BADGES
**********************************************************************/
function getVehicleTypeId(row) {
return cleanText(row.getAttribute('vehicle_type'));
}
function getVehicleId(row) {
const idMatch = cleanText(row.id).match(/vehicle[_-]?(?:row[_-]?)?(\d+)/i);
if (idMatch) return idMatch[1];
const checkbox = row.querySelector('input.vehicle_checkbox[value], input[type="checkbox"][value], input[name*="vehicle"][value]');
if (checkbox && checkbox.value) return cleanText(checkbox.value);
const vehicleLink = row.querySelector('a[href*="/vehicles/"]');
if (vehicleLink) {
const hrefMatch = vehicleLink.getAttribute('href')?.match(/\/vehicles\/(\d+)/);
if (hrefMatch) return hrefMatch[1];
}
return '';
}
function getExplicitTypeNameFromRow(row) {
const attributeCandidates = [
'vehicle_type_caption',
'vehicle_type_name',
'data-vehicle-type-caption',
'data-vehicle-type-name',
'data-vehicle_type_caption',
'data-vehicle_type_name'
];
for (const attr of attributeCandidates) {
const value = cleanText(row.getAttribute(attr));
if (value) return value;
}
if (row.dataset) {
const datasetCandidates = [
row.dataset.vehicleTypeCaption,
row.dataset.vehicleTypeName,
row.dataset.vehicle_type_caption,
row.dataset.vehicle_type_name
];
for (const value of datasetCandidates) {
const cleaned = cleanText(value);
if (cleaned) return cleaned;
}
}
return '';
}
function normaliseGlobalTypeEntry(entry) {
if (entry === null || entry === undefined) return '';
if (typeof entry === 'string' || typeof entry === 'number') return cleanText(entry);
if (typeof entry !== 'object') return '';
const keys = ['caption', 'name', 'title', 'label', 'text', 'typeName', 'vehicleType', 'vehicle_type', 'vehicle_type_caption', 'vehicle_type_name'];
for (const key of keys) {
if (entry[key] !== undefined && entry[key] !== null) {
const value = cleanText(entry[key]);
if (value) return value;
}
}
return '';
}
function lookupFromGlobalObject(root, typeId) {
if (!root || typeof root === 'function') return '';
if (Array.isArray(root)) return normaliseGlobalTypeEntry(root[Number(typeId)]);
if (typeof root === 'object') {
if (Object.prototype.hasOwnProperty.call(root, typeId)) return normaliseGlobalTypeEntry(root[typeId]);
if (Object.prototype.hasOwnProperty.call(root, String(typeId))) return normaliseGlobalTypeEntry(root[String(typeId)]);
}
return '';
}
function lookupFromKnownGlobals(typeId) {
const w = getPageWindow();
const possibleGlobals = [
w.vehicleTypes,
w.vehicle_types,
w.vehicleTypeNames,
w.vehicle_type_names,
w.vehicleTypeMap,
w.vehicle_type_map,
w.LSSM?.vehicleTypes,
w.lssm?.vehicleTypes,
w.I18n?.vehicleTypes
];
for (const root of possibleGlobals) {
const value = lookupFromGlobalObject(root, typeId);
if (value && value !== typeId) return value;
}
return '';
}
function lookupFromI18n(typeId) {
const w = getPageWindow();
const i18n = w.I18n;
if (!i18n || typeof i18n.t !== 'function') return '';
const possibleKeys = [
`vehicle_types.${typeId}.caption`,
`vehicle_types.${typeId}.name`,
`vehicle_types.${typeId}`,
`vehicles.${typeId}.caption`,
`vehicles.${typeId}.name`,
`vehicles.${typeId}`,
`vehicleType.${typeId}.caption`,
`vehicleType.${typeId}.name`,
`vehicleType.${typeId}`
];
for (const key of possibleKeys) {
try {
const value = cleanText(i18n.t(key));
if (value && value !== key && value !== typeId && !/^translation missing/i.test(value)) return value;
} catch (_) {
// next key
}
}
return '';
}
function resolveTypeName(row, typeId) {
if (!typeId) return '';
const explicitName = getExplicitTypeNameFromRow(row);
if (explicitName && explicitName !== typeId) return explicitName;
if (!isNumericType(typeId)) return typeId;
if (CUSTOM_TYPE_NAMES[typeId]) return cleanText(CUSTOM_TYPE_NAMES[typeId]);
if (typeNameCache[typeId]) return cleanText(typeNameCache[typeId]);
const globalName = lookupFromKnownGlobals(typeId);
if (globalName) {
typeNameCache[typeId] = globalName;
saveJSON(CACHE_KEY, typeNameCache);
return globalName;
}
const i18nName = lookupFromI18n(typeId);
if (i18nName) {
typeNameCache[typeId] = i18nName;
saveJSON(CACHE_KEY, typeNameCache);
return i18nName;
}
return CONFIG.showFallbackTypeId ? `Type ${typeId}` : typeId;
}
function findVehicleNameTarget(row) {
const missionVehicleLabel = row.querySelector('.mission_vehicle_label');
if (missionVehicleLabel) return missionVehicleLabel;
const vehicleLink = Array.from(row.querySelectorAll('a[href*="/vehicles/"]'))
.find(link => !link.closest('.dropdown-menu') && !link.classList.contains('btn'));
if (vehicleLink) return vehicleLink;
const cells = row.cells || row.querySelectorAll('td');
if (cells && cells.length >= 3) return cells[2].firstElementChild || cells[2];
return null;
}
function decorateVehicleTypeBadge(row) {
if (!CONFIG.enableVehicleTypeBadges) return;
if (!row || row.nodeType !== Node.ELEMENT_NODE) return;
if (row.querySelector(`.${BADGE_CLASS}`)) return;
const typeId = getVehicleTypeId(row);
if (!typeId) return;
const vehicleId = getVehicleId(row);
const typeName = resolveTypeName(row, typeId);
if (!typeName) return;
const target = findVehicleNameTarget(row);
if (!target || !target.parentNode) return;
const badge = document.createElement('span');
badge.className = `${BADGE_CLASS} pull-right`;
badge.dataset.mcdiTypeId = typeId;
if (vehicleId) {
badge.dataset.mcdiVehicleId = vehicleId;
badge.id = `vehicleTypeText_${vehicleId}`;
}
badge.textContent = ` (${typeName})`;
badge.title = `Vehicle type: ${typeName}`;
target.insertAdjacentElement('afterend', badge);
if (CONFIG.lookupNamesFromVehicleEditPage && isNumericType(typeId) && vehicleId && !typeNameCache[typeId] && /^Type \d+$/.test(typeName)) {
queueVehicleTypeLookup(typeId, vehicleId);
}
}
async function queueVehicleTypeLookup(typeId, vehicleId) {
if (!typeId || !vehicleId) return;
if (typeNameCache[typeId]) return;
if (pendingLookups.has(typeId)) return;
pendingLookups.set(typeId, true);
try {
const resolvedName = await fetchVehicleTypeNameFromEditPage(vehicleId);
if (resolvedName && resolvedName !== typeId && !/^Type \d+$/.test(resolvedName)) {
typeNameCache[typeId] = resolvedName;
saveJSON(CACHE_KEY, typeNameCache);
updateBadgesForType(typeId, resolvedName);
scheduleApply();
}
} catch (error) {
debugLog(`Could not resolve type ${typeId}:`, error);
} finally {
pendingLookups.delete(typeId);
}
}
async function fetchVehicleTypeNameFromEditPage(vehicleId) {
const url = new URL(`/vehicles/${vehicleId}/edit`, window.location.origin).href;
const response = await fetch(url, { credentials: 'same-origin', cache: 'force-cache' });
if (!response.ok) return '';
const html = await response.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
return extractVehicleTypeNameFromEditDocument(doc);
}
function extractVehicleTypeNameFromEditDocument(doc) {
const selectCandidates = Array.from(doc.querySelectorAll('select')).filter(select => {
const marker = `${select.id || ''} ${select.name || ''}`.toLowerCase();
return marker.includes('vehicle_type') || marker.includes('vehicletype') || marker.includes('vehicle-type') || marker.includes('type');
});
for (const select of selectCandidates) {
const selectedOption = select.querySelector('option:checked') || Array.from(select.options || []).find(option => option.selected || option.hasAttribute('selected'));
const text = cleanText(selectedOption?.textContent);
if (text && !/^please/i.test(text) && !/^select/i.test(text) && !/^choose/i.test(text)) return text;
}
return '';
}
function updateBadgesForType(typeId, typeName) {
document.querySelectorAll(`.${BADGE_CLASS}`).forEach(badge => {
if (badge.dataset.mcdiTypeId === String(typeId)) {
badge.textContent = ` (${typeName})`;
badge.title = `Vehicle type: ${typeName}`;
}
});
}
/**********************************************************************
* ROLE + PERSONNEL DETECTION
**********************************************************************/
function detectRoles(text, typeName = '', typeId = '') {
const haystack = `${text || ''} ${typeName || ''} ${typeId || ''}`;
const roles = new Set();
for (const custom of CUSTOM_ROLE_RULES) {
try {
if (custom.match && custom.match.test(haystack)) {
for (const role of custom.roles || []) roles.add(role);
}
} catch (_) {
// ignored
}
}
for (const definition of ROLE_DEFINITIONS) {
if (definition.vehicle.test(haystack)) roles.add(definition.key);
}
return Array.from(roles);
}
function detectPersonnelCarriers(text, typeName = '', typeId = '') {
const haystack = `${text || ''} ${typeName || ''} ${typeId || ''}`;
const personnelRoles = new Set();
for (const custom of CUSTOM_PERSONNEL_RULES) {
try {
if (custom.match && custom.match.test(haystack)) {
for (const role of custom.roles || []) personnelRoles.add(role);
}
} catch (_) {
// ignored
}
}
for (const definition of PERSONNEL_DEFINITIONS) {
if (definition.carrier.test(haystack)) personnelRoles.add(definition.key);
}
return Array.from(personnelRoles);
}
function getVehicleText(row) {
const clone = row.cloneNode(true);
clone.querySelectorAll(`.${BADGE_CLASS}, .${ROLE_CHIP_CLASS}, .${PERSONNEL_CHIP_CLASS}`).forEach(el => el.remove());
return cleanText(clone.textContent);
}
function getRowAnalysis(row) {
const typeId = getVehicleTypeId(row);
const typeName = resolveTypeName(row, typeId);
const rowText = `${getVehicleText(row)} ${typeName}`;
const roles = detectRoles(rowText, typeName, typeId);
const personnelRoles = detectPersonnelCarriers(rowText, typeName, typeId);
const selected = isVehicleSelected(row);
return { row, typeId, typeName, rowText, roles, personnelRoles, selected };
}
function isVehicleSelected(row) {
const checkbox = row.querySelector('input.vehicle_checkbox, input[type="checkbox"][name*="vehicle"], input[type="checkbox"]');
if (checkbox && checkbox.checked) return true;
return row.classList.contains('success') || row.classList.contains('selected') || row.classList.contains('vehicle_selected') || row.getAttribute('aria-selected') === 'true';
}
function getRoleLabel(roleKey) {
return ROLE_DEFINITIONS.find(role => role.key === roleKey)?.label || roleKey;
}
function getPersonnelLabel(personnelKey) {
return PERSONNEL_DEFINITIONS.find(role => role.key === personnelKey)?.label || personnelKey;
}
/**********************************************************************
* REQUIREMENT TEXT COLLECTION
**********************************************************************/
function collectRequirementText() {
const selectors = [
'#mission_vehicle_requirements',
'.mission_vehicle_requirements',
'#mission_required_vehicles',
'.mission_required_vehicles',
'#mission_requirements',
'.mission_requirements',
'#missing_vehicle_text',
'#missing_vehicle_span',
'#missing_vehicles',
'.missing_vehicles',
'#missing_text',
'.missing_text',
'.alert-danger',
'.alert-warning',
'.alert-info',
'[id*="missing"]',
'[class*="missing"]',
'[id*="require"]',
'[class*="require"]'
];
const seen = new Set();
const chunks = [];
for (const selector of selectors) {
document.querySelectorAll(selector).forEach(element => {
if (!element || !isVisible(element)) return;
if (element.closest(`#${PANEL_ID}, #${ARR_PANEL_ID}`)) return;
if (element.closest('#vehicle_show_table_body_all')) return;
const text = cleanText(element.textContent);
if (!text) return;
if (text.length > CONFIG.maxRequirementTextLength) return;
if (!looksLikeRequirementText(text)) return;
if (seen.has(text)) return;
seen.add(text);
chunks.push(text);
});
}
return chunks.join(' | ');
}
function looksLikeRequirementText(text) {
const hasRelevantWord = /\b(vehicle|vehicles|unit|units|required|requirement|missing|needed|need|ambulance|police|fire|rescue|search|patients?|prisoners?|transport|personnel|technicians?|advisor|adviser|drone|helicopter|4x4|trailer|still\s*needed|missing\s*on\s*mission)\b/i.test(text);
const hasNumber = /\d/.test(text) || /\bor\b/i.test(text);
const notNoise = !/\b(credits?|coins?|alliance\s*funds?|premium|coins?\s*for)\b/i.test(text);
return hasRelevantWord && hasNumber && notNoise;
}
/**********************************************************************
* REQUIREMENT PARSING - VEHICLE + OR LOGIC
**********************************************************************/
function parseMissionRequirements(text) {
const requirements = new Map();
const patientInfo = parsePatientInfo(text);
if (text) {
const cleaned = cleanText(text)
.replace(/\b(missing\s*vehicles?|required\s*vehicles?|vehicles\s*required|required\s*units?|requirements?|needed\s*vehicles?|units\s*needed)\b\s*:?/gi, '|')
.replace(/\bmissing\s*personnel\b\s*:?/gi, '|')
.replace(/[•\n\r;]+/g, ',')
.replace(/\s*[|]\s*/g, ',');
const segments = cleaned.split(',')
.map(segment => cleanText(segment))
.filter(Boolean);
for (const segment of segments) {
if (looksLikePersonnelOnlySegment(segment)) continue;
parseRequirementSegment(segment, requirements);
}
}
addStructuredMissingTableRequirements(requirements);
return {
requirements: Array.from(requirements.values())
.filter(req => req.count > 0)
.sort((a, b) => a.label.localeCompare(b.label)),
patientInfo,
sourceText: text || ''
};
}
function looksLikePersonnelOnlySegment(segment) {
return PERSONNEL_DEFINITIONS.some(definition => definition.requirement.test(segment));
}
function parseRequirementSegment(segment, requirements) {
if (!segment) return;
if (/\b(credits?|coins?|station|stations|building|buildings|expansion|expansions)\b/i.test(segment)) return;
if (parseOrRequirementSegment(segment, requirements)) return;
const patterns = [
/(?:^|\b)(\d{1,3})\s*x?\s+([a-zA-ZÀ-ÿ0-9/&().'\-\s]{2,120})/g,
/([a-zA-ZÀ-ÿ0-9/&().'\-\s]{2,120})\s*[:\-]\s*(\d{1,3})(?:\b|$)/g
];
let matched = false;
for (let patternIndex = 0; patternIndex < patterns.length; patternIndex += 1) {
const pattern = patterns[patternIndex];
let match;
while ((match = pattern.exec(segment)) !== null) {
matched = true;
const count = Number(patternIndex === 0 ? match[1] : match[2]);
const rawLabel = patternIndex === 0 ? match[2] : match[1];
addRequirement(rawLabel, count, requirements);
}
}
if (!matched) {
for (const definition of ROLE_DEFINITIONS) {
if (definition.requirement.test(segment)) {
addRequirement(segment, 1, requirements);
break;
}
}
}
}
function parseOrRequirementSegment(segment, requirements) {
if (!/\bor\b/i.test(segment)) return false;
const countMatch = segment.match(/^\s*(\d{1,3})\s*x?\s+(.+)$/i);
const count = countMatch ? Number(countMatch[1]) : 1;
const labelText = countMatch ? countMatch[2] : segment;
const parts = labelText.split(/\s+or\s+/i)
.map(part => cleanText(part))
.filter(Boolean);
if (parts.length < 2) return false;
const alternatives = [];
for (const part of parts) {
const roleKey = normaliseRequirementToRole(part, true);
if (!roleKey) continue;
alternatives.push({ key: roleKey, label: getRoleLabel(roleKey) });
}
const uniqueAlternatives = [];
const seen = new Set();
for (const alt of alternatives) {
if (seen.has(alt.key)) continue;
seen.add(alt.key);
uniqueAlternatives.push(alt);
}
if (uniqueAlternatives.length < 2) return false;
const key = `or_${uniqueAlternatives.map(item => item.key).sort().join('_')}`;
const label = uniqueAlternatives.map(item => item.label).join(' OR ');
const existing = requirements.get(key);
if (existing) {
existing.count = Math.max(existing.count, count);
existing.rawLabels.add(labelText);
} else {
requirements.set(key, {
key,
label,
count,
rawLabels: new Set([labelText]),
isOr: true,
alternatives: uniqueAlternatives
});
}
return true;
}
function addRequirement(rawLabel, count, requirements) {
if (!Number.isFinite(count) || count <= 0) return;
const label = cleanText(rawLabel)
.replace(/\b(required|missing|needed|vehicle|vehicles|unit|units|minimum|min|missing\s*on\s*mission|still\s*needed|selected|en\s*route|en-route)\b/gi, '')
.replace(/\b(if|when)\s+available\b/gi, '')
.replace(/[.:]+$/g, '')
.trim();
if (!label || label.length < 2) return;
if (/\b(patient|patients|prisoner|prisoners|credits?|coins?|mission|personnel)\b/i.test(label)) return;
if (looksLikePersonnelOnlySegment(label)) return;
const key = normaliseRequirementToRole(label, false);
if (!key) return;
const existing = requirements.get(key);
if (existing) {
existing.count = Math.max(existing.count, count);
existing.rawLabels.add(label);
} else {
requirements.set(key, { key, label: getRoleLabel(key), count, rawLabels: new Set([label]) });
}
}
function normaliseRequirementToRole(label, isOrPart = false) {
const haystack = cleanText(label);
if (isOrPart && /^trailers?$/i.test(haystack)) return 'sar_trailer';
if (isOrPart && /^drones?$/i.test(haystack)) return 'drone';
for (const definition of ROLE_DEFINITIONS) {
if (definition.requirement.test(haystack)) return definition.key;
}
return '';
}
function addStructuredMissingTableRequirements(requirements) {
const tables = Array.from(document.querySelectorAll('table'));
for (const table of tables) {
if (!isVisible(table)) continue;
if (table.closest(`#${PANEL_ID}, #${ARR_PANEL_ID}, #vehicle_show_table_body_all`)) continue;
const tableText = cleanText(table.textContent);
if (!/missing\s*on\s*mission|still\s*needed/i.test(tableText)) continue;
const headerCells = Array.from(table.querySelectorAll('thead th, thead td, tr:first-child th, tr:first-child td'));
const headers = headerCells.map(cell => cleanText(cell.textContent).toLowerCase());
const missingIndex = headers.findIndex(header => /missing\s*on\s*mission/.test(header));
const stillIndex = headers.findIndex(header => /still\s*needed/.test(header));
const selectedIndex = headers.findIndex(header => /selected/.test(header));
const rows = Array.from(table.querySelectorAll('tbody tr, tr')).slice(headerCells.length ? 1 : 0);
for (const row of rows) {
const cells = Array.from(row.querySelectorAll('td, th'));
if (cells.length < 2) continue;
const label = cleanText(cells[0].textContent);
if (!label || /missing\s*on\s*mission|still\s*needed/i.test(label)) continue;
if (looksLikePersonnelOnlySegment(label)) continue;
const stillNeeded = stillIndex >= 0 && cells[stillIndex] ? parseFirstInteger(cells[stillIndex].textContent) : 0;
const selected = selectedIndex >= 0 && cells[selectedIndex] ? parseFirstInteger(cells[selectedIndex].textContent) : 0;
const missing = missingIndex >= 0 && cells[missingIndex] ? parseFirstInteger(cells[missingIndex].textContent) : 0;
const count = Math.max(stillNeeded + selected, stillNeeded, missing);
if (count > 0) addRequirement(label, count, requirements);
}
}
}
function parsePatientInfo(text) {
if (!text) return null;
const patientMatches = Array.from(text.matchAll(/(\d{1,3})\s+(?:patients?|casualties)/gi));
if (!patientMatches.length) return null;
const total = patientMatches.reduce((sum, match) => sum + Number(match[1] || 0), 0);
if (!total) return null;
return { patients: total };
}
/**********************************************************************
* PERSONNEL REQUIREMENT PARSING
**********************************************************************/
function parsePersonnelRequirements(text) {
const requirements = new Map();
if (text) {
const cleaned = cleanText(text)
.replace(/\bmissing\s*personnel\b\s*:?/gi, '|')
.replace(/[•\n\r;]+/g, ',')
.replace(/\s*[|]\s*/g, ',');
const segments = cleaned.split(',')
.map(segment => cleanText(segment))
.filter(Boolean);
for (const segment of segments) {
parsePersonnelSegment(segment, requirements);
}
}
return Array.from(requirements.values())
.filter(req => req.count > 0)
.sort((a, b) => a.label.localeCompare(b.label));
}
function parsePersonnelSegment(segment, requirements) {
if (!segment) return;
const patterns = [
/(?:^|\b)(\d{1,3})\s*x?\s+([a-zA-ZÀ-ÿ0-9/&().'\-\s]{2,120})/g,
/([a-zA-ZÀ-ÿ0-9/&().'\-\s]{2,120})\s*[:\-]\s*(\d{1,3})(?:\b|$)/g
];
let matched = false;
for (let patternIndex = 0; patternIndex < patterns.length; patternIndex += 1) {
const pattern = patterns[patternIndex];
let match;
while ((match = pattern.exec(segment)) !== null) {
const count = Number(patternIndex === 0 ? match[1] : match[2]);
const rawLabel = patternIndex === 0 ? match[2] : match[1];
const key = normalisePersonnelRequirement(rawLabel);
if (!key) continue;
matched = true;
addPersonnelRequirement(key, count, rawLabel, requirements);
}
}
if (!matched) {
const key = normalisePersonnelRequirement(segment);
if (key) addPersonnelRequirement(key, 1, segment, requirements);
}
}
function normalisePersonnelRequirement(label) {
const haystack = cleanText(label);
for (const definition of PERSONNEL_DEFINITIONS) {
if (definition.requirement.test(haystack)) return definition.key;
}
return '';
}
function addPersonnelRequirement(key, count, rawLabel, requirements) {
if (!key || !Number.isFinite(count) || count <= 0) return;
const existing = requirements.get(key);
if (existing) {
existing.count = Math.max(existing.count, count);
existing.rawLabels.add(cleanText(rawLabel));
} else {
requirements.set(key, {
key,
label: getPersonnelLabel(key),
count,
rawLabels: new Set([cleanText(rawLabel)])
});
}
}
/**********************************************************************
* DISPATCH STATE
**********************************************************************/
function getRequirementRoleKeys(req) {
return req.isOr ? req.alternatives.map(item => item.key) : [req.key];
}
function buildDispatchState() {
const rows = Array.from(document.querySelectorAll(ROW_SELECTOR));
const rowAnalyses = rows.map(getRowAnalysis);
const requirementText = collectRequirementText();
const parsed = parseMissionRequirements(requirementText);
const personnelParsed = parsePersonnelRequirements(requirementText);
const selectedByRole = new Map();
const availableByRole = new Map();
const availablePersonnelCarrierByRole = new Map();
const selectedPersonnelCarrierByRole = new Map();
for (const analysis of rowAnalyses) {
for (const role of analysis.roles) {
availableByRole.set(role, (availableByRole.get(role) || 0) + 1);
if (analysis.selected) selectedByRole.set(role, (selectedByRole.get(role) || 0) + 1);
}
for (const personnelRole of analysis.personnelRoles) {
availablePersonnelCarrierByRole.set(personnelRole, (availablePersonnelCarrierByRole.get(personnelRole) || 0) + 1);
if (analysis.selected) selectedPersonnelCarrierByRole.set(personnelRole, (selectedPersonnelCarrierByRole.get(personnelRole) || 0) + 1);
}
}
const requirementRows = parsed.requirements.map(req => {
const roleKeys = getRequirementRoleKeys(req);
const selected = req.isOr
? Math.max(...roleKeys.map(role => selectedByRole.get(role) || 0), 0)
: selectedByRole.get(req.key) || 0;
const available = req.isOr
? roleKeys.reduce((sum, role) => sum + (availableByRole.get(role) || 0), 0)
: availableByRole.get(req.key) || 0;
const missing = Math.max(req.count - selected, 0);
return {
...req,
roleKeys,
selected,
available,
missing,
satisfied: selected >= req.count,
noVisibleVehicles: available === 0
};
});
const personnelRows = personnelParsed.map(req => {
const candidateVehicles = availablePersonnelCarrierByRole.get(req.key) || 0;
const selectedCandidateVehicles = selectedPersonnelCarrierByRole.get(req.key) || 0;
return {
...req,
candidateVehicles,
selectedCandidateVehicles,
missing: req.count,
satisfied: false,
noVisibleVehicles: candidateVehicles === 0
};
});
const knownRequirementKeys = new Set();
const missingRequirementKeys = new Set();
for (const req of requirementRows) {
for (const roleKey of req.roleKeys) knownRequirementKeys.add(roleKey);
if (!req.satisfied) {
for (const roleKey of req.roleKeys) missingRequirementKeys.add(roleKey);
}
}
const missingPersonnelKeys = new Set(personnelRows.map(req => req.key));
return {
rows,
rowAnalyses,
requirementText,
requirements: requirementRows,
personnelRequirements: personnelRows,
patientInfo: parsed.patientInfo,
knownRequirementKeys,
missingRequirementKeys,
missingPersonnelKeys
};
}
/**********************************************************************
* READINESS PANEL
**********************************************************************/
function ensurePanel() {
if (!CONFIG.enableReadinessPanel) return null;
let panel = document.getElementById(PANEL_ID);
if (panel) return panel;
panel = document.createElement('div');
panel.id = PANEL_ID;
const tableBody = document.getElementById('vehicle_show_table_body_all');
const table = tableBody?.closest('table');
const container = table?.parentElement || tableBody?.parentElement;
if (container && container.parentElement) container.parentElement.insertBefore(panel, container);
else document.body.prepend(panel);
return panel;
}
function renderPanel(state) {
const panel = ensurePanel();
if (!panel) return;
const requirements = state.requirements;
const personnelRequirements = state.personnelRequirements;
const selectedCount = state.rowAnalyses.filter(row => row.selected).length;
const vehicleCount = state.rowAnalyses.length;
let statusClass = 'mcdi-status-unknown';
let statusText = 'Scanning';
if (!requirements.length && !personnelRequirements.length) {
statusClass = 'mcdi-status-unknown';
statusText = 'No requirements read';
} else if (requirements.length && requirements.every(req => req.satisfied) && !personnelRequirements.length) {
statusClass = 'mcdi-status-ready';
statusText = 'Looks ready';
} else if (requirements.some(req => req.noVisibleVehicles) || personnelRequirements.some(req => req.noVisibleVehicles)) {
statusClass = 'mcdi-status-warning';
statusText = 'Units unavailable';
} else {
statusClass = 'mcdi-status-missing';
statusText = 'Still needed';
}
const patientNote = state.patientInfo
? `<div class="mcdi-muted">Patients detected: ${state.patientInfo.patients}. Ambulance transport may still depend on mission chance/transport rules.</div>`
: '';
const vehicleBody = requirements.length
? `<div class="mcdi-section-title">Vehicle requirements</div><div class="mcdi-requirement-list">${requirements.map(renderRequirementRow).join('')}</div>`
: `<div class="mcdi-muted">No readable vehicle requirement text found yet.</div>`;
const personnelBody = CONFIG.enablePersonnelIntelligence && personnelRequirements.length
? `<div class="mcdi-section-title">Personnel intelligence</div><div class="mcdi-personnel-list">${personnelRequirements.map(renderPersonnelRow).join('')}</div><div class="mcdi-muted">Personnel counts are treated as advisory because the dispatch list does not reliably expose trained crew per vehicle.</div>`
: '';
panel.innerHTML = `
<div class="mcdi-panel-head">
<span>${escapeHtml(CONFIG.panelTitle)}</span>
<span class="mcdi-panel-status ${statusClass}">${escapeHtml(statusText)}</span>
</div>
<div class="mcdi-muted">Visible vehicles: ${vehicleCount} · Selected: ${selectedCount}</div>
${vehicleBody}
${personnelBody}
${patientNote}
`;
}
function renderRequirementRow(req) {
let icon = '❌';
if (req.satisfied) icon = '✅';
else if (req.noVisibleVehicles) icon = '⚠️';
const countText = `${req.selected}/${req.count}`;
const availableText = req.noVisibleVehicles ? ' · none visible' : ` · ${req.available} visible`;
const orText = req.isOr ? ' · OR' : '';
return `
<div class="mcdi-req-row" title="${escapeHtml(Array.from(req.rawLabels || []).join(', '))}">
<span>${icon} ${escapeHtml(req.label)}${orText}</span>
<strong>${escapeHtml(countText + availableText)}</strong>
</div>
`;
}
function renderPersonnelRow(req) {
const icon = req.noVisibleVehicles ? '⚠️' : '👥';
const vehicleText = req.noVisibleVehicles ? 'none visible' : `${req.candidateVehicles} candidate vehicles`;
return `
<div class="mcdi-personnel-row" title="${escapeHtml(Array.from(req.rawLabels || []).join(', '))}">
<span>${icon} ${escapeHtml(req.label)}</span>
<strong>${escapeHtml(`${req.count} needed · ${vehicleText}`)}</strong>
</div>
`;
}
/**********************************************************************
* VEHICLE ROW HIGHLIGHTING
**********************************************************************/
function clearRowClasses(row) {
row.classList.remove('mcdi-needed', 'mcdi-possible', 'mcdi-personnel-needed', 'mcdi-selected', 'mcdi-irrelevant', 'mcdi-no-match');
}
function applyVehicleRowHighlighting(state) {
if (!CONFIG.enableVehicleRowHighlighting) return;
const hasVehicleRequirements = state.requirements.length > 0;
const hasPersonnelRequirements = state.personnelRequirements.length > 0;
const missingKeys = state.missingRequirementKeys;
const knownKeys = state.knownRequirementKeys;
const missingPersonnelKeys = state.missingPersonnelKeys;
for (const analysis of state.rowAnalyses) {
const row = analysis.row;
clearRowClasses(row);
const roles = new Set(analysis.roles);
const personnelRoles = new Set(analysis.personnelRoles);
const matchesMissing = Array.from(roles).some(role => missingKeys.has(role));
const matchesKnown = Array.from(roles).some(role => knownKeys.has(role));
const matchesPersonnel = Array.from(personnelRoles).some(role => missingPersonnelKeys.has(role));
if (CONFIG.highlightSelectedVehicles && analysis.selected) row.classList.add('mcdi-selected');
if (matchesMissing) row.classList.add('mcdi-needed');
else if (matchesPersonnel) row.classList.add('mcdi-personnel-needed');
else if (matchesKnown) row.classList.add('mcdi-possible');
else if (hasVehicleRequirements || hasPersonnelRequirements) {
if (roles.size || personnelRoles.size) {
if (CONFIG.greyOutIrrelevantVehicles) row.classList.add('mcdi-irrelevant');
} else row.classList.add('mcdi-no-match');
}
}
}
function addRoleChips(state) {
state.rows.forEach(row => row.querySelectorAll(`.${ROLE_CHIP_CLASS}, .${PERSONNEL_CHIP_CLASS}`).forEach(chip => chip.remove()));
for (const analysis of state.rowAnalyses) {
const row = analysis.row;
const target = row.querySelector(`.${BADGE_CLASS}`) || findVehicleNameTarget(row);
if (!target || !target.parentElement) continue;
if (analysis.roles.length) {
const chipText = analysis.roles.slice(0, 2).map(getRoleLabel).join(' + ');
const chip = document.createElement('span');
chip.className = ROLE_CHIP_CLASS;
chip.textContent = chipText;
chip.title = `Detected role: ${analysis.roles.map(getRoleLabel).join(', ')}`;
target.insertAdjacentElement('afterend', chip);
}
if (CONFIG.enablePersonnelIntelligence && analysis.personnelRoles.length) {
const personnelChipText = analysis.personnelRoles.slice(0, 2).map(getPersonnelLabel).join(' + ');
const personnelChip = document.createElement('span');
personnelChip.className = PERSONNEL_CHIP_CLASS;
personnelChip.textContent = personnelChipText;
personnelChip.title = `Possible personnel carrier: ${analysis.personnelRoles.map(getPersonnelLabel).join(', ')}`;
target.insertAdjacentElement('afterend', personnelChip);
}
}
}
/**********************************************************************
* SMART ARR COMMAND BOARD
**********************************************************************/
function getARRText(element) {
if (!element) return '';
if (element.tagName === 'INPUT') return cleanText(element.value || element.getAttribute('value') || '');
return cleanText(element.textContent || element.getAttribute('title') || element.getAttribute('aria-label') || '');
}
function isLikelyARRControl(element, text) {
if (!CONFIG.enableARRCommandBoard) return false;
if (!element || !text) return false;
if (!isVisible(element)) return false;
const lower = text.toLowerCase();
if (IGNORED_ARR_LABELS.has(lower)) return false;
if (text.length > 90) return false;
if (element.closest('ul.nav, .nav, .nav-tabs, .navbar, .dropdown-menu')) return false;
if (element.closest(`#${PANEL_ID}, #${ARR_PANEL_ID}`)) return false;
if (element.closest('#vehicle_show_table_body_all')) return false;
const roles = detectRoles(text);
const personnelRoles = detectPersonnelCarriers(text);
return roles.length > 0 || personnelRoles.length > 0;
}
function findARRControls() {
const controls = [];
const seen = new Set();
document.querySelectorAll(ARR_CONTROL_SELECTOR).forEach(element => {
const text = getARRText(element);
if (!isLikelyARRControl(element, text)) return;
const key = normaliseKey(text);
if (!key || seen.has(key)) return;
seen.add(key);
controls.push({ element, controlRoot: getARRControlRoot(element), text, key });
});
return controls;
}
function getARRControlRoot(element) {
const group = element.closest('.btn-group, .input-group, .form-group, label');
if (group && cleanText(group.textContent).length <= 140) return group;
return element;
}
function inferARRCount(text) {
const xMatch = text.match(/(?:x|×)\s*0*(\d{1,3})\b/i);
if (xMatch) return Number(xMatch[1]);
const endNumber = text.match(/\b0*(\d{1,3})\s*$/);
if (endNumber && /\b(police|ambulance|fire|officer|sar|unit|engine|car|rrv|4x4)\b/i.test(text)) return Number(endNumber[1]);
return 1;
}
function analyseARRControls(state) {
const controls = findARRControls();
return controls.map(control => {
const roles = detectRoles(control.text);
const personnelRoles = detectPersonnelCarriers(control.text);
const count = inferARRCount(control.text);
const matchingMissing = roles.filter(role => state.missingRequirementKeys.has(role));
const matchingKnown = roles.filter(role => state.knownRequirementKeys.has(role));
const matchingPersonnel = personnelRoles.filter(role => state.missingPersonnelKeys.has(role));
let status = 'neutral';
let reason = 'No readable mission requirement yet.';
if (state.requirements.length || state.personnelRequirements.length) {
if (matchingMissing.length) {
const mainRole = matchingMissing[0];
const req = state.requirements.find(item => item.roleKeys.includes(mainRole));
const overSend = req ? Math.max(count - req.missing, 0) : 0;
if (overSend > 2 && count >= 5) {
status = 'risk';
reason = `Helps ${getRoleLabel(mainRole)}, but may over-send by ${overSend}.`;
} else {
status = 'recommended';
reason = `Directly helps missing ${getRoleLabel(mainRole)}.`;
}
} else if (matchingPersonnel.length) {
status = 'personnel';
reason = `May carry/support missing personnel: ${matchingPersonnel.map(getPersonnelLabel).join(', ')}.`;
} else if (matchingKnown.length) {
status = 'useful';
reason = `Matches mission role already partly/fully covered: ${matchingKnown.map(getRoleLabel).join(', ')}.`;
} else {
status = 'irrelevant';
reason = 'No detected match against current mission requirements.';
}
} else if (state.patientInfo && roles.includes('ambulance')) {
status = 'useful';
reason = 'Patients detected, but no explicit ambulance requirement was readable.';
}
return { ...control, roles, personnelRoles, count, status, reason };
});
}
function clearARRClasses(root) {
root.classList.remove('mcdi-arr-control', 'mcdi-arr-recommended', 'mcdi-arr-useful', 'mcdi-arr-personnel', 'mcdi-arr-risk', 'mcdi-arr-irrelevant', 'mcdi-arr-hidden');
}
function applyARRControlStyles(analyses, state) {
document.querySelectorAll('.mcdi-arr-control, .mcdi-arr-recommended, .mcdi-arr-useful, .mcdi-arr-personnel, .mcdi-arr-risk, .mcdi-arr-irrelevant, .mcdi-arr-hidden').forEach(clearARRClasses);
for (const analysis of analyses) {
const root = analysis.controlRoot;
clearARRClasses(root);
root.classList.add('mcdi-arr-control');
root.dataset.mcdiArrKey = analysis.key;
root.title = `${analysis.text}\n${analysis.reason}`;
if (analysis.status === 'recommended') root.classList.add('mcdi-arr-recommended');
if (analysis.status === 'useful') root.classList.add('mcdi-arr-useful');
if (analysis.status === 'personnel') root.classList.add('mcdi-arr-personnel');
if (analysis.status === 'risk') root.classList.add('mcdi-arr-risk');
if (analysis.status === 'irrelevant' && CONFIG.greyOutIrrelevantARRButtons) root.classList.add('mcdi-arr-irrelevant');
const shouldHide = arrState.usefulOnly && (state.requirements.length || state.personnelRequirements.length) && !['recommended', 'useful', 'personnel', 'risk'].includes(analysis.status);
if (shouldHide) root.classList.add('mcdi-arr-hidden');
}
}
function findARRRoot(analyses) {
if (!analyses.length) return null;
const first = analyses[0].element;
let candidate = first.parentElement;
let best = null;
while (candidate && candidate !== document.body) {
const count = Array.from(candidate.querySelectorAll(ARR_CONTROL_SELECTOR))
.filter(el => isLikelyARRControl(el, getARRText(el))).length;
if (count >= Math.min(8, analyses.length)) best = candidate;
candidate = candidate.parentElement;
}
return best || analyses[0].controlRoot.parentElement || null;
}
function ensureARRPanel(analyses) {
if (!CONFIG.enableARRCommandBoard || !analyses.length) return null;
let panel = document.getElementById(ARR_PANEL_ID);
if (panel) return panel;
panel = document.createElement('div');
panel.id = ARR_PANEL_ID;
const root = findARRRoot(analyses);
if (root && root.parentElement) root.parentElement.insertBefore(panel, root);
else document.body.prepend(panel);
return panel;
}
function renderARRPanel(state, analyses) {
const panel = ensureARRPanel(analyses);
if (!panel) return;
const recommended = analyses.filter(a => a.status === 'recommended');
const useful = analyses.filter(a => a.status === 'useful');
const personnel = analyses.filter(a => a.status === 'personnel');
const risk = analyses.filter(a => a.status === 'risk');
const missingVehicleText = state.requirements.length
? state.requirements.filter(req => req.missing > 0).map(req => `${req.missing} ${req.label}`).join(' · ') || 'Nothing obvious missing'
: 'No readable vehicle requirements yet';
const missingPersonnelText = state.personnelRequirements.length
? state.personnelRequirements.map(req => `${req.count} ${req.label}`).join(' · ')
: '';
const recommendedText = recommended.length
? recommended.slice(0, 10).map(a => `<span class="mcdi-arr-pill">✅ ${escapeHtml(a.text)}</span>`).join('')
: '<span class="mcdi-muted">No direct vehicle button matches yet.</span>';
const personnelText = personnel.length
? `<div style="margin-top:4px;">${personnel.slice(0, 8).map(a => `<span class="mcdi-arr-pill">👥 ${escapeHtml(a.text)}</span>`).join('')}</div>`
: '';
const riskText = risk.length
? `<div class="mcdi-muted">Oversend risk: ${risk.slice(0, 6).map(a => escapeHtml(a.text)).join(' · ')}</div>`
: '';
panel.innerHTML = `
<div class="mcdi-panel-head">
<span>${escapeHtml(CONFIG.arrPanelTitle)}</span>
<span class="mcdi-panel-status mcdi-status-${recommended.length ? 'ready' : (state.requirements.length || state.personnelRequirements.length) ? 'warning' : 'unknown'}">
${recommended.length ? `${recommended.length} recommended` : (state.requirements.length || state.personnelRequirements.length) ? 'No direct match' : 'Scanning'}
</span>
</div>
<div><strong>Still missing:</strong> ${escapeHtml(missingVehicleText)}</div>
${missingPersonnelText ? `<div><strong>Personnel:</strong> ${escapeHtml(missingPersonnelText)}</div>` : ''}
<div style="margin-top:5px;">${recommendedText}</div>
${personnelText}
${riskText}
<div class="mcdi-arr-toolbar">
<label class="mcdi-arr-toggle">
<input type="checkbox" id="mcdi-arr-useful-only" ${arrState.usefulOnly ? 'checked' : ''}>
Show useful only
</label>
<span class="mcdi-muted">Buttons read: ${analyses.length} · Useful: ${recommended.length + useful.length + personnel.length + risk.length}</span>
</div>
`;
}
function applyARRCommandBoard(state) {
if (!CONFIG.enableARRCommandBoard) return;
removeOldBrokenStars();
const analyses = analyseARRControls(state);
applyARRControlStyles(analyses, state);
renderARRPanel(state, analyses);
}
/**********************************************************************
* MAIN LOOP
**********************************************************************/
function applyAll() {
installStyle();
removeOldBrokenStars();
const rows = Array.from(document.querySelectorAll(ROW_SELECTOR));
rows.forEach(decorateVehicleTypeBadge);
const state = buildDispatchState();
renderPanel(state);
applyVehicleRowHighlighting(state);
addRoleChips(state);
applyARRCommandBoard(state);
debugLog(`Rows: ${rows.length}, Vehicle requirements: ${state.requirements.length}, Personnel requirements: ${state.personnelRequirements.length}`);
}
function scheduleApply(delay = CONFIG.refreshDelayMs) {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => {
refreshTimer = null;
applyAll();
observeCorrectTarget();
}, delay);
}
function observeCorrectTarget() {
const target = document.body;
if (!target || observerTarget === target) return;
if (observer) observer.disconnect();
observerTarget = target;
observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
const addedRelevantNode = Array.from(mutation.addedNodes).some(node => {
if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
if (node.id === PANEL_ID || node.id === ARR_PANEL_ID) return false;
if (node.closest && node.closest(`#${PANEL_ID}, #${ARR_PANEL_ID}`)) return false;
return true;
});
if (addedRelevantNode) {
scheduleApply();
return;
}
}
}
});
observer.observe(target, { childList: true, subtree: true });
}
function installHooks() {
document.addEventListener('click', event => {
const target = event.target;
if (!target || !target.closest) return;
const usefulOnly = target.closest('#mcdi-arr-useful-only');
if (usefulOnly) {
arrState.usefulOnly = Boolean(usefulOnly.checked);
saveJSON(ARR_STATE_KEY, arrState);
scheduleApply(0);
return;
}
if (
target.closest('.missing_vehicles_load') ||
target.closest('[data-toggle="tab"]') ||
target.closest('[data-bs-toggle="tab"]') ||
target.closest('a[href="#vehicle_show_all"]') ||
target.closest('a[href="#vehicles"]') ||
target.closest('input[type="checkbox"]') ||
target.closest('button') ||
target.closest('.btn')
) {
scheduleApply(80);
setTimeout(() => scheduleApply(), 450);
setTimeout(() => scheduleApply(), 1100);
}
}, true);
document.addEventListener('change', event => {
const target = event.target;
if (target && target.id === 'mcdi-arr-useful-only') {
arrState.usefulOnly = Boolean(target.checked);
saveJSON(ARR_STATE_KEY, arrState);
scheduleApply(0);
return;
}
if (target && target.matches && target.matches('input[type="checkbox"], select')) scheduleApply(60);
}, true);
}
function init() {
installStyle();
removeOldBrokenStars();
installHooks();
observeCorrectTarget();
scheduleApply(0);
setTimeout(() => scheduleApply(), 600);
setTimeout(() => scheduleApply(), 1600);
setTimeout(() => scheduleApply(), 3000);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true });
else init();
})();