Skip to content

Doctor Job

hobbs_doctorjob, current version 1.0.0

Overview

This is the whole medical side of a server in one resource. Players catch diseases and pick up wounds from the things that happen to them: a gunfight leaves a bullet in the leg, a fall breaks a bone, a swamp gives malaria, a bad meal gives dysentery. Every condition does something you feel, drains health over time and, left alone, can get worse.

Doctors examine patients on a patient record, see the findings on a body silhouette, treat each one step by step with real material (wash, stitch, splint, serum) and three small minigames for the hard steps. There is an x-ray machine for the things you can't see from outside, an examination bed, medical records per practice, prescriptions, a doctor's journal and a pharmacopoeia anyone can read.

When nobody is around, a country doctor NPC in each practice patches people up for a small fee, and an own death screen lets an unconscious player call a doctor or, with nobody on duty, the emergency doctor. Fist fights knock out instead of killing. Everything a player's body carries is stored per character and survives relogs and restarts.

Fourteen conditions ship: bleeding, animal bite, gangrene, broken bone, sprain, snakebite, plant poisoning, rabies, cold, pneumonia, malaria, cholera, typhoid and dysentery. Forty-five items come with them. English and German are included.

How it plays

As a patient

You get hurt. A revolver hit has a small chance per shot to start a bleed, a knife a much bigger one. You see a message ("You start to bleed. Put on a bandage and see a doctor."), you walk with a limp, sprinting throws you to the ground, and your health drains. Bleeding empties a full health bar in three minutes. A bandage from your pocket stops it for five to ten minutes, a herbal poultice for up to fifteen, yarrow powder a little longer. That buys time, it does not heal. After five bandages the wound is too raw and no bandage holds any more, and after twenty minutes without a doctor there is a ten percent chance the wound turns to gangrene.

The same idea runs through every condition. Something causes it, it has a clock, some items borrow time, and one thing ends it for good:

Condition How you catch it What it does What you can do yourself What ends it
Bleeding Gunshot or blade hit, chance per weapon Limp, falls on sprint, health gone in 3 min Bandage, leather bandage, herbal poultice, yarrow powder (hold 5 to 15 min, 5 times at most) Doctor washes, removes the bullet and stitches
Animal bite Predator bite (wolf, coyote, cougar, panther, bear, alligator) Wound, 5 min drain, fast pulse Same bandages as bleeding Doctor washes and stitches. Untreated 30 min: 10 % rabies
Gangrene Bleeding left untreated for 20 min, 10 % chance 12 min drain, fever Nothing Doctor cuts it out with carbolic acid, then stitches
Broken bone A fall with 91+ damage on an arm or leg Limp, running hurts, 80 min drain, never heals alone Nothing X-ray, then the doctor splints it
Sprain A fall with 40 to 90 damage on a limb Limp, no running or jumping, no health loss Laudanum, wintergreen liniment or a leech ease it. Heals alone in 5 to 30 min Rest. The doctor can only give laudanum or liniment
Snakebite A snake 10 min drain, vomiting, seizures, the poison screen after 5 min Theriac holds it 10 min, twice at most. Antidote cures Antidote, yours or the doctor's
Plant poisoning Only given by other scripts through the export Vomiting, seizures, drains to a quarter of health, gone after 15 min Emetic brew cures Emetic brew
Cold About four minutes in the cold or in water. Clothes add warmth Cough, weak stamina, 100 min drain, gone after an hour Cough syrup or chest elixir cure, teas and the cough drop sometimes Any cold remedy. Untreated 25 min: 20 % pneumonia
Pneumonia A cold left untreated Deep cough, 25 min drain Pleurisy root tea holds it 20 min, three times Doctor applies a mustard plaster
Malaria About eight minutes in a contaminated zone Vomiting, seizures, 30 min drain Fever mixture cures, quinine 70 %. Healing draught, herbal tea and fever bitters borrow an hour or more Fever mixture at the doctor
Cholera A contaminated zone Stomach cramps, 20 min drain Fever mixture cures. Cholera drops, draught and tea borrow time Fever mixture at the doctor
Typhoid A contaminated zone The fastest fever, 15 min drain Fever bitters hold it 15 min, twice Fever mixture, only the doctor's
Dysentery Food or drink, one in a thousand uses Cramps, weak, 50 min drain, never passes alone Poppy tea cures, blackberry cordial sometimes. Stomach tea and wormwood bitters borrow time Doctor gives stomach tea, poppy tea or cordial
Rabies An untreated animal bite Cramps, 20 min drain Nerve drops hold it 10 min, twice Pasteur serum from the doctor

A raised neckerchief freezes the clock in a contaminated zone, so does mosquito oil (malaria) or four thieves vinegar (cholera and typhoid) for half an hour. Laudanum and morphine only work with a prescription in your own name, unless you are a doctor.

/health shows you how you feel, including what nobody can see from outside. Type it any time you are conscious.

When your health runs out you are unconscious. Instead of the usual death screen you lie there with a heartbeat line and two choices. Give up opens after five minutes and puts you at the nearest practice, waking up "a few hours later". Call a doctor is there while a doctor is on duty: a pigeon flies off, every doctor on duty gets a message and a map marker, and your countdown grows by ten minutes. Once a doctor takes the call you see how far away they are. If no doctor is on duty the second choice is the emergency doctor: ten dollars, and an NPC doctor revives you after twenty seconds to two minutes and clears everything. The countdown pauses while someone carries you or you lie on an examination bed. Jailed players get neither call.

While you are conscious, /calldoctor sends the same pigeon. There is a thirty second cooldown between calls.

Giving up does not heal you. Wounds survive death, a bullet is still in your leg when you wake up at the practice.

A fist fight is different: below fifteen percent health you are knocked out instead of killed. You go down for twelve seconds, recover a little health over the next twelve, and can't be knocked out again for five minutes.

As a doctor

You need the job (doctor by default) and, if your server has a duty system wired into the bridge, you need to be on duty. Aim at a player within two metres and the Examine prompt appears. The patient record opens: name, pulse, temperature, a body silhouette with the findings, and tabs for notes and prescriptions.

