Skip to content

Window Cleaner

hobbs_windowcleaner, current version 1.0.1

Overview

A window cleaner job for VORP. Each town gets a foreman NPC who hands out shifts of different sizes, and a list of windows around town. Players walk from window to window, wipe each one clean in a small mouse minigame, and return to the foreman for their pay. The script tracks cleaned windows, money earned and XP per character in the database, so players climb through ten ranks and unlock one-time milestone bonuses over time.

Weather and daytime matter if you run weathersync: bad weather pays a hazard bonus, a storm closes the job, and the foreman only hands out work during his hours. Everything else, the pay per window, the shift sizes, the ranks and milestones, is yours to tune in config.lua.

How it plays

  1. The player walks up to the foreman, who stands at a blip called "Window cleaner in Saint Denis" (or whichever town). Within 1.5 m a prompt appears: E, "Talk to the foreman".
  2. A menu opens. The foreman says the town is dusty and asks how much work the player wants. The menu lists the shifts for this town (Small job, Half shift, Long shift, Full shift) with window count, estimated minutes, bonus and expected pay. On the side the player sees their own statistics, rank, the weather, the time and whether they are still on a break.
  3. The player picks a shift and hits Accept. The server picks a random starting window and sends the player along the list from there. A blip and a GPS route point to the current window, and a small HUD shows "Windows 0 / 10".
  4. At the window, within 1.5 m, the prompt E, "Clean the window" appears. The player turns to the window, the wiping animation plays, and a dirty pane fills the screen. Hold the mouse button and wipe. The pane counts up in percent; at 99 % the window is done. Each window has a 60 second limit and must take at least 10 seconds, otherwise it does not count.
  5. The HUD ticks up, the blip jumps to the next window. After the last one the route leads back to the foreman.
  6. Talking to the foreman again pays out: windows times pay per window, plus the shift bonus, plus hazard pay in bad weather, plus the rank bonus, plus any milestone the player just reached. Then a 5 minute break starts before the next shift.

Cancelling with /wccancel throws the shift away and gives a 10 minute break instead. I made the cancel break longer than the normal one on purpose, so nobody farms the easy windows and drops the rest.

Requirements

Resource Note
vorp_core Characters, cash and notifications
nss_libs Prompts, blips, point-in-range checks, database access, Discord log
oxmysql Database for the statistics table
weathersync Optional. Supplies weather and time to the server (getWeather, getTime exports). Without it there is no hazard bonus and no storm block, and the hours are only checked on the client

A VORP job is only needed if you turn on Config.Access.requireJob (off by default).

Installation

  1. Unpack the resource into resources and keep the folder name hobbs_windowcleaner.
  2. Run sql/install.sql on your database. It creates the table hobbs_wc_stats with one row per character: windows cleaned, money earned, XP, last job time and reached milestones. The script also creates this table on first start if it is missing, so this step only matters if your database user is not allowed to create tables.
  3. There are no items to add. If you want the job tied to a VORP job, create the job windowcleaner (or whatever you set in Config.Access.jobName) and set requireJob = true.
  4. Add the resource to server.cfg after its dependencies:

    ensure oxmysql
    ensure vorp_core
    ensure nss_libs
    ensure weathersync   # optional
    ensure hobbs_windowcleaner
    
  5. Start the server. Walk to the Saint Denis foreman at 2512.35, -1161.66, 51.99 or the Annesburg foreman at 2966.46, 1419.48, 45.04, check that the blip and the E prompt show, open the menu and take a Small job.

  6. Set Config.Locale and go through config.lua (see below). server_config.lua holds the Discord webhook and never leaves the server, while config.lua is sent to every client.

Note

config.lua, server_config.lua, locales.lua and sql/install.sql are open files. Everything else is encrypted.

Configuration

Settings live in config.lua, the webhook in server_config.lua, all texts in locales.lua.

Language and debug

Config.Locale = 'en'   -- "en" or "de"
Config.Debug  = false  -- console output for debugging

Every text the player sees comes from locales.lua, including the shift names, rank titles and milestone names. To add a language, copy a block, translate it and set Config.Locale to its key. Config.Debug = true prints what the server and client are doing to the console, useful when a window does not count and you want to know why.

Commands

Config.Commands = {
    menu   = false,          -- opens the job menu without the NPC, e.g. 'wcmenu'
    cancel = 'wccancel',     -- cancels the running job
    getPos = false,          -- prints your position as a window line to F8, e.g. 'wcpos'
}