Every finding shows where it is, what state it is in and what material the next step needs. A lodged bullet goes: x-ray, remove the bullet (carbolic acid or sage wash), stitch (suture thread), done. A through-and-through wound skips the x-ray, a graze is one wash. If the patient wears a bandage you take it off first (free), which puts the wound back to the state it was in, and the patient gets three minutes without drain while you work. Click a finding, the material is taken from your pocket, you play the animation and, for bullet, stitch and splint, a minigame with the mouse. Fail it and the material is gone, the wound stays. Esc cancels a minigame. Diseases without a wound (a cold, malaria, dysentery) are cured outright with one of the listed medicines from your pocket, so at the doctor a stomach tea cures dysentery for good where at home it only holds for half an hour.

Some things you can't tell from the outside. A broken bone looks like a sprain, a lodged bullet like a flesh wound: the record says "Unclear finding, X-ray needed". Have the patient stand at the x-ray spot of the practice, open the record and press X-ray, or type /xray. The patient walks to the spot, the lights go blue, and you click the regions on the skeleton. Every shot reveals what sits in that region. The patient sees the same picture on his side. DEL ends it, after a minute it ends by itself.

A bleeding patient you can't treat right now gets Apply bandage from the record. It uses a bandage from your pocket and holds the bleed like a self-applied one. For a sprain you hand over laudanum or liniment, it eases the pain, nothing more.

An unconscious patient: use a revival syringe (always works) or heart drops (sixty percent) from your inventory, a picker shows the unconscious players within three metres. Both items are doctor-only by default. Diseases stay after a revive, so the record is the next step.

/inspect next to a dead body opens a post-mortem: the same record, read only, with the place of the last hit marked.

At an examination bed you can lie a carried patient down (Lay patient down), examine whoever lies there and take them off again. A conscious patient can lie down and get up by himself.

The Notes tab is the medical record. You must stand inside a practice for it, and the note belongs to that practice: every doctor of the house sees it. Incident is required, diagnosis and treatment are prefilled from the record, a follow-up can be ticked. Deleting needs job grade 4. The Prescription tab writes laudanum or morphine for this patient, up to three doses on a sheet. Every dose taken is struck off.

The doctor's journal item opens all records of all practices, searchable by patient, with a reference of every condition and every medicine. From there you make a transcript, a paper copy of one record that can be handed to a sheriff or a relative. The pharmacopoeia is the same reference as a book anyone can own and read.

A practice blip on the map changes colour while a doctor on duty is in that town. The training patient in Saint Denis lets doctors practise the three minigames without material.

The country doctor

Each practice has an NPC doctor as long as fewer than two doctors are on duty. Treat me clears every condition and heals for five dollars (he sends you off if nothing is wrong). Revive wakes an unconscious player next to you for eight dollars, after thirty seconds of work. Insured patients (see the bridge) pay half. Ten percent of his income can go to the doctors' company account if your money script is wired in.

Requirements

Resource Note
vorp_core Characters, jobs, money, revive
vorp_inventory Items, usable items, prescription and transcript metadata
vorp_lib The player picker for revive, inspect and the country doctor
nss_libs Prompts, blips, client and server event helpers
oxmysql Disease state, practices and medical records
jo_libs Reads whether the neckerchief is up. See the bridge if you want to drop it
vorp_progressbar Optional. Shows the progress of item use and treatment steps, without it the script just waits

Create the job doctor in VORP (or rename the entries in config/doctor.lua). Note deletion looks at job grade 4.

Installation

  1. Unpack the resource into resources and keep the folder name hobbs_doctorjob.
  2. Run sql/install.sql once. It creates three tables: doctor_diseases (one row of disease state per character), doctor_hospitals (one row per practice, filled on start) and doctor_notes (the medical records). It also inserts the forty-five items with INSERT IGNORE, so items you already have keep their labels.
  3. Copy the item images from install/ to vorp_inventory/html/img/Items/ and restart vorp_inventory.
  4. Create the job doctor in VORP and give your doctors a grade. Grade 4 and up may delete records.
  5. Add the resource to server.cfg after its dependencies:

    ensure oxmysql
    ensure vorp_core
    ensure vorp_inventory
    ensure vorp_lib
    ensure nss_libs
    ensure jo_libs
    ensure vorp_progressbar
    ensure hobbs_doctorjob
    
  6. Put your Discord webhook URLs into server_config.lua if you want logs.

  7. Start the server. On first start check the console for warnings about models that don't load, then walk to a practice: the blip is there, the country doctor stands inside, and /health opens your own report.

The script ships with six practices, seven x-ray machines, one examination bed, twelve respawn points and three contaminated zones. They are examples from the game's map. Keep what fits your server and replace the rest.

Warning

The resource brings its own death screen. If you run another death or respawn script, either turn Config.Features.emergency off or remove the other one. Two scripts fighting over a dead player ends badly.

Configuration

Everything lives in config/, one file per topic. All of them are open files. Texts are in locales/en.lua and locales/de.lua, the handbook texts in locales/en_book.lua and locales/de_book.lua.

core.lua

Language, feature switches, commands and the things that don't fit anywhere else.

Config.Locale = 'en'       -- 'en' or 'de'
Config.Debug  = false

Config.Features = {
    xray          = true,    -- x-ray machines; off: fractures and bullets need no x-ray
    countryDoctor = true,    -- NPC doctor in the practices while few doctors are on duty
    emergency     = true,    -- own death screen with doctor call and NPC emergency doctor
    knockout      = true,    -- fist fights knock out instead of killing
    crutch        = false,   -- check the hand position in config/crutch.lua first
    pharmacopoeia = true,    -- the book of remedies, readable by anyone who has it
    doctorBlips   = true,    -- practice blips change colour while a doctor is on duty
    training      = true,    -- training patient for the wound minigames
    minigames     = true,    -- off: a progress bar instead of the wound minigames
    foodDiseases  = true,    -- bad food and water can cause dysentery
}

Config.AdminGroups = { 'admin' }