false means the command is not registered at all. Only cancel is on by default, because the HUD tells the player "Cancel with /wccancel". Turn on getPos while you add windows, then switch it off again. menu opens the menu of the nearest town from anywhere, handy for testing, but the server still refuses to start the shift unless the player stands near that foreman.

Notifications

Config.Notify = {
    dict     = 'generic_textures',
    icon     = 'tick',
    warnIcon = 'cross',
    duration = 4000,
}

Notifications go through vorp_core (NotifyLeft). The title is notify_title in locales.lua.

Foreman defaults

Config.NpcDefaults = {
    model           = 'mp_carmela_bink_victim_males_01',
    scenario        = 'WORLD_HUMAN_SMOKE',
    promptKey       = 0xCEFD9220,   -- E
    promptRadius    = 1.5,
    spawnDistance   = 40.0,
    despawnDistance = 50.0,
    tickMs          = 1950,
    blip = {
        enabled  = true,
        sprite   = 'blip_ambient_quartermaster',
        modifier = 'BLIP_MODIFIER_MP_COLOR_32',
    },
}

These apply to every foreman. A location can override single values inside its npc block, for example a different model for one town. The NPC appears when a player comes within spawnDistance, disappears beyond despawnDistance, is invincible and stands still in his scenario. tickMs is how often the client checks the distance; I kept it slow because nobody needs a foreman that reacts within a frame. Set blip.enabled = false to hide a foreman from the map.

Locations

Config.Locations = {
    {
        id    = 'saint_denis',
        label = 'Saint Denis',
        npc = {
            coords  = vector3(2512.35, -1161.66, 51.99),
            heading = 95.79,
        },
        tiers = {
            { id = 'small',  windows = 10, payPerWin = 0.17, bonusPct = 0, estMinutes = 10 },
            { id = 'medium', windows = 20, payPerWin = 0.17, bonusPct = 2, estMinutes = 20 },
            { id = 'large',  windows = 50, payPerWin = 0.17, bonusPct = 3, estMinutes = 40 },
            { id = 'all',    windows = 0,  payPerWin = 0.17, bonusPct = 4, estMinutes = 90 },
        },
        windows = {
            { coords = vector3(2488.78, -1271.51, 49.19), heading = 183.7 },
            { coords = vector3(2491.59, -1271.55, 49.19), heading = 186.7 },
            -- ...
        },
    },
}
Key Meaning
id Unique key of the town. Used in the server session and in the "report to the foreman in ..." message
label Town name shown in the blip, the prompt group and the menu
npc.coords, npc.heading Where the foreman stands. Any key from Config.NpcDefaults can be added here to override it for this town
tiers The shifts offered here, see below
windows One entry per window: where the player stands and the heading he turns to while wiping

Each tier:

Key Meaning
id Key of the texts in locales.lua (tier_<id> and tier_<id>_desc)
windows How many windows the shift covers. 0 means every window of the town
payPerWin Dollars per cleaned window
bonusPct Percent added on top of the base pay when the shift is finished
estMinutes Shown in the menu as "about n minutes". Pure information, nothing is enforced

The script ships with two example towns, Saint Denis with 81 windows and Annesburg with 52. They are examples, replace or extend them. Stand at a window with the getPos command on, face the glass, and the command prints a ready { coords = ..., heading = ... }, line to the F8 console. Window order is the list order: the server picks a random start and continues down the list, wrapping around at the end, so keep neighbouring windows next to each other in the list and the player walks a sensible round.

Example: a town with only 15 windows does not need a Long shift. Remove the large entry and set medium to windows = 15, or simply keep all with windows = 0.

Window prompt and blip

Config.StationPromptRadius = 1.5
Config.StationPromptKey    = 0xCEFD9220 -- E

Config.WindowBlip = {
    sprite     = 'blip_ambient_corpse',
    modifier   = 'BLIP_MODIFIER_MP_COLOR_2',
    routeColor = 'COLOR_YELLOW',
}

The prompt at a window shows within StationPromptRadius. The server allows 3 m more than that when it checks the player's position, so a slightly off coordinate still counts. The blip and route always point to the current window only.

Minigame

Config.Minigame = {
    successThreshold = 99,      -- % clean to finish a window
    dirtIntensityMin = 0.55,    -- dirt at the start (0..1)
    dirtIntensityMax = 0.95,
    squeegeeRadius   = 80,      -- px at 1080 lines, scales with the resolution
    timeLimitSec     = 60,      -- max. time per window
    minTimeSec       = 10,      -- min. time per window (checked by the server)
    serverRequestMinGapMs = 200,
}