Config.Commands = {
    selfCheck     = 'health',
    inspect       = 'inspect',
    xray          = 'xray',
    emergencyCall = 'calldoctor',
    acceptCall    = 'acceptcall',
    declineCall   = 'declinecall',
    giveDisease   = 'disease_give',
    healDisease   = 'disease_heal',
}

Config.WorldBaseYear = 1896          -- every shown date is in this year
Config.Insurance     = { share = 0.5 }
Config.ItemImages    = 'nui://vorp_inventory/html/img/Items/'
Config.ProgressBar   = { color = '#c41e1e', width = 30 }

A feature that is off starts nothing and registers no item, so you lose nothing by switching parts off that you don't want. crutch is off because the crutch prop sits in the hand by offset and rotation, and I want you to look at it in game before players see it. minigames = false gives you a plain progress bar for every treatment step, which is the right call for a server with many players who hate minigames. foodDiseases listens to every item use on the server, so if you don't want dysentery at all, turn it off here instead of setting the chance to zero.

Insurance.share is the share an insured patient pays at the country doctor and the emergency doctor. Who counts as insured is decided in bridge/server.lua. With the default bridge nobody is.

hospitals.lua

The practices: blip, catchment area for notes and the country doctor.

Config.Hospitals = {
    ['valentine'] = {
        label      = 'Valentine Doctor',
        showBlip   = true,
        blipSprite = -1739686743,
        coords     = vector3(-285.68, 810.36, 119.39),
        radius     = 30.0,            -- catchment area for notes and treatment
        countryDoctor = {
            enabled    = true,
            jobs       = { 'doctor' }, -- doctors of these jobs close him from two on duty
            healCost   = 5.0,
            reviveCost = 8.0,
            reviveTime = 30,          -- seconds
            coords     = vector3(-288.47, 813.23, 118.39),
            heading    = 170.25,
            range      = 2.0,
        },
    },
    -- saint_denis, rhodes, blackwater, strawberry, armadillo
}

Config.CountryDoctor = {
    recountMs   = 15000,
    selectRange = 5.0,
    companyShare = { rate = 0.10, job = 'doctor' },   -- job = nil turns it off
    models    = { 'cs_drmalcolmmacintosh', 'u_m_m_valdoctor_01', 'cs_creoledoctor',
                  'u_m_m_rhddoctor_01', 'cs_sddoctor_01' },
    scenarios = { 'WORLD_HUMAN_SMOKE_CIGAR', 'MP_LOBBY_STANDING_A', 'WORLD_HUMAN_WRITE_NOTEBOOK' },
}

Config.DoctorBlips = {
    pollMs        = 5000,
    townRadius    = 250.0,    -- for practices outside any named district
    activeColor   = 'BLIP_MODIFIER_MP_COLOR_8',
    inactiveColor = 'BLIP_MODIFIER_MP_COLOR_32',
    recheckMs     = 5000,
}

The key ('valentine') is stored with every medical record, so don't rename a practice once it has notes. Add a practice by copying a block with a new key. radius matters more than it looks: a doctor must stand inside it to write a note, and the patient must be in the same practice. Thirty metres covers a doctor's house with its porch; if your practice is a whole hospital building, raise it. Set countryDoctor = nil for a practice that should have no NPC, for example a prison infirmary. The model is picked from the practice key, not rolled, so every player sees the same face. The company share goes through Bridge.payCompany and is silently skipped when that returns false.

doctor.lua

The job, targeting, revive rules, the minigames and the training patient.

Config.Doctor = {
    jobs          = { 'doctor' },
    requireOnDuty = true,          -- asks Bridge.isOnDuty, which says yes by default

    targeting = { players = true, range = 2.0, scanIdleMs = 1000, scanAimingMs = 100 },
    inspect   = { jobs = { 'doctor' }, range = 5.0 },

    revive = {
        range         = 3.0,
        noHeadshot    = false,     -- country doctor refuses headshot victims
        clearDiseases = false,     -- false: wounds survive death and need treatment
    },

    minigames = {
        ['cut']        = { game = 'suture', settings = { stitches = 5, lives = 2, time = 40 } },
        ['projectile'] = { game = 'bullet', settings = { lives = 2, time = 30, width = 21, shake = 2.2 } },
        ['broken']     = { game = 'splint', settings = { lives = 2, time = 45, holdMs = 5000 } },
    },
}

Config.Training = {
    npcModel   = 'cs_sddoctor_01',
    coords     = vector3(2717.21, -1239.27, 33.03),
    heading    = 245.34,
    range      = 1.5,
    buildRange = 25.0,
    jobsOnly   = true,
}

jobs can hold several job names if your server has a doctor and a nurse job, both get the same rights. clearDiseases = false is the heart of the script: an unconscious player who is revived still carries every wound, and someone has to treat them. Set it to true if you want revive to be a full reset like most servers run it. The minigame settings are difficulty: width is half the channel the forceps may move in, shake the hand tremor, time the seconds you get. If your doctors complain that the bullet is impossible, raise width to 28 and lower shake to 1.5. Each file lists every setting with its meaning.

beds.lua

Examination beds and how a patient lies on them.

Config.Beds = {
    places = {
        { model = 'p_medbed01x', coords = vector3(-286.21, 816.14, 118.39), heading = 10.0,
          label = 'Valentine' },
    },
    extraModels = {},   -- further bed models without a known position

    attach = {
        default = { x = -0.714, y = -0.013, z = 0.968, pitch = 0.0, roll = 0.0, yaw = 180.0 },
        female  = { x = -0.614, y = -0.113, z = 0.908, pitch = 0.0, roll = 0.0, yaw = 180.0 },
    },

    range  = { use = 1.0, find = 1.0, place = 1.3, getUp = 2.0, state = 3.5 },
    holdMs = 900000,    -- longest an unconscious patient is held on the bed
}

A bed is a map prop, the script does not spawn one. places tells the script which prop at which spot is a bed; the nearest one is used. Only Valentine is set up, add a line per bed you want to use in the other practices. extraModels is for bed models you want to count anywhere without a known position. The attach numbers are the head position in bed space; they fit p_medbed01x and you only touch them for another bed model. holdMs must outlast your longest treatment, fifteen minutes is generous.

diseases.lua

Every condition: how it is caught, how fast it drains, what it does to the body and what the doctor needs.

Config.Diseases = {
    ['bleeding'] = {
        drain      = { minutes = 3 },   -- time to empty a full health bar, 0 = no drain
        effectMs   = 6000,
        tickMs     = 250,
        selfHealMs = -1,                -- -1 never, ms, or { minMs, maxMs }
        maxEases   = 5,                 -- bandages before the wound is too raw

        cause = {
            kind = 'damage',
            damageTypeFirearm = 'projectile',
            damageTypeMelee   = 'cut',
            firearmSplit = {            -- must add up to 1.0
                { type = 'grazed',     chance = 0.50 },
                { type = 'perforated', chance = 0.30 },
                { type = 'projectile', chance = 0.20 },
            },
            firearms = { ['weapon_revolver_cattleman'] = 0.08, ['weapon_rifle_elephant'] = 0.15, --[[ ... ]] },
            melee    = { ['weapon_melee_knife'] = 0.4, --[[ ... ]] },
        },

        complication = {
            disease = 'infection', chance = 0.10, afterSeconds = 1200, when = 'untreated',
            chanceByItem = { herbal_poultice = 0.0, bandage_native = 0.5 },
        },

        effects = { loco = 'injured_general', wound = true, ragdollOnSprint = true,
                    healthRecharge = 0, camShake = 0.1 },
    },
    -- brokenBone, sprain, animalBite, snakeBite, plantPoison, cold, malaria,
    -- cholera, typhus, dysentery, infection, rabies, pneumonia
}

Config.DiseaseOrder = { 'bleeding', 'animalBite', 'infection', 'brokenBone', 'sprain', 'snakeBite',
    'plantPoison', 'rabies', 'cold', 'pneumonia', 'malaria', 'cholera', 'typhus', 'dysentery' }

Config.Relief  = { ['sprain'] = { items = { 'laudanum', 'wintergreen_liniment' }, durationMs = 900000 } }
Config.Consume = { items = { ['consumable_stew'] = true, ['water'] = true } }   -- examples
Config.ClothesWarmth = { [0xE06D30CE] = 6.0, --[[ coat ]] [0x2026C46D] = 2.0, --[[ shirt ]] }

drain.minutes is the knob you will touch most. It is the time from full health to the ground, so it scales with max health. Three minutes for bleeding is harsh on purpose: a bandage is cheap and every player should carry one. If your server is slower, twenty minutes gives people time to ride to town. selfHealMs is the other one: a cold passes after an hour by itself, bleeding never does.

The cause block differs by kind. damage rolls per weapon hit, animal per predator bite, bite for snakes, cold from the felt temperature (outside temperature plus the warmth of the clothes worn), zone from standing inside a radius (one point per second, outbreak at maxLevel), consume from item use, complication only from another disease. The zones are three example spots on the map, move them to where your swamps and ghost towns are. blockedByBandana lets a raised neckerchief freeze the zone clock.

complication is what happens when a condition is left alone: after afterSeconds the server rolls once with chance. when = 'untreated' rolls even while a bandage holds, and chanceByItem lets a better bandage lower or pause the roll. maxEases caps how often time-borrowing items may be used before only a doctor helps.

A new disease also needs an entry in Config.DiseaseOrder (a disease missing there is invisible everywhere) and its texts in every locale: the name under diseases, the messages under diseaseTexts, and a handbook entry in the book file.

wounds.lua

The wound states and the doctor's step for each one.

Config.DamageTypes = {
    ['projectile'] = {   -- lodged bullet: x-ray, remove, stitch
        severity    = 4,
        next        = 'cut',
        needsXray   = true,
        dict        = 'amb_work@world_human_repair@med@oilcan@male_a@idle_b',
        anim        = 'idle_d',
        duration    = 5000,
        doctorItems = { 'carbolic_acid', 'sage_wash' },
    },
    ['grazed']     = { severity = 1, next = nil,   duration = 10000, doctorItems = { 'carbolic_acid', 'sage_wash' } },
    ['perforated'] = { severity = 3, next = 'cut', duration = 15000, doctorItems = { 'carbolic_acid', 'sage_wash' } },
    ['cut']        = { severity = 2, next = nil,   duration = 20000, doctorItems = { 'suture_thread' } },
    ['bitten']     = { next = 'cut', duration = 15000, doctorItems = { 'carbolic_acid', 'sage_wash' } },
    ['broken']     = { next = nil,   duration = 20000, doctorItems = { 'wooden_splint' } },
    ['sprained']   = { next = nil,   duration = 6000 },        -- material from Config.Relief
    ['necrotic']   = { next = 'cut', duration = 25000, doctorItems = { 'carbolic_acid' } },
    ['rabid']      = { next = nil,   duration = 12000, doctorItems = { 'rabies_serum' } },
    ['illness']    = { next = nil,   duration = 15000, doctorItems = { 'mustard_plaster' } },
    ['bandaged']   = { next = 'cut', duration = 10000, doctorItems = {} },   -- removing a bandage costs nothing
    ['default']    = { next = nil,   duration = 20000 },       -- the disease's own doctorItems apply
}

A wound is a chain: next is the state after this step, nil means treated. doctorItems is a list, one of them is used up, so carbolic acid and sage wash are two ways to wash a wound. severity stops a second hit from downgrading a wound: a graze on top of a lodged bullet stays a lodged bullet. duration is the animation time and the progress bar when no minigame runs. Want gunshots to be cheaper? Give grazed an empty doctorItems = {} and a wash costs nothing. The progress texts ("Removing the bullet...") sit under treatmentTexts in the locales.

medicines.lua

Every usable item: what it cures, for how long, what it heals, how it is used.