Each window starts with a random amount of dirt between the two intensities. The player wipes with the mouse button held; the squeegee clears a circle of squeegeeRadius pixels. When the clean share reaches successThreshold the window is done. When timeLimitSec runs out the pane closes with whatever it has, and below the threshold the prompt simply comes back for another try.

minTimeSec is the anti-cheat side: the server refuses a window reported faster than this, and also refuses two windows closer together than this. serverRequestMinGapMs drops repeated reports that come in faster than 200 ms. Leave both alone unless you know why.

Example: your players find 99 % fiddly on the corners. Set successThreshold = 95 and a bigger squeegeeRadius = 100, the window still takes real wiping but forgives the edges.

Animation

Config.Animation = {
    flag  = 1,
    blend = 8.0,
    clips = {
        { dict = 'amb_work@world_human_clean_window@female_a@base',   name = 'base'   },
        { dict = 'amb_work@world_human_clean_window@female_a@idle_a', name = 'idle_a' },
        -- ...
    },
}

One clip is picked at random each window. The player is turned to the window's heading, frozen in place, and a small dust effect plays on the player. The minimap is hidden while the pane is open.

Access and breaks

Config.Access = {
    cooldownSec       = 300,    -- between two jobs
    cancelCooldownSec = 600,    -- after cancelling a job
    requireJob        = false,  -- true: only characters with the VORP job below
    jobName           = 'windowcleaner',
}

cooldownSec starts when a shift is paid out, cancelCooldownSec when a player cancels. The menu shows the remaining break and greys out the shifts. Breaks are kept in server memory, a server restart clears them.

With requireJob = true the server checks the character's VORP job against jobName on every start. Anyone else gets "You need the window cleaner job". I left this off because on most servers a dusty town should be open to any drifter who wants a few dollars.

Weather and hours

Config.Weather = {
    enabled        = true,
    syncResource   = 'weathersync',
    hazardBonusPct = 10,            -- bonus in bad weather
    hazardTypes    = { 'rain', 'thunder', 'snow', 'drizzle' },
    blockingTypes  = { 'thunderstorm', 'blizzard' },   -- no jobs at all
    sunnyTypes     = { 'sunny', 'clear', 'extrasunny' },
}

Config.Hours = {
    enabled = true,
    startH  = 5,
    endH    = 21,   -- exclusive
}

The server asks syncResource for the weather when a shift starts. A type in hazardTypes adds hazardBonusPct to the whole shift, a type in blockingTypes refuses the start ("Nobody cleans windows in this weather."). sunnyTypes only drives the "Sunny" label in the menu. Weather names are lower case, as weathersync reports them.

Hours are checked on the server against weathersync time. endH is exclusive, so 5 and 21 means 05:00 up to 20:59. A span across midnight works too, for example startH = 20, endH = 4.

Without weathersync the server skips both checks. The client still reads the game's own weather and clock for the menu, so a player may see "closed" in the menu, but the server will not refuse a start on weather or hours. If you do not run weathersync, set Config.Weather.enabled = false and Config.Hours.enabled = false so the menu does not promise what the server does not enforce.

Ranks

Config.Skill = {
    enabled       = true,
    xpPerWindow   = 5,
    levelBonusPct = 1,
    levels = {
        { level = 1,  xp = 0 },
        { level = 2,  xp = 100 },
        { level = 3,  xp = 300 },
        -- ...
        { level = 10, xp = 21000 },
    },
}

Every cleaned window gives xpPerWindow. Each rank above 1 adds levelBonusPct percent to the payout, so a rank 10 cleaner earns 9 % more than an apprentice. Titles are level_<n> in locales.lua (Apprentice, Journeyman, Window cleaner, ... Legendary window cleaner). The menu shows the current rank and a progress bar to the next one; a promotion comes as a notification right after the payout.

Example: you want ranks to mean something. Set levelBonusPct = 3 and the top rank pays 27 % more. The pay per window can then go down a little for everyone.

Milestones

Config.Achievements = {
    enabled = true,
    list = {
        { total = 100,   bonus = 10 },
        { total = 500,   bonus = 25 },
        { total = 1000,  bonus = 50 },
        { total = 5000,  bonus = 100 },
        { total = 10000, bonus = 200 },
        { total = 30000, bonus = 500 },
    },
}