Config.Medicines = {
    ['bandage'] = {
        cureChance = { bleeding = 1.0, animalBite = 1.0 },
        cureMs     = { minMs = 300000, maxMs = 600000 },  -- borrows time, rolled per use
        useMs      = 10000,
        health     = 40,
        healthCore = 20,
        scenario   = 'WORLD_HUMAN_CROUCH_INSPECT',
        damageType = 'bandaged',                          -- wound state it leaves behind
    },
    ['antibiotic'] = {
        cureChance = { cholera = 1.0, malaria = 1.0, typhus = 1.0 },
        cureMs     = -1,                                  -- cures for good
        useMs      = 5000,
        health     = 100,
        healthCore = 50,
        anim       = { dict = 'amb_rest_drunk@world_human_drinking@male_a@idle_a', anim = 'idle_a' },
    },
    ['fever_bitters'] = {
        cureChance = { malaria = 1.0, typhus = 1.0 },
        cureMs     = 5400000,
        cureMsFor  = { typhus = 900000 },                 -- per disease override
        useMs      = 4000, health = 20, healthCore = 10,
    },
    ['morphine'] = { cureChance = {}, cureMs = -1, useMs = 2400, health = 200, healthCore = 100 },
    -- ...
}

Config.Revives = {
    ['syringe']     = { reviveChance = 1.0, useMs = 10000, health = 100, healthCore = 50, jobs = { 'doctor' } },
    ['heart_drops'] = { reviveChance = 0.6, useMs = 10000, health = 60,  healthCore = 30, jobs = { 'doctor' } },
}

Config.Protections = {
    ['mosquito_oil']         = { protects = { 'malaria' },           durationMs = 1800000, useMs = 5000 },
    ['four_thieves_vinegar'] = { protects = { 'cholera', 'typhus' }, durationMs = 1800000, useMs = 5000 },
}

Config.Prescription = {
    item      = 'prescription',
    medicines = { 'laudanum', 'morphine' },
    maxDoses  = 3,
}

cureMs is the difference between a remedy and a cure. -1 ends the disease, a number of milliseconds pauses it and the disease comes back with the item's expired message. cureChance below 1.0 means the item sometimes does nothing, the player still loses it. An item with an empty cureChance is a tonic: it only restores health. health and healthCore are what the player gets after useMs, on top of any cure.

The doctor reads this list too: every item with damageType = 'bandaged' is a bandage he can apply from the record. Revives with jobs = {} lets anyone use the syringe. Protections are client side and gone after a relog, a second use restarts the timer.

To add a medicine, add it here, insert the item, drop an image into the inventory and add its messages under itemTexts and its name under items in both locales. The pharmacopoeia picks it up by itself.

pharmacopoeia.lua

The book of remedies, a plate per item.

Config.Pharmacopoeia = {
    item     = 'pharmacopoeia',
    scenario = 'WORLD_HUMAN_WRITE_NOTEBOOK',   -- reading pose, false = none
    chapters = {
        { id = 'wounds',   items = { 'bandage', 'bandage_native', 'yarrow_powder', 'herbal_poultice', 'leech',
                                     'laudanum', 'wintergreen_liniment', 'crutch' } },
        { id = 'cough',    items = { 'consumable_medicine', 'cough_syrup', 'cold_tea', 'native_tea', 'thyme_balm',
                                     'consumable_cough_drop', 'pleurisy_tea' } },
        { id = 'fever',    items = { 'antibiotic', 'quinine_powder', 'fever_bitters', 'cholera_drops', 'herbal_tea',
                                     'native_medicine', 'mosquito_oil', 'four_thieves_vinegar' } },
        { id = 'stomach',  items = { 'poppy_tea', 'blackberry_cordial', 'stomach_tea', 'wormwood_bitters' } },
        { id = 'poison',   items = { 'antidote', 'theriac', 'emetic_brew', 'nerve_drops' } },
        { id = 'strength', items = { 'syringe', 'heart_drops', 'morphine', 'ginseng_tonic' } },
        { id = 'tools',    items = { 'carbolic_acid', 'sage_wash', 'suture_thread', 'wooden_splint',
                                     'mustard_plaster', 'rabies_serum' } },
    },
    stamps = { crutch = 'doctor' },   -- 'prescription', 'doctor' or false
}

The chapters only decide the order. Effects, cures and stamps ("Prescription only", "For the doctor") are read from the other configs, so you rarely touch this file. An item in no chapter lands in a last chapter called "more", an item the script doesn't use drops out. Chapter titles are under book.chapters in the book locale.

emergency.lua

The death screen, the doctor call and the emergency doctor.

Config.Emergency = {
    respawnSeconds     = 300,    -- until "Give up" opens
    afterCallSeconds   = 600,    -- added once a doctor was called
    callCooldownMs     = 30000,
    callExpireSec      = 1800,   -- an unanswered call lapses

    npcDoctorCost    = 10,
    npcDoctorDelayMs = { 20000, 120000 },

    deathFilter        = false,  -- the game's grey filter
    hideUi             = true,
    ragdollOnRevive    = true,
    controllableCamera = true,   -- free look while lying

    keyRespawn      = 0xCEFD9220,  keyRespawnLabel = 'E',
    keyCallDoctor   = 0xE30CD707,  keyCallLabel    = 'R',
    holdMs          = 1500,

    blockControls   = true,
    allowedControls = { 0x1F6D95E5, 0x9720FCEE },   -- F4, T (chat)

    callMarker = { sprite = -1739686743, color = 'BLIP_MODIFIER_MP_COLOR_8' },

    respawnPoints = {
        { name = 'Valentine',   pos = vector4( -289.49,   820.34,  118.80,  15.83) },
        { name = 'Saint Denis', pos = vector4( 2724.04, -1220.35,   49.39, 267.73) },
        -- Blackwater, Rhodes, Strawberry, Annesburg, Armadillo, Tumbleweed,
        -- Guarma, Wapiti, Colter, Van Horn
    },
    districts = { towns = { --[[ ... ]] }, regions = { --[[ ... ]] }, states = { --[[ ... ]] } },
}

respawnSeconds is how long a player has to wait before giving up, and afterCallSeconds what a doctor call adds. Five plus ten minutes means a doctor across the map still arrives in time. Make respawnSeconds short on a server with few doctors, nobody likes lying in the dirt. allowedControls are the keys that still work while dead, chat and the pause menu by default; add your radio key here. respawnPoints are where a player wakes up after giving up, the nearest one is used, so cover your map. districts turns a position into a place name for the call text ("Someone lies unconscious: Rhodes."), you only touch it for a new map.

xray.lua

The x-ray machine is a map prop, the resource only adds the session.

Config.Xray = {
    requireRange = 1000.0,   -- x-ray only required within this distance from a machine
    jobs         = nil,      -- nil = Config.Doctor.inspect.jobs
    durationMs   = 60000,
    cancelKey    = 0x4AF4D473,   -- DEL
    machineRange = 5.0,      -- doctor to machine
    patientRange = 5.0,      -- patient to his spot

    machine = {
        places = {
            { coords = vector3(-285.47, 814.44, 119.39), heading = 198.29, label = 'Valentine',
              patient = vector4(-284.81, 811.85, 118.39, 23.34) },
            -- Saint Denis, Blackwater, Strawberry, Armadillo, Sisika, Van Horn
        },
    },

    look   = { patientAlpha = 90, skeleton = { enabled = true, mode = 'model', model = 'a_m_m_unicorpse_01' } },
    sound  = { enabled = true, radius = 15.0, volume = 0.5 },
    light  = { enabled = true, range = 9.0, intensity = 90.0 },
    sparks = { enabled = true, radius = 20.0, every = 1600 },
}

coords is the machine, patient where the patient stands and faces. requireRange is a kindness: a bullet only needs an x-ray when a machine is within a kilometre of the patient, so a camp doctor in the mountains can still pull a bullet. Set it very high to require the x-ray everywhere, or turn the feature off and nothing ever needs one. The skeleton can be a ped model (mode = 'model') or drawn lines (mode = 'lines'). Sound, light and sparks are for bystanders and cost a little each frame while a session runs, turn them off on a weak server.

knockout.lua

Config.Knockout = {
    onlyMelee             = true,
    healthPercent         = 15.0,    -- of the real max health
    ragdollMs             = 12000,
    recoverySeconds       = 12,
    regenPercentPerSecond = 2.5,     -- up to healthPercent
    cooldownMs            = 300000,
    screenFx  = 'DeathFailMP01',
    soundName = 'Heartbeat', soundSet = 'RDRO_Sniper_Tension_Sounds', soundEvery = 2000,
}

Below healthPercent in a fist fight the player goes down for ragdollMs, is invincible meanwhile, and gets back a little health. onlyMelee = false would knock out on any damage, which I don't recommend. Never fires in a PvP match (see Bridge.inPvpMatch).

crutch.lua

Off by default. While held: walk only, no running or jumping, so a player with a hurt leg stops falling over. It is put away by itself when mounting, in a coach, swimming, with a weapon drawn, on the bed and on death.

Config.Crutch = {
    item         = 'crutch',
    uses         = 10,                 -- breaks after this many pick-ups
    model        = 'p_crutchjoe01x',
    bone         = 'PH_L_Hand',
    offset       = vector3(0.14, -0.04, 0.01),
    rotation     = vector3(-20.0, -102.0, 2.0),
    maxMoveBlend = 1.0,                -- 1.0 walk, 2.0 run, 3.0 sprint
    jumpKey      = 0xD9D0E1C0,         -- SPACE, blocked while held
}

Check offset and rotation in game before you switch the feature on, the prop should sit under the arm. The item carries its remaining uses in its metadata and the description tells the player how many walks are left.

records.lua

Config.Records = {
    notes = {
        maxLength     = 500,    -- per field, cut not rejected
        followupMax   = 200,
        deleteAny     = true,   -- false: only own notes
        deleteGrade   = 4,      -- minimum job grade to delete
        maxPerPatient = 20,     -- the oldest drops out
    },
    journal    = { item = 'doctor_journal', pageSize = 50 },
    transcript = { item = 'medical_record', seconds = 15, anim = { scenario = 'WORLD_HUMAN_WRITE_NOTEBOOK' } },
}

The journal needs the job, not duty, so a doctor at home can still read up on a patient. deleteGrade is checked on the server. If your doctors' job has only three grades, lower it or nobody can ever delete a note.

effects.lua

How wounds and diseases show on the body: the walk style per body part, the injury pose others can see, the seizure clips, the grace time after a bandage comes off.

Config.Effects = {
    staminaRechargeNormal = 0.20,
    seizureMs    = 5000,
    ragdollMs    = { 4000, 10000 },    -- on the ground after sprinting with a bad leg
    woundGraceMs = 180000,             -- no drain after the doctor removes a bandage, 0 = off

    locomotionRank = { ['right-leg'] = 4, ['left-leg'] = 4, ['torso'] = 3, ['right-arm'] = 2, ['head'] = 1 },
    injuryPose = { enabled = true, flag = 31, checkMs = 500, weaponGraceMs = 2500, wheelGuard = true },
    partAnims  = { ['left-leg'] = { dict = 'mech_loco_m@generic@injured@unarmed@left_leg@idle', clip = 'idle' } },
}

Walk styles are local only, the injuryPose is what other players see. woundGraceMs gives the patient three minutes without drain after the doctor takes a bandage off, long enough for the stitching. You rarely change this file.

body.lua

Bone ids per body part, which silhouette region each part lights, healthy vitals and the pulse ranking for the record. Leave it alone unless you add a new part to the silhouette.

Config.Body = {
    parts = { ['head'] = { 21030 }, ['right-leg'] = { 11440, 11456, 43312, 53081, 55120, 65478 }, --[[ ... ]] },
    healthyVitals = { pulse = 'normal', temperature = { 36.0, 37.5 } },
    pulseRank     = { normal = 0, fast = 1, weak = 2, extreme = 3 },
}

bridge/client.lua and bridge/server.lua

The bridge is where the script meets your other resources. Both files are open, every function has a comment with what it gets and what it must return. Change the body, keep the name.