When a character's lifetime window count passes total, the bonus in dollars is paid once, on top of that shift's payout, with a notification naming the milestone. Names are achievement_<total> in locales.lua, so a new entry needs a new text with the same number. Reached milestones are stored per character in the achievements column, as a JSON list of totals.

Discord log

In server_config.lua:

Config.Webhook = {
    enabled = false,
    url     = '',
    name    = 'Window cleaner',
}

With a URL and enabled = true, every payout posts an embed with the character's name, the window count and the amount. The texts are webhook_payout_title and webhook_payout_body in locales.lua. Nothing else is logged.

Commands and keys

Command What it does Default
/wccancel Cancels the running shift, triggers the cancel break on (Config.Commands.cancel)
/wcmenu Opens the menu of the nearest town without talking to the foreman off (Config.Commands.menu)
/wcpos Prints your position and heading as a window line to the F8 console off (Config.Commands.getPos)

The command names are only suggestions from the config comments. Whatever string you put in Config.Commands is the command.

In the world:

Key Where Action
E Near the foreman Talk to the foreman: open the menu, or collect the pay after the last window
E Near the current window Start wiping

In the menu and the minigame:

Key Action
Mouse Click a shift to expand it, click Accept to start
Enter Activates the focused entry, so the menu works by keyboard too
Esc Close the menu, or cancel the current window while wiping
A / A buttons Smaller or larger menu text, saved per player
Mouse button held Wipe the pane

There are no key mappings to bind. The prompt keys are the control hashes in Config.NpcDefaults.promptKey and Config.StationPromptKey.

Exports and events

The script offers no exports. The events between client and server run through nss_libs and are not meant for other resources.

FAQ

The foreman prompt does not show. The player has to be within promptRadius (1.5 m) of npc.coords. The NPC only exists within spawnDistance; if the model did not load, turn on Config.Debug and look for "NPC model did not load" in the F8 console. Also make sure nss_libs started before this resource.

The menu says closed or shows a break, but the player should be allowed. The menu reads weather and time from weathersync when it is running, otherwise from the game's own clock and weather. Check Config.Hours and remember that endH is exclusive. A break comes from a payout (cooldownSec) or a cancel (cancelCooldownSec) and is kept in server memory until it runs out or the server restarts.

Accept does nothing, or "You are not at the foreman." The server checks the player's position against the foreman's coordinates when a shift starts, with promptRadius plus 3 m of slack. This also hits players who opened the menu with the menu command from across town. Walk to the foreman.

A window does not count. Three server checks can refuse it: the window is not the one the server expects (the blip then jumps to the right one), the player is more than StationPromptRadius plus 3 m from the window's coordinates, or the window was done in under minTimeSec. Under the time limit the player sees "That took too long. Try again." and the prompt comes back. If this happens at one specific window, its coordinates are probably off, use getPos to take them again.

Which foreman pays out? Only the one where the shift was taken. At another town the player gets "Report to the foreman in Saint Denis, that is where you took the job."

How do I add a town? Copy a location block, give it a new id and label, set the foreman's coords and heading, add tiers and walk the town with getPos for the window lines. Tier ids reuse the existing texts (small, medium, large, all). A new tier id needs tier_<id> and tier_<id>_desc in every language block.

How is the pay calculated? Windows done times payPerWin, then plus bonusPct of the tier, then plus hazardBonusPct if the weather at the start was in hazardTypes, then plus the rank bonus (levelBonusPct per rank above 1), then plus any milestone bonus reached with this shift. Everything is rounded to cents and paid as cash. Only finished shifts pay; a cancelled shift pays nothing for the windows already done.

What happens on a restart or disconnect? Shifts live in server memory. A disconnect drops the shift without a break, a server restart drops all shifts and all breaks. A restart of just this resource removes the NPCs, blips and HUD on the client, and the player has to take a new shift. Statistics, XP and milestones are in the database and survive everything.

Can two players clean the same town at once? Yes. Each player has his own shift with his own random starting window, and the windows are not locked. Only one shift per character at a time.

Does this cost performance? One distance check per town every tickMs (1950 ms) on the client, and the window prompt is a point-in-range check from nss_libs. The foreman is only spawned within 40 m. The minigame is a canvas in the NUI and runs only while a pane is open. The server does one row read and one update per payout.

Can I use the menu to make players richer than the config? No. The client only sends the tier id and the window index; windows, pay, bonuses and XP are all computed on the server from config.lua.

Changelog

1.0.1

First release.