Function Default Change it when
Bridge.notify(kind, text, itemName) vorp_core left notification You use another notification script
Bridge.inPvpMatch() false You run arenas or boxing rings. There fights leave no wounds and never knock out
Bridge.isJailed() (client and server) Reads the state bag isInJail Your jail script uses another flag
Bridge.respawnJailed() Nothing A jailed player who gave up should wake in his cell
Bridge.isMaskUp() Reads the neckerchief through jo_libs You want to drop jo_libs, then return false here and remove it from fxmanifest.lua
Bridge.clothingWarmth() Config.ClothesWarmth Your HUD has its own warmth table, return it so both agree
Bridge.isOnDuty(source) true You have a duty system. Call exports.hobbs_doctorjob:refreshDuty() when duty changes
Bridge.isInsured(source, charId) false You sell health insurance
Bridge.payCompany(job, amount, reason, payer) false Your society script has company accounts
Bridge.isFood(itemName, metadata) Hunger or thirst in the metadata, or listed in Config.Consume.items Your food items are recognised differently
Bridge.sendDoctorCall(call) false, the built-in notification and marker are used You have a dispatch script. Report back with the callAccepted, callDeclined and callResolved exports
Bridge.closeDoctorCall(patient) Nothing Your dispatch should close the call when the patient is helped

A duty example: if your duty script sets the state bag onDuty, the server bridge becomes

function Bridge.isOnDuty(source)
    return Player(source).state.onDuty == true
end

server_config.lua

Discord logs, never sent to players.

ServerConfig = {
    webhooks = {
        diseases  = '',   -- diseases coming and going, the loudest log
        treatment = '',   -- treatments by doctors and items
        npcdoctor = '',   -- country doctor fees and treatments
        emergency = '',   -- emergency doctor and lapsed calls
        notes     = '',   -- medical records and transcripts
    },
    webhookName   = 'Doctor',
    webhookAvatar = '',
}

Leave a URL empty to keep that channel quiet. diseases fires for every outbreak and every cure on the server, so give it its own channel.

Items

All items are inserted by sql/install.sql, images are in install/. Chances and durations are the defaults from config/medicines.lua.

Bandages and wound care

Item What it does
bandage Holds bleeding or a bite 5 to 10 min, 40 health
bandage_native Leather bandage, same hold, 30 health. Halves the gangrene roll
herbal_poultice Holds 5 to 15 min, 80 health. Pauses the gangrene roll while it holds
yarrow_powder Holds 8 to 12 min, 30 health

Fever

Item What it does
antibiotic Fever mixture. Cures cholera, malaria and typhoid, 100 health
native_medicine Healing draught. Holds cholera and malaria an hour
herbal_tea Holds cholera and malaria an hour, works half the time
fever_bitters Holds malaria 90 min, typhoid 15 min
quinine_powder Cures malaria seven times out of ten
cholera_drops Holds cholera 90 min, dysentery 45 min

Stomach

Item What it does
stomach_tea Holds dysentery 30 min. At the doctor it cures
poppy_tea Cures dysentery
blackberry_cordial Cures dysentery six times out of ten
wormwood_bitters Holds dysentery 20 min

Poison and bites

Item What it does
antidote Cures a snakebite
theriac Holds a snakebite 10 min
emetic_brew Cures plant poisoning, the only thing that does
nerve_drops Holds rabies 10 min

Cough and cold

Item What it does
cold_tea, native_tea Cure a cold half the time
cough_syrup, consumable_medicine Cure a cold
consumable_cough_drop Cures a cold four times out of ten
thyme_balm Cures a cold six times out of ten
pleurisy_tea Cures a cold, holds pneumonia 20 min

Pain and strength

Item What it does
laudanum Eases a sprain 15 min, 50 core. Prescription only
wintergreen_liniment Eases a sprain 10 min
leech Eases a sprain 15 min
morphine 200 health, cures nothing. Prescription only
ginseng_tonic 120 health, cures nothing

Revival and protection

Item What it does
syringe Revival syringe, always works. Doctors only
heart_drops Revives six times out of ten. Doctors only
mosquito_oil Freezes the malaria clock for 30 min
four_thieves_vinegar Freezes the cholera and typhoid clock for 30 min

Doctor's material

Not usable from the inventory (usable = 0), used up by a treatment step from the record.

Item Used for
carbolic_acid Washing a gunshot or bite, cutting out gangrene
sage_wash Washing a gunshot or bite
suture_thread Stitching an open wound
wooden_splint Splinting a fracture
rabies_serum Pasteur serum against rabies
mustard_plaster Pneumonia

Papers and tools

Item What it does
prescription Written by a doctor for one patient, carries medicine, doses and names in its metadata
doctor_journal Opens all medical records, the reference and the medicine list. Doctors only
medical_record A transcript, a frozen copy of one record
pharmacopoeia The book of remedies, for anyone
crutch Only registered while Config.Features.crutch is on

Commands and keys

Command Who What
/health Everyone Your own condition, including what nobody can see
/calldoctor Everyone, conscious Sends a pigeon to the doctors on duty
/inspect Doctor job, on duty Post-mortem of a dead body within 5 m
/xray Doctor job, on duty X-rays whoever stands on the spot of the machine you stand at
/acceptcall Doctor on duty Takes the newest open emergency call
/declinecall Doctor on duty Drops the call you took, or turns the newest one down
/disease_give <disease> [id] Admin group or console Gives a disease, for example /disease_give malaria 3
/disease_heal <disease> [id] Admin group or console Takes it away again

Disease names for the admin commands are the keys of Config.Diseases: bleeding, animalBite, infection, brokenBone, sprain, snakeBite, plantPoison, rabies, cold, pneumonia, malaria, cholera, typhus, dysentery. All command names change in Config.Commands.

Prompts and keys:

Where Key Action
Aiming at a player within 2 m, as doctor L Examine, opens the patient record
Examination bed L Examine whoever lies on it
Examination bed Key shown in the prompt Lie down, Lay patient down (while carrying one), Take patient off
Lying on the bed Key shown in the prompt Get up
Country doctor Key shown in the prompt Treat me, with the price
Country doctor G Revive, with the price
Training patient L, G and the third shown key Practise stitching, splinting, bullet removal
Death screen Hold E Give up, once the countdown is over
Death screen Hold R Call a doctor, or the emergency doctor when nobody is on duty
X-ray DEL Ends the session, for doctor and patient
Wound minigames Mouse and wheel, Esc Steady the hand, cancel
Holding a crutch Space Blocked

The record, journal, pharmacopoeia and transcript are pages with the mouse. The record shows a hint line with every button and the keys it listens to. The prompt keys are RedM control hashes in shared/init.lua (DOC.KEY), the death screen keys in config/emergency.lua, DEL in config/xray.lua.

Exports and events

Server exports

local doc = exports.hobbs_doctorjob

doc:getDiseases(source)          -- list of { id, label, damageType, bone, eased } or nil
doc:hasDisease(source, 'malaria')
doc:isInjured(source)            -- true while anything is active
doc:giveDisease(source, 'plantPoison', 'ate nightshade')   -- false if unknown or already active
doc:healDisease(source, 'plantPoison')
doc:clearDiseases(source, 'arena reset')
doc:listDiseases()               -- { id = label, ... }
doc:isDoctor(source)             -- job and duty
doc:getDoctorsOnDuty()           -- list of server ids
doc:isUnconscious(source)
doc:revive(source)               -- false if not down
doc:refreshDuty()                -- call when your duty script changes duty
doc:callAccepted(patient, doctor)   -- for your own dispatch, see Bridge.sendDoctorCall
doc:callDeclined(patient, doctor)
doc:callResolved(patient)

A herb script that poisons the player who picks the wrong plant:

exports.hobbs_doctorjob:giveDisease(source, 'plantPoison', 'picked ' .. herbName)

Client exports

exports.hobbs_doctorjob:getDiseases()        -- same shape as the server list, for the own player
exports.hobbs_doctorjob:hasDisease('cold')
exports.hobbs_doctorjob:isInjured()
exports.hobbs_doctorjob:isUnconscious()
exports.hobbs_doctorjob:isOnExamBed()
exports.hobbs_doctorjob:openPharmacopoeia()  -- false while the feature is off
exports.hobbs_doctorjob:getInjuryLocomotion()

Events

Fired on the server for other server resources:

Event Arguments
hobbs_doctorjob:diseaseStarted source, diseaseId
hobbs_doctorjob:diseaseCured source, diseaseId, how (doctor, self, admin, script, country_doctor, emergency_doctor, revive)
hobbs_doctorjob:treated doctor, patient, diseaseId, done (done is true when the chain is finished)
hobbs_doctorjob:revived source, by (a doctor's server id, country_doctor, emergency_doctor or script)

On the client the own player gets hobbs_doctorjob:diseaseStarted and hobbs_doctorjob:diseaseCured with the disease id. A HUD that wants to show a wound icon:

AddEventHandler('hobbs_doctorjob:diseaseStarted', function(diseaseId)
    showWoundIcon(diseaseId)
end)

The player state bags doctorInjury, doctorOnBed and doctorCrutch and the global state bags doctorsOnDuty and doctorTowns are set by the script and may be read, not written.

FAQ

The Examine prompt never shows. Three checks. The character's job must be one of Config.Doctor.jobs, the name as VORP stores it. If you wired a duty system into Bridge.isOnDuty, the doctor must be on duty. And you have to aim at the player (right mouse button) within two metres; the prompt is tied to the aimed entity. It also hides while a record is open or while you are down yourself.

The country doctor is not in the practice. He only stands there while fewer than two doctors of his jobs are on duty, and the count includes every job in that list. With Bridge.isOnDuty left at the default, every online doctor counts as on duty. If the model does not load there is a warning in the client console; switch Config.CountryDoctor.models to models your server has.

"There is no x-ray machine here." The doctor must stand within machineRange (5 m) of a coords entry in config/xray.lua, and the patient within patientRange of that entry's patient point. Add a line per practice with a machine. Without one, raise requireRange only if you want the x-ray required everywhere; otherwise a far-away practice simply treats without it.

Players bleed out before they reach a doctor. That is drain.minutes in config/diseases.lua, three for bleeding. Raise it to ten or twenty, or hand out bandages. Every bandage buys five to ten minutes and a doctor can apply one from the record too.

A treatment step says "You are missing the material" but the doctor has the item. The item must sit in the doctor's own inventory, not a saddlebag or chest, and the name must match doctorItems exactly. Doctor's material is inserted with usable = 0; if you created the items yourself, check the name.

A player can't use laudanum. Laudanum and morphine are in Config.Prescription.medicines and need a prescription item in that player's name. A doctor writes one on the Prescription tab of the record, three doses by default. Doctors themselves need none. Remove the medicine from the list to free it.

What happens on a restart? Disease state is written to doctor_diseases on every change and on resource stop, and sent back to the player on start. A running x-ray ends, a patient on a bed is released, a held crutch is put away, the death screen closes. Nothing is lost.

Where does the money go? The country doctor and the emergency doctor take cash from the paying player. Ten percent of the country doctor's fees are offered to Bridge.payCompany for the doctor job; with the default bridge that does nothing and the money simply disappears. A player doctor's treatment costs nothing in the script, what he charges is roleplay.

How do I add a practice, a bed or a contaminated zone? A practice is a new key in Config.Hospitals; it is registered in doctor_hospitals on the next start. A bed is a line in Config.Beds.places with the prop model and its position. A zone is a coords and radius entry under the disease's cause.zones in config/diseases.lua; one point per second inside, outbreak at maxLevel.

Is it heavy? The client scans for a target once a second (ten times a second while aiming), for beds every 750 ms, for the cold every 250 ms and for zones every second. The server recounts doctors every 15 s and rolls complications every 30 s. The x-ray light and sparks are drawn every frame while a session runs; Config.Xray.light and sparks can be turned off. Keep Config.Debug off on a live server.

Can I add a language? Copy locales/en.lua and locales/en_book.lua, rename the table (Locales.fr = { ... }), add both files to fxmanifest.lua under shared_scripts and set Config.Locale = 'fr'. Missing keys fall back to English.

Changelog

1.0.0

First release